Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 231 additions & 0 deletions extensions/levelcode-ai/mcpClient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
/*---------------------------------------------------------------------------------------------
* MCP stdio client — spawns a configured server and speaks JSON-RPC to it (docs/MCP.md, S2).
*
* Hand-rolled on purpose (docs/MCP.md D1): the official SDK pulls 93 transitive packages — two web
* frameworks and an OAuth stack — to support the REMOTE transport we do not use, is ESM against this
* CJS extension, and `npm install` never runs for this extension anyway. The surface we need is three
* methods: initialize, tools/list, tools/call. The framing/flattening lives in mcpProtocol.js so the
* fiddly parts are testable without spawning; this file is the process handling.
*
* Four things this owes the agent that the generic tool path does NOT provide:
* • a per-call TIMEOUT — tools run sequentially in agent.js, so one wedged server would hang the
* whole run;
* • a capped, flattened STRING result (mcpProtocol.flattenContent) — tool_result is text;
* • never throwing out of call() — a failure comes back as an `ERROR: …` string, the same shape the
* agent's other tools use, so one bad server cannot break the turn loop;
* • deterministic REAPING. A server is a detached child holding ports/handles; it must die on New
* Chat and on unload, exactly like bgRuns/commandStops (extension.js) — otherwise the next run
* inherits orphans.
*
* SECURITY: spawning a server is arbitrary code execution, so this module deliberately does NOT decide
* whether a server may start — the caller must have cleared it (mcpConfig marks workspace-sourced
* entries, and the launch gate is S4). Two hardening choices here: `shell:false` (args are passed as
* argv, never re-parsed by a shell), and server→client requests (`sampling/*`, `elicitation/*`) are
* explicitly REFUSED rather than ignored — a server must not be able to drive our model.
*--------------------------------------------------------------------------------------------*/
'use strict';

const cp = require('child_process');
const { encode, createFramer, initializeParams, flattenContent, errorText } = require('./mcpProtocol');

const CONNECT_TIMEOUT_MS = 20000; // spawn + initialize + tools/list
const CALL_TIMEOUT_MS = 60000; // one tools/call
const KILL_GRACE_MS = 1500; // SIGTERM → SIGKILL, same as runCommand
const STDERR_KEEP = 2000; // ring buffer for diagnostics ("command not found", "missing key")

/** name → handle, module-scoped so servers survive between agent runs (like bgRuns). */
const active = new Map();

/**
* Start one MCP server and complete the handshake.
* @param {{name:string, command:string, args?:string[], env?:object, source?:string, origin?:string}} server
* @param {{cwd?:string, connectTimeoutMs?:number, clientVersion?:string}} [opts]
* @returns {Promise<object>} handle
*/
async function connect(server, opts) {
const o = opts || {};
const name = server.name;
const connectTimeout = o.connectTimeoutMs || CONNECT_TIMEOUT_MS;

const proc = cp.spawn(server.command, Array.isArray(server.args) ? server.args : [], {
cwd: o.cwd || process.cwd(),
env: Object.assign({}, process.env, server.env || {}),
stdio: ['pipe', 'pipe', 'pipe'],
// Detached so we can kill the whole process group — a server that spawns children (npx, docker)
// would otherwise leave them behind. Mirrors runCommand in agent.js.
detached: true,
shell: false // args are argv, never re-parsed by a shell
});

const framer = createFramer();
const pending = new Map();
let nextId = 1;
let alive = true;
let stderrBuf = '';
let tools = [];

const stderrTail = () => stderrBuf.trim().split('\n').filter(Boolean).slice(-3).join(' | ').slice(0, 300);

const failAll = (why) => {
for (const [, p] of pending) { clearTimeout(p.timer); p.reject(new Error(why)); }
pending.clear();
};

const killGroup = () => {
if (!proc.pid) { return; }
try { process.kill(-proc.pid, 'SIGTERM'); } catch { try { proc.kill('SIGTERM'); } catch { /* gone */ } }
const t = setTimeout(() => {
try { process.kill(-proc.pid, 'SIGKILL'); } catch { try { proc.kill('SIGKILL'); } catch { /* gone */ } }
}, KILL_GRACE_MS);
if (t.unref) { t.unref(); }
};

const dispose = (reason) => {
if (!alive) { return; }
alive = false;
active.delete(name);
failAll(reason || 'MCP server "' + name + '" was stopped');
try { proc.stdin.end(); } catch { /* already closed */ }
killGroup();
};

const request = (method, params, timeoutMs) => new Promise((resolve, reject) => {
if (!alive) { reject(new Error('MCP server "' + name + '" is not running')); return; }
const id = nextId++;
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(method + ' timed out after ' + timeoutMs + 'ms'));
}, timeoutMs);
if (timer.unref) { timer.unref(); }
pending.set(id, { resolve, reject, timer });
try { proc.stdin.write(encode({ jsonrpc: '2.0', id, method, params: params || {} })); }
catch (e) { clearTimeout(timer); pending.delete(id); reject(e); }
});

