|
| 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 | +}; |
0 commit comments