Skip to content

Commit 5c512ba

Browse files
authored
Merge pull request #30 from levelcodeai/feat/mcp-s2-client
feat(mcp): S2 — hand-rolled stdio client (JSON-RPC) + lifecycle
2 parents 46df79b + 4264adb commit 5c512ba

5 files changed

Lines changed: 716 additions & 0 deletions

File tree

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* MCP stdio client — spawns a configured server and speaks JSON-RPC to it (docs/MCP.md, S2).
3+
*
4+
* Hand-rolled on purpose (docs/MCP.md D1): the official SDK pulls 93 transitive packages — two web
5+
* frameworks and an OAuth stack — to support the REMOTE transport we do not use, is ESM against this
6+
* CJS extension, and `npm install` never runs for this extension anyway. The surface we need is three
7+
* methods: initialize, tools/list, tools/call. The framing/flattening lives in mcpProtocol.js so the
8+
* fiddly parts are testable without spawning; this file is the process handling.
9+
*
10+
* Four things this owes the agent that the generic tool path does NOT provide:
11+
* • a per-call TIMEOUT — tools run sequentially in agent.js, so one wedged server would hang the
12+
* whole run;
13+
* • a capped, flattened STRING result (mcpProtocol.flattenContent) — tool_result is text;
14+
* • never throwing out of call() — a failure comes back as an `ERROR: …` string, the same shape the
15+
* agent's other tools use, so one bad server cannot break the turn loop;
16+
* • deterministic REAPING. A server is a detached child holding ports/handles; it must die on New
17+
* Chat and on unload, exactly like bgRuns/commandStops (extension.js) — otherwise the next run
18+
* inherits orphans.
19+
*
20+
* SECURITY: spawning a server is arbitrary code execution, so this module deliberately does NOT decide
21+
* whether a server may start — the caller must have cleared it (mcpConfig marks workspace-sourced
22+
* entries, and the launch gate is S4). Two hardening choices here: `shell:false` (args are passed as
23+
* argv, never re-parsed by a shell), and server→client requests (`sampling/*`, `elicitation/*`) are
24+
* explicitly REFUSED rather than ignored — a server must not be able to drive our model.
25+
*--------------------------------------------------------------------------------------------*/
26+
'use strict';
27+
28+
const cp = require('child_process');
29+
const { encode, createFramer, initializeParams, flattenContent, errorText } = require('./mcpProtocol');
30+
31+
const CONNECT_TIMEOUT_MS = 20000; // spawn + initialize + tools/list
32+
const CALL_TIMEOUT_MS = 60000; // one tools/call
33+
const KILL_GRACE_MS = 1500; // SIGTERM → SIGKILL, same as runCommand
34+
const STDERR_KEEP = 2000; // ring buffer for diagnostics ("command not found", "missing key")
35+
36+
/** name → handle, module-scoped so servers survive between agent runs (like bgRuns). */
37+
const active = new Map();
38+
39+
/**
40+
* Start one MCP server and complete the handshake.
41+
* @param {{name:string, command:string, args?:string[], env?:object, source?:string, origin?:string}} server
42+
* @param {{cwd?:string, connectTimeoutMs?:number, clientVersion?:string}} [opts]
43+
* @returns {Promise<object>} handle
44+
*/
45+
async function connect(server, opts) {
46+
const o = opts || {};
47+
const name = server.name;
48+
const connectTimeout = o.connectTimeoutMs || CONNECT_TIMEOUT_MS;
49+
50+
const proc = cp.spawn(server.command, Array.isArray(server.args) ? server.args : [], {
51+
cwd: o.cwd || process.cwd(),
52+
env: Object.assign({}, process.env, server.env || {}),
53+
stdio: ['pipe', 'pipe', 'pipe'],
54+
// Detached so we can kill the whole process group — a server that spawns children (npx, docker)
55+
// would otherwise leave them behind. Mirrors runCommand in agent.js.
56+
detached: true,
57+
shell: false // args are argv, never re-parsed by a shell
58+
});
59+
60+
const framer = createFramer();
61+
const pending = new Map();
62+
let nextId = 1;
63+
let alive = true;
64+
let stderrBuf = '';
65+
let tools = [];
66+
67+
const stderrTail = () => stderrBuf.trim().split('\n').filter(Boolean).slice(-3).join(' | ').slice(0, 300);
68+
69+
const failAll = (why) => {
70+
for (const [, p] of pending) { clearTimeout(p.timer); p.reject(new Error(why)); }
71+
pending.clear();
72+
};
73+
74+
const killGroup = () => {
75+
if (!proc.pid) { return; }
76+
try { process.kill(-proc.pid, 'SIGTERM'); } catch { try { proc.kill('SIGTERM'); } catch { /* gone */ } }
77+
const t = setTimeout(() => {
78+
try { process.kill(-proc.pid, 'SIGKILL'); } catch { try { proc.kill('SIGKILL'); } catch { /* gone */ } }
79+
}, KILL_GRACE_MS);
80+
if (t.unref) { t.unref(); }
81+
};
82+
83+
const dispose = (reason) => {
84+
if (!alive) { return; }
85+
alive = false;
86+
active.delete(name);
87+
failAll(reason || 'MCP server "' + name + '" was stopped');
88+
try { proc.stdin.end(); } catch { /* already closed */ }
89+
killGroup();
90+
};
91+
92+
const request = (method, params, timeoutMs) => new Promise((resolve, reject) => {
93+
if (!alive) { reject(new Error('MCP server "' + name + '" is not running')); return; }
94+
const id = nextId++;
95+
const timer = setTimeout(() => {
96+
pending.delete(id);
97+
reject(new Error(method + ' timed out after ' + timeoutMs + 'ms'));
98+
}, timeoutMs);
99+
if (timer.unref) { timer.unref(); }
100+
pending.set(id, { resolve, reject, timer });
101+
try { proc.stdin.write(encode({ jsonrpc: '2.0', id, method, params: params || {} })); }
102+
catch (e) { clearTimeout(timer); pending.delete(id); reject(e); }
103+
});
104+
105+
const notify = (method, params) => {
106+
try { proc.stdin.write(encode({ jsonrpc: '2.0', method, params: params || {} })); } catch { /* dying */ }
107+
};
108+
109+
proc.stdout.setEncoding('utf8');
110+
proc.stdout.on('data', (chunk) => {
111+
let messages;
112+
try { messages = framer.push(chunk); }
113+
catch (e) { dispose('protocol error from "' + name + '": ' + ((e && e.message) || e)); return; }
114+
for (const msg of messages) {
115+
// A response to something we asked.
116+
if (msg.id != null && pending.has(msg.id)) {
117+
const p = pending.get(msg.id);
118+
pending.delete(msg.id);
119+
clearTimeout(p.timer);
120+
if (msg.error) { p.reject(new Error(errorText(msg.error))); } else { p.resolve(msg.result); }
121+
continue;
122+
}
123+
// A REQUEST from the server (sampling/elicitation/roots). We implement none of them — refuse
124+
// explicitly so the server gets a clean answer instead of hanging, and so it can never drive
125+
// our model or prompt the user behind our back.
126+
if (msg.method && msg.id != null) {
127+
notifyError(msg.id, 'LevelCode does not implement ' + msg.method);
128+
continue;
129+
}
130+
// Notifications (no id) are ignored in v1.
131+
}
132+
});
133+
134+
const notifyError = (id, message) => {
135+
try { proc.stdin.write(encode({ jsonrpc: '2.0', id, error: { code: -32601, message } })); } catch { /* dying */ }
136+
};
137+
138+
proc.stderr.setEncoding('utf8');
139+
proc.stderr.on('data', (c) => { stderrBuf = (stderrBuf + c).slice(-STDERR_KEEP); });
140+
141+
proc.on('error', (e) => {
142+
// Spawn failure (ENOENT: command not found) — surfaces as a rejected connect().
143+
alive = false;
144+
active.delete(name);
145+
failAll('could not start MCP server "' + name + '": ' + ((e && e.message) || e));
146+
});
147+
148+
proc.on('exit', (code, signal) => {
149+
alive = false;
150+
active.delete(name);
151+
const why = 'MCP server "' + name + '" exited (' + (signal || 'code ' + code) + ')';
152+
const tail = stderrTail();
153+
failAll(tail ? why + ': ' + tail : why);
154+
});
155+
156+
const handle = {
157+
name,
158+
pid: proc.pid,
159+
source: server.source,
160+
origin: server.origin,
161+
get alive() { return alive; },
162+
get tools() { return tools; },
163+
stderrTail,
164+
dispose,
165+
/**
166+
* Call a tool. NEVER throws — returns the agent-facing string, with failures as `ERROR: …`
167+
* so a bad server cannot break the turn loop.
168+
*/
169+
async call(toolName, args, callOpts) {
170+
const timeout = (callOpts && callOpts.timeoutMs) || CALL_TIMEOUT_MS;
171+
try {
172+
const result = await request('tools/call', { name: toolName, arguments: args || {} }, timeout);
173+
return flattenContent(result, callOpts);
174+
} catch (e) {
175+
const tail = stderrTail();
176+
return 'ERROR: ' + ((e && e.message) || e) + (tail ? ' — server stderr: ' + tail : '');
177+
}
178+
}
179+
};
180+
181+
try {
182+
await request('initialize', initializeParams('LevelCode', o.clientVersion), connectTimeout);
183+
notify('notifications/initialized');
184+
const listed = await request('tools/list', {}, connectTimeout);
185+
tools = (listed && Array.isArray(listed.tools)) ? listed.tools : [];
186+
} catch (e) {
187+
const tail = stderrTail();
188+
dispose('handshake failed');
189+
throw new Error('MCP server "' + name + '" failed to start: ' + ((e && e.message) || e) + (tail ? ' — ' + tail : ''));
190+
}
191+
192+
active.set(name, handle);
193+
return handle;
194+
}
195+
196+
/**
197+
* Connect a list of servers, tolerating individual failures — one broken server must not deny the user
198+
* the others. Returns the handles that came up plus a problem per server that did not.
199+
*/
200+
async function connectAll(servers, opts) {
201+
const handles = [];
202+
const problems = [];
203+
for (const s of (Array.isArray(servers) ? servers : [])) {
204+
if (active.has(s.name)) { handles.push(active.get(s.name)); continue; }
205+
try { handles.push(await connect(s, opts)); }
206+
catch (e) { problems.push({ level: 'error', server: s.name, message: (e && e.message) || String(e) }); }
207+
}
208+
return { handles, problems };
209+
}
210+
211+
/** Kill every server. Call from newChat() and deactivate(), beside reapCommands(). */
212+
function reapMcp() {
213+
for (const h of Array.from(active.values())) {
214+
try { h.dispose('reaped'); } catch { /* already gone */ }
215+
}
216+
active.clear();
217+
}
218+
219+
/** Live servers, for the /mcp view and the context meter (S5). */
220+
function listActive() {
221+
return Array.from(active.values()).map((h) => ({
222+
name: h.name, source: h.source, origin: h.origin, alive: h.alive, toolCount: h.tools.length
223+
}));
224+
}
225+
226+
function getServer(name) { return active.get(name) || null; }
227+
228+
module.exports = {
229+
connect, connectAll, reapMcp, listActive, getServer,
230+
CONNECT_TIMEOUT_MS, CALL_TIMEOUT_MS
231+
};
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* MCP wire protocol — the PURE half of the stdio client (see docs/MCP.md, S2).
3+
*
4+
* MCP over stdio is JSON-RPC 2.0, one message per line. Two things here are genuinely easy to get
5+
* wrong, so they live away from the process handling and are unit-tested (test/mcpProtocol.test.js):
6+
*
7+
* 1. FRAMING. Chunks off a pipe do not respect message boundaries — one `data` event may carry
8+
* half a message, or three and a half. A framer that assumes "one chunk = one message" works
9+
* right up until a server answers with a big tools/list. It must also SKIP unparseable lines:
10+
* servers writing a stray log line to stdout instead of stderr is a well-known MCP footgun, and
11+
* one such line must not desynchronise the stream.
12+
* 2. RESULT FLATTENING. A tool result is a list of typed content blocks (text / image / audio /
13+
* resource), but the agent's tool_result is a plain STRING (agent.js coerces with String()).
14+
* Non-text blocks have to degrade to a readable placeholder, and the whole thing must be CAPPED —
15+
* the generic tool path has no size limit, so an unbounded result would silently eat the context
16+
* window. read_file caps at 100 KB and run_command at 8 000 chars; this sits between them.
17+
*
18+
* Pure + dependency-free: no child_process, no fs, no vscode. Nothing here does IO.
19+
*--------------------------------------------------------------------------------------------*/
20+
'use strict';
21+
22+
// The revision we ASK for. Current stable as of 2026-07; `initialize` negotiates, and a server that
23+
// answers with an older revision it supports is still fine — we do not hard-fail on a mismatch.
24+
const PROTOCOL_VERSION = '2025-11-25';
25+
26+
// A tool result is injected into the transcript and re-sent on later turns, so bound it.
27+
const RESULT_CAP = 24000;
28+
// A single line bigger than this is a broken or hostile server, not a message.
29+
const MAX_LINE = 4 * 1024 * 1024;
30+
31+
/** One JSON-RPC message, newline-terminated (stdio framing). */
32+
function encode(msg) { return JSON.stringify(msg) + '\n'; }
33+
34+
/**
35+
* Incremental newline-delimited JSON reader.
36+
* `push(chunk)` returns the messages that chunk COMPLETED (possibly none, possibly several).
37+
* Unparseable lines are skipped, not fatal. Throws only if a single line exceeds maxLine, which means
38+
* the peer is not speaking the protocol — the caller should drop the connection.
39+
*/
40+
function createFramer(opts) {
41+
const maxLine = (opts && opts.maxLine) || MAX_LINE;
42+
let buf = '';
43+
return {
44+
push(chunk) {
45+
buf += String(chunk == null ? '' : chunk);
46+
const out = [];
47+
let i;
48+
while ((i = buf.indexOf('\n')) >= 0) {
49+
const line = buf.slice(0, i).trim();
50+
buf = buf.slice(i + 1);
51+
if (!line) { continue; }
52+
let msg;
53+
try { msg = JSON.parse(line); } catch { continue; } // a stray log line must not desync us
54+
if (msg && typeof msg === 'object') { out.push(msg); }
55+
}
56+
if (buf.length > maxLine) {
57+
buf = '';
58+
throw new Error('MCP server sent more than ' + maxLine + ' bytes with no newline');
59+
}
60+
return out;
61+
},
62+
/** Bytes buffered but not yet terminated by a newline (diagnostics/tests). */
63+
get pending() { return buf.length; }
64+
};
65+
}
66+
67+
/** The `initialize` params we send. Capabilities are deliberately empty: we consume tools, nothing more. */
68+
function initializeParams(clientName, clientVersion) {
69+
return {
70+
protocolVersion: PROTOCOL_VERSION,
71+
capabilities: {},
72+
clientInfo: { name: clientName || 'LevelCode', version: clientVersion || '0.0.0' }
73+
};
74+
}
75+
76+
/**
77+
* A `tools/call` result → the single string the agent's tool_result carries.
78+
* Mirrors the agent's error convention: a failure comes back as a string starting with `ERROR: `
79+
* (agent.js treats that prefix as the failure signal) rather than throwing.
80+
*/
81+
function flattenContent(result, opts) {
82+
const cap = (opts && opts.cap) || RESULT_CAP;
83+
if (result == null || typeof result !== 'object') { return '(no output)'; }
84+
const parts = [];
85+
for (const b of (Array.isArray(result.content) ? result.content : [])) {
86+
if (!b || typeof b !== 'object') { continue; }
87+
if (b.type === 'text') { if (typeof b.text === 'string') { parts.push(b.text); } continue; }
88+
if (b.type === 'image') { parts.push('[image' + (b.mimeType ? ' ' + b.mimeType : '') + ' omitted — the transcript is text]'); continue; }
89+
if (b.type === 'audio') { parts.push('[audio omitted — the transcript is text]'); continue; }
90+
if (b.type === 'resource') {
91+
const r = b.resource || {};
92+
if (typeof r.text === 'string') { parts.push(r.text); }
93+
else { parts.push('[resource ' + (r.uri || 'unknown') + ' omitted]'); }
94+
continue;
95+
}
96+
parts.push('[' + String(b.type || 'unknown') + ' content omitted]');
97+
}
98+
// Servers may answer with structuredContent and no text block at all.
99+
if (!parts.length && result.structuredContent !== undefined) {
100+
try { parts.push(JSON.stringify(result.structuredContent)); } catch { /* not serialisable */ }
101+
}
102+
let text = parts.join('\n').trim();
103+
if (text.length > cap) { text = text.slice(0, cap) + '\n…[MCP result truncated at ' + cap + ' chars]'; }
104+
if (result.isError) { return 'ERROR: ' + (text || 'the tool reported a failure'); }
105+
return text || '(no output)';
106+
}
107+
108+
/** A JSON-RPC error object → a one-line message. */
109+
function errorText(err) {
110+
if (!err || typeof err !== 'object') { return 'unknown error'; }
111+
const code = err.code != null ? ' (' + err.code + ')' : '';
112+
return String(err.message || 'error') + code;
113+
}
114+
115+
module.exports = {
116+
encode, createFramer, initializeParams, flattenContent, errorText,
117+
PROTOCOL_VERSION, RESULT_CAP, MAX_LINE
118+
};

0 commit comments

Comments
 (0)