const notify = (method, params) => {
try { proc.stdin.write(encode({ jsonrpc: '2.0', method, params: params || {} })); } catch { /* dying */ }
};

proc.stdout.setEncoding('utf8');
proc.stdout.on('data', (chunk) => {
let messages;
try { messages = framer.push(chunk); }
catch (e) { dispose('protocol error from "' + name + '": ' + ((e && e.message) || e)); return; }
for (const msg of messages) {
// A response to something we asked.
if (msg.id != null && pending.has(msg.id)) {
const p = pending.get(msg.id);
pending.delete(msg.id);
clearTimeout(p.timer);
if (msg.error) { p.reject(new Error(errorText(msg.error))); } else { p.resolve(msg.result); }
continue;
}
// A REQUEST from the server (sampling/elicitation/roots). We implement none of them — refuse
// explicitly so the server gets a clean answer instead of hanging, and so it can never drive
// our model or prompt the user behind our back.
if (msg.method && msg.id != null) {
notifyError(msg.id, 'LevelCode does not implement ' + msg.method);
continue;
}
// Notifications (no id) are ignored in v1.
}
});

const notifyError = (id, message) => {
try { proc.stdin.write(encode({ jsonrpc: '2.0', id, error: { code: -32601, message } })); } catch { /* dying */ }
};

proc.stderr.setEncoding('utf8');
proc.stderr.on('data', (c) => { stderrBuf = (stderrBuf + c).slice(-STDERR_KEEP); });

proc.on('error', (e) => {
// Spawn failure (ENOENT: command not found) — surfaces as a rejected connect().
alive = false;
active.delete(name);
failAll('could not start MCP server "' + name + '": ' + ((e && e.message) || e));
});

proc.on('exit', (code, signal) => {
alive = false;
active.delete(name);
const why = 'MCP server "' + name + '" exited (' + (signal || 'code ' + code) + ')';
const tail = stderrTail();
failAll(tail ? why + ': ' + tail : why);
});

const handle = {
name,
pid: proc.pid,
source: server.source,
origin: server.origin,
get alive() { return alive; },
get tools() { return tools; },
stderrTail,
dispose,
/**
* Call a tool. NEVER throws — returns the agent-facing string, with failures as `ERROR: …`
* so a bad server cannot break the turn loop.
*/
async call(toolName, args, callOpts) {
const timeout = (callOpts && callOpts.timeoutMs) || CALL_TIMEOUT_MS;
try {
const result = await request('tools/call', { name: toolName, arguments: args || {} }, timeout);
return flattenContent(result, callOpts);
} catch (e) {
const tail = stderrTail();
return 'ERROR: ' + ((e && e.message) || e) + (tail ? ' — server stderr: ' + tail : '');
}
}
};

try {
await request('initialize', initializeParams('LevelCode', o.clientVersion), connectTimeout);
notify('notifications/initialized');
const listed = await request('tools/list', {}, connectTimeout);
tools = (listed && Array.isArray(listed.tools)) ? listed.tools : [];
} catch (e) {
const tail = stderrTail();
dispose('handshake failed');
throw new Error('MCP server "' + name + '" failed to start: ' + ((e && e.message) || e) + (tail ? ' — ' + tail : ''));
}

active.set(name, handle);
return handle;
}

/**
* Connect a list of servers, tolerating individual failures — one broken server must not deny the user
* the others. Returns the handles that came up plus a problem per server that did not.
*/
async function connectAll(servers, opts) {
const handles = [];
const problems = [];
for (const s of (Array.isArray(servers) ? servers : [])) {
if (active.has(s.name)) { handles.push(active.get(s.name)); continue; }
try { handles.push(await connect(s, opts)); }
catch (e) { problems.push({ level: 'error', server: s.name, message: (e && e.message) || String(e) }); }
}
return { handles, problems };
}

/** Kill every server. Call from newChat() and deactivate(), beside reapCommands(). */
function reapMcp() {
for (const h of Array.from(active.values())) {
try { h.dispose('reaped'); } catch { /* already gone */ }
}
active.clear();
}

/** Live servers, for the /mcp view and the context meter (S5). */
function listActive() {
return Array.from(active.values()).map((h) => ({
name: h.name, source: h.source, origin: h.origin, alive: h.alive, toolCount: h.tools.length
}));
}

function getServer(name) { return active.get(name) || null; }

module.exports = {
connect, connectAll, reapMcp, listActive, getServer,
CONNECT_TIMEOUT_MS, CALL_TIMEOUT_MS
};
118 changes: 118 additions & 0 deletions extensions/levelcode-ai/mcpProtocol.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*---------------------------------------------------------------------------------------------
* MCP wire protocol — the PURE half of the stdio client (see docs/MCP.md, S2).
*
* MCP over stdio is JSON-RPC 2.0, one message per line. Two things here are genuinely easy to get
* wrong, so they live away from the process handling and are unit-tested (test/mcpProtocol.test.js):
*
* 1. FRAMING. Chunks off a pipe do not respect message boundaries — one `data` event may carry
* half a message, or three and a half. A framer that assumes "one chunk = one message" works
* right up until a server answers with a big tools/list. It must also SKIP unparseable lines:
* servers writing a stray log line to stdout instead of stderr is a well-known MCP footgun, and
* one such line must not desynchronise the stream.
* 2. RESULT FLATTENING. A tool result is a list of typed content blocks (text / image / audio /
* resource), but the agent's tool_result is a plain STRING (agent.js coerces with String()).
* Non-text blocks have to degrade to a readable placeholder, and the whole thing must be CAPPED —
* the generic tool path has no size limit, so an unbounded result would silently eat the context
* window. read_file caps at 100 KB and run_command at 8 000 chars; this sits between them.
*
* Pure + dependency-free: no child_process, no fs, no vscode. Nothing here does IO.
*--------------------------------------------------------------------------------------------*/
'use strict';

// The revision we ASK for. Current stable as of 2026-07; `initialize` negotiates, and a server that
// answers with an older revision it supports is still fine — we do not hard-fail on a mismatch.
const PROTOCOL_VERSION = '2025-11-25';

// A tool result is injected into the transcript and re-sent on later turns, so bound it.
const RESULT_CAP = 24000;
// A single line bigger than this is a broken or hostile server, not a message.
const MAX_LINE = 4 * 1024 * 1024;

/** One JSON-RPC message, newline-terminated (stdio framing). */
function encode(msg) { return JSON.stringify(msg) + '\n'; }

/**
* Incremental newline-delimited JSON reader.
* `push(chunk)` returns the messages that chunk COMPLETED (possibly none, possibly several).
* Unparseable lines are skipped, not fatal. Throws only if a single line exceeds maxLine, which means
* the peer is not speaking the protocol — the caller should drop the connection.
*/
function createFramer(opts) {
const maxLine = (opts && opts.maxLine) || MAX_LINE;
let buf = '';
return {
push(chunk) {
buf += String(chunk == null ? '' : chunk);
const out = [];
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (!line) { continue; }
let msg;
try { msg = JSON.parse(line); } catch { continue; } // a stray log line must not desync us
if (msg && typeof msg === 'object') { out.push(msg); }
}
if (buf.length > maxLine) {
buf = '';
throw new Error('MCP server sent more than ' + maxLine + ' bytes with no newline');
}
return out;
},
/** Bytes buffered but not yet terminated by a newline (diagnostics/tests). */
get pending() { return buf.length; }
};
}

/** The `initialize` params we send. Capabilities are deliberately empty: we consume tools, nothing more. */
function initializeParams(clientName, clientVersion) {
return {
protocolVersion: PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: clientName || 'LevelCode', version: clientVersion || '0.0.0' }
};
}

/**
* A `tools/call` result → the single string the agent's tool_result carries.
* Mirrors the agent's error convention: a failure comes back as a string starting with `ERROR: `
* (agent.js treats that prefix as the failure signal) rather than throwing.
*/
function flattenContent(result, opts) {
const cap = (opts && opts.cap) || RESULT_CAP;
if (result == null || typeof result !== 'object') { return '(no output)'; }
const parts = [];
for (const b of (Array.isArray(result.content) ? result.content : [])) {
if (!b || typeof b !== 'object') { continue; }
if (b.type === 'text') { if (typeof b.text === 'string') { parts.push(b.text); } continue; }
if (b.type === 'image') { parts.push('[image' + (b.mimeType ? ' ' + b.mimeType : '') + ' omitted — the transcript is text]'); continue; }
if (b.type === 'audio') { parts.push('[audio omitted — the transcript is text]'); continue; }
if (b.type === 'resource') {
const r = b.resource || {};
if (typeof r.text === 'string') { parts.push(r.text); }
else { parts.push('[resource ' + (r.uri || 'unknown') + ' omitted]'); }
continue;
}
parts.push('[' + String(b.type || 'unknown') + ' content omitted]');
}
// Servers may answer with structuredContent and no text block at all.
if (!parts.length && result.structuredContent !== undefined) {
try { parts.push(JSON.stringify(result.structuredContent)); } catch { /* not serialisable */ }
}
let text = parts.join('\n').trim();
if (text.length > cap) { text = text.slice(0, cap) + '\n…[MCP result truncated at ' + cap + ' chars]'; }
if (result.isError) { return 'ERROR: ' + (text || 'the tool reported a failure'); }
return text || '(no output)';
}

/** A JSON-RPC error object → a one-line message. */
function errorText(err) {
if (!err || typeof err !== 'object') { return 'unknown error'; }
const code = err.code != null ? ' (' + err.code + ')' : '';
return String(err.message || 'error') + code;
}

module.exports = {
encode, createFramer, initializeParams, flattenContent, errorText,
PROTOCOL_VERSION, RESULT_CAP, MAX_LINE
};
Loading