From decf490659c065ffffabca422a8c5f9652ce3779 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Wed, 22 Jul 2026 23:17:33 -0400 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20S2=20=E2=80=94=20hand-rolled=20std?= =?UTF-8?q?io=20client=20(JSON-RPC)=20+=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second slice (docs/MCP.md). Still wired to nothing: S3 is the router line in agent.js. Split pure/IO the way the codebase already does (sse.js thin IO, translate.js pure), so the parts most likely to be wrong are testable without spawning: - mcpProtocol.js (pure) — newline-delimited JSON-RPC framing and tool-result flattening. Both are genuinely easy to get wrong: a pipe does not respect message boundaries, so a framer that assumes "one chunk = one message" works until a big tools/list arrives; and a stray non-JSON log line on stdout (a well-known MCP footgun) must be skipped rather than desync the stream. Results are typed content blocks but tool_result is a STRING, so non-text degrades to a placeholder (never raw base64), failures take the agent's `ERROR: ` prefix, and the whole thing is capped — the generic tool path bounds nothing. - mcpClient.js (IO) — spawn, handshake, request/response, teardown. Four things it owes the agent that the generic tool path does not provide: timeouts (tools run sequentially, so one wedged server would hang the whole run), a capped flattened string, call() that NEVER throws (a failure returns an `ERROR: ` string so one bad server cannot break the turn loop), and deterministic reaping via reapMcp() for New Chat / deactivate — a detached child otherwise outlives the window, exactly the reason reapCommands() exists. Security choices: shell:false (args are 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 or prompt the user. This module deliberately does not decide whether a server MAY start; that gate is S4. Tested against a real subprocess, not mocks: test/fixtures/mock-mcp-server.js speaks just enough protocol to drive the client, with tools that succeed, fail, and never answer, plus --noise/--crash/--sample modes. So the handshake, framing over a live pipe, the timeout, the sampling refusal and process teardown are all actually exercised. Verified: 15 protocol + 13 client tests pass; the full CI gate runs 18 extension suites with 0 failures; requires are child_process + the pure module only (no vscode); and `ps` is clean after the run — dispose() and reapMcp() genuinely kill the process group, asserted in-test by polling the pid. Co-Authored-By: Claude Opus 4.8 --- extensions/levelcode-ai/mcpClient.js | 231 ++++++++++++++++++ extensions/levelcode-ai/mcpProtocol.js | 118 +++++++++ .../test/fixtures/mock-mcp-server.js | 69 ++++++ .../levelcode-ai/test/mcpClient.test.js | 172 +++++++++++++ .../levelcode-ai/test/mcpProtocol.test.js | 126 ++++++++++ 5 files changed, 716 insertions(+) create mode 100644 extensions/levelcode-ai/mcpClient.js create mode 100644 extensions/levelcode-ai/mcpProtocol.js create mode 100644 extensions/levelcode-ai/test/fixtures/mock-mcp-server.js create mode 100644 extensions/levelcode-ai/test/mcpClient.test.js create mode 100644 extensions/levelcode-ai/test/mcpProtocol.test.js diff --git a/extensions/levelcode-ai/mcpClient.js b/extensions/levelcode-ai/mcpClient.js new file mode 100644 index 0000000..0572190 --- /dev/null +++ b/extensions/levelcode-ai/mcpClient.js @@ -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} 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 +}; diff --git a/extensions/levelcode-ai/mcpProtocol.js b/extensions/levelcode-ai/mcpProtocol.js new file mode 100644 index 0000000..a707ce7 --- /dev/null +++ b/extensions/levelcode-ai/mcpProtocol.js @@ -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 +}; diff --git a/extensions/levelcode-ai/test/fixtures/mock-mcp-server.js b/extensions/levelcode-ai/test/fixtures/mock-mcp-server.js new file mode 100644 index 0000000..ec87e85 --- /dev/null +++ b/extensions/levelcode-ai/test/fixtures/mock-mcp-server.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/*--------------------------------------------------------------------------------------------- + * A minimal MCP server, for driving mcpClient.js in tests. Not shipped behaviour — just enough + * protocol to exercise the client: initialize, tools/list, tools/call. + * + * Flags: --noise write a non-JSON line to stdout first (servers really do this; the framer must cope) + * --crash exit right after initialize (tests handshake failure + stderr surfacing) + * --sample send a server→client sampling request (the client must REFUSE it) + * Deliberate tools: echo (ok) · boom (isError) · hang (never answers → tests the call timeout) + * Lives under test/fixtures/ so the CI glob (test/*.test.js) does not run it as a suite. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +const argv = process.argv.slice(2); +const has = (f) => argv.includes(f); + +if (has('--noise')) { process.stdout.write('mock server starting — this line is not JSON\n'); } + +const TOOLS = [ + { name: 'echo', description: 'Echo the input back', inputSchema: { type: 'object', properties: { text: { type: 'string' } } } }, + { name: 'boom', description: 'Always fails', inputSchema: { type: 'object', properties: {} } }, + { name: 'hang', description: 'Never answers', inputSchema: { type: 'object', properties: {} } } +]; + +function send(o) { process.stdout.write(JSON.stringify(o) + '\n'); } +function reply(id, result) { send({ jsonrpc: '2.0', id, result }); } + +function handle(m) { + // Our own outbound sampling request came back refused — record it so the test can assert it. + if (m.id === 9001 && m.error) { process.stderr.write('REFUSED ' + m.error.message + '\n'); return; } + + if (m.method === 'initialize') { + reply(m.id, { protocolVersion: '2025-11-25', capabilities: { tools: {} }, serverInfo: { name: 'mock', version: '1' } }); + if (has('--crash')) { process.stderr.write('mock server crashing on purpose\n'); process.exit(3); } + if (has('--sample')) { send({ jsonrpc: '2.0', id: 9001, method: 'sampling/createMessage', params: {} }); } + return; + } + if (m.method === 'notifications/initialized') { return; } + if (m.method === 'tools/list') { reply(m.id, { tools: TOOLS }); return; } + if (m.method === 'tools/call') { + const name = m.params && m.params.name; + if (name === 'hang') { return; } // no response, ever + if (name === 'boom') { reply(m.id, { content: [{ type: 'text', text: 'it broke' }], isError: true }); return; } + if (name === 'echo') { + const text = (m.params.arguments && m.params.arguments.text) || ''; + reply(m.id, { content: [{ type: 'text', text: 'echo: ' + text }] }); + return; + } + send({ jsonrpc: '2.0', id: m.id, error: { code: -32602, message: 'unknown tool ' + name } }); + return; + } + if (m.method && m.id != null) { send({ jsonrpc: '2.0', id: m.id, error: { code: -32601, message: 'method not found' } }); } +} + +let buf = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buf += chunk; + let i; + while ((i = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, i).trim(); + buf = buf.slice(i + 1); + if (!line) { continue; } + let m; + try { m = JSON.parse(line); } catch { continue; } + handle(m); + } +}); +process.stdin.on('end', () => process.exit(0)); diff --git a/extensions/levelcode-ai/test/mcpClient.test.js b/extensions/levelcode-ai/test/mcpClient.test.js new file mode 100644 index 0000000..c6773fc --- /dev/null +++ b/extensions/levelcode-ai/test/mcpClient.test.js @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Integration tests for extensions/levelcode-ai/mcpClient.js — run: node test/mcpClient.test.js + * + * These spawn a REAL subprocess (test/fixtures/mock-mcp-server.js) and drive the real protocol, so + * the handshake, framing over a live pipe, timeouts and process teardown are actually exercised — + * not mocked. The directions that matter: + * • call() NEVER throws — a failing/hanging/unknown tool comes back as an `ERROR: ` string, because + * agent.js runs tools sequentially and one bad server must not break the turn loop. + * • a server may not drive us — a server→client sampling request is REFUSED. + * • nothing is orphaned — dispose()/reapMcp() actually kill the process. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const path = require('path'); +const C = require('../mcpClient'); + +const FIXTURE = path.join(__dirname, 'fixtures', 'mock-mcp-server.js'); +const server = (extraArgs) => ({ name: 'mock', command: process.execPath, args: [FIXTURE].concat(extraArgs || []), source: 'settings', origin: 'test' }); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** True while the OS still knows this pid. */ +function isRunning(pid) { + try { process.kill(pid, 0); return true; } catch { return false; } +} + +const tests = []; +function test(name, fn) { tests.push([name, fn]); } + +// ---- happy path ------------------------------------------------------------------------------ + +test('connects, completes the handshake, and lists the server\'s tools', async () => { + const h = await C.connect(server()); + try { + assert.strictEqual(h.alive, true); + assert.deepStrictEqual(h.tools.map((t) => t.name).sort(), ['boom', 'echo', 'hang']); + assert.ok(h.tools[0].inputSchema, 'inputSchema must survive — it becomes the agent\'s input_schema'); + assert.deepStrictEqual(C.listActive().map((s) => s.name), ['mock']); + } finally { h.dispose(); } +}); + +test('calls a tool and returns the flattened string', async () => { + const h = await C.connect(server()); + try { assert.strictEqual(await h.call('echo', { text: 'hi' }), 'echo: hi'); } + finally { h.dispose(); } +}); + +test('tolerates a server that logs non-JSON to stdout', async () => { + const h = await C.connect(server(['--noise'])); + try { + assert.strictEqual(h.alive, true); + assert.strictEqual(await h.call('echo', { text: 'still works' }), 'echo: still works'); + } finally { h.dispose(); } +}); + +// ---- failures come back as ERROR strings, never throws ---------------------------------------- + +test('a tool that reports isError returns an ERROR: string (does not throw)', async () => { + const h = await C.connect(server()); + try { + const out = await h.call('boom', {}); + assert.ok(out.startsWith('ERROR: '), out); + assert.ok(out.includes('it broke')); + } finally { h.dispose(); } +}); + +test('a hanging tool times out into an ERROR: string rather than wedging the loop', async () => { + const h = await C.connect(server()); + try { + const started = Date.now(); + const out = await h.call('hang', {}, { timeoutMs: 300 }); + assert.ok(out.startsWith('ERROR: '), out); + assert.ok(/timed out/.test(out), out); + assert.ok(Date.now() - started < 3000, 'must return promptly, not hang'); + } finally { h.dispose(); } +}); + +test('an unknown tool returns an ERROR: string from the server\'s JSON-RPC error', async () => { + const h = await C.connect(server()); + try { + const out = await h.call('does_not_exist', {}); + assert.ok(out.startsWith('ERROR: '), out); + } finally { h.dispose(); } +}); + +test('calling after dispose returns an ERROR: string, not a crash', async () => { + const h = await C.connect(server()); + h.dispose(); + const out = await h.call('echo', { text: 'x' }); + assert.ok(out.startsWith('ERROR: '), out); +}); + +// ---- the server may not drive us -------------------------------------------------------------- + +test('a server→client sampling request is REFUSED (a server cannot drive our model)', async () => { + const h = await C.connect(server(['--sample'])); + try { + await sleep(300); // let the refusal round-trip; the fixture logs it to stderr + assert.ok(/REFUSED/.test(h.stderrTail()), 'expected the server to see a refusal, got: ' + h.stderrTail()); + assert.ok(/sampling\/createMessage/.test(h.stderrTail())); + } finally { h.dispose(); } +}); + +// ---- startup failures ------------------------------------------------------------------------- + +test('a server that dies during the handshake rejects connect() and surfaces its stderr', async () => { + await assert.rejects( + () => C.connect(server(['--crash']), { connectTimeoutMs: 4000 }), + (e) => /failed to start/.test(e.message) && /crashing on purpose|exited/.test(e.message) + ); + assert.deepStrictEqual(C.listActive(), [], 'a failed server must not linger in the registry'); +}); + +test('a command that does not exist rejects connect() with a readable reason', async () => { + await assert.rejects( + () => C.connect({ name: 'nope', command: 'levelcode-no-such-binary-xyz', args: [] }, { connectTimeoutMs: 4000 }), + (e) => /nope/.test(e.message) + ); +}); + +// ---- lifecycle: nothing orphaned --------------------------------------------------------------- + +test('dispose() actually kills the process and clears the registry', async () => { + const h = await C.connect(server()); + const pid = h.pid; + assert.ok(isRunning(pid), 'fixture should be running'); + h.dispose(); + assert.strictEqual(h.alive, false); + assert.deepStrictEqual(C.listActive(), []); + for (let i = 0; i < 40 && isRunning(pid); i++) { await sleep(100); } // SIGTERM → SIGKILL grace + assert.ok(!isRunning(pid), 'process ' + pid + ' survived dispose() — that is an orphan'); +}); + +test('reapMcp() kills every server (New Chat / deactivate path)', async () => { + const a = await C.connect(server()); + const pid = a.pid; + assert.strictEqual(C.listActive().length, 1); + C.reapMcp(); + assert.deepStrictEqual(C.listActive(), []); + for (let i = 0; i < 40 && isRunning(pid); i++) { await sleep(100); } + assert.ok(!isRunning(pid), 'reapMcp left an orphan'); +}); + +test('connectAll tolerates a broken server without denying the good one', async () => { + const { handles, problems } = await C.connectAll( + [server(), { name: 'broken', command: 'levelcode-no-such-binary-xyz', args: [] }], + { connectTimeoutMs: 4000 } + ); + try { + assert.strictEqual(handles.length, 1); + assert.strictEqual(handles[0].name, 'mock'); + assert.strictEqual(problems.length, 1); + assert.strictEqual(problems[0].server, 'broken'); + } finally { C.reapMcp(); } +}); + +// ---- runner ------------------------------------------------------------------------------------ + +(async () => { + let n = 0; + try { + for (const [name, fn] of tests) { await fn(); n++; console.log(' ok - ' + name); } + } catch (e) { + console.error('\n FAILED: ' + ((e && e.stack) || e)); + C.reapMcp(); + process.exit(1); + } + C.reapMcp(); + console.log('\nmcpClient.js: ' + n + ' tests passed.'); + process.exit(0); +})(); diff --git a/extensions/levelcode-ai/test/mcpProtocol.test.js b/extensions/levelcode-ai/test/mcpProtocol.test.js new file mode 100644 index 0000000..91cbf78 --- /dev/null +++ b/extensions/levelcode-ai/test/mcpProtocol.test.js @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Unit tests for extensions/levelcode-ai/mcpProtocol.js — run: node test/mcpProtocol.test.js + * + * Two directions worth protecting: + * FRAMING — a pipe does not respect message boundaries. A message split across chunks must still + * arrive exactly once, and a stray non-JSON line (servers do log to stdout) must be + * skipped rather than desynchronising the stream. + * RESULTS — a tool result becomes a plain string in the transcript, so every content shape must + * degrade readably, failures must carry the agent's `ERROR: ` prefix, and the whole + * thing must be capped (nothing else in the tool path bounds size). + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const P = require('../mcpProtocol'); + +let n = 0; +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } + +// ---- framing --------------------------------------------------------------------------------- + +test('FRAMING: one whole message in one chunk', () => { + const f = P.createFramer(); + assert.deepStrictEqual(f.push('{"jsonrpc":"2.0","id":1,"result":{}}\n'), [{ jsonrpc: '2.0', id: 1, result: {} }]); +}); + +test('FRAMING: a message SPLIT across chunks arrives once, when completed', () => { + const f = P.createFramer(); + assert.deepStrictEqual(f.push('{"jsonrpc":"2.0","id":'), []); // nothing yet + assert.deepStrictEqual(f.push('7,"result":{"ok":true}}'), []); // still no newline + const out = f.push('\n'); + assert.strictEqual(out.length, 1); + assert.strictEqual(out[0].id, 7); + assert.strictEqual(f.pending, 0); +}); + +test('FRAMING: several messages in one chunk all arrive, in order', () => { + const f = P.createFramer(); + const out = f.push('{"id":1}\n{"id":2}\n{"id":3}\n'); + assert.deepStrictEqual(out.map((m) => m.id), [1, 2, 3]); +}); + +test('FRAMING: a stray non-JSON log line is skipped, not fatal, and does not desync', () => { + const f = P.createFramer(); + const out = f.push('listening on stdio…\n{"id":1}\nanother log\n{"id":2}\n'); + assert.deepStrictEqual(out.map((m) => m.id), [1, 2]); +}); + +test('FRAMING: blank lines and \\r\\n are tolerated', () => { + const f = P.createFramer(); + const out = f.push('\n\n{"id":1}\r\n\n{"id":2}\n'); + assert.deepStrictEqual(out.map((m) => m.id), [1, 2]); +}); + +test('FRAMING: a line past the cap throws (peer is not speaking the protocol)', () => { + const f = P.createFramer({ maxLine: 64 }); + assert.throws(() => f.push('x'.repeat(200)), /no newline/); +}); + +test('FRAMING: encode is newline-terminated single-line JSON', () => { + const s = P.encode({ a: 1, b: 'two\nlines' }); + assert.strictEqual(s.slice(-1), '\n'); + assert.strictEqual(s.trim().split('\n').length, 1, 'embedded newlines must stay escaped'); + assert.deepStrictEqual(JSON.parse(s), { a: 1, b: 'two\nlines' }); +}); + +// ---- results --------------------------------------------------------------------------------- + +test('RESULTS: text blocks are joined', () => { + assert.strictEqual(P.flattenContent({ content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] }), 'a\nb'); +}); + +test('RESULTS: isError gets the agent\'s ERROR: prefix', () => { + const out = P.flattenContent({ content: [{ type: 'text', text: 'it broke' }], isError: true }); + assert.ok(out.startsWith('ERROR: '), out); + assert.ok(out.includes('it broke')); +}); + +test('RESULTS: non-text blocks degrade to a readable placeholder', () => { + const out = P.flattenContent({ content: [ + { type: 'image', mimeType: 'image/png', data: 'AAAA' }, + { type: 'audio', data: 'BBBB' }, + { type: 'resource', resource: { uri: 'file:///x', text: 'inline text' } }, + { type: 'resource', resource: { uri: 'file:///y' } }, + { type: 'weird-future-type' } + ] }); + assert.ok(out.includes('[image image/png omitted')); + assert.ok(out.includes('[audio omitted')); + assert.ok(out.includes('inline text'), 'a resource WITH text should contribute its text'); + assert.ok(out.includes('[resource file:///y omitted]')); + assert.ok(out.includes('[weird-future-type content omitted]')); + assert.ok(!out.includes('AAAA'), 'base64 payloads must never reach the transcript'); +}); + +test('RESULTS: structuredContent is used when there are no text blocks', () => { + assert.strictEqual(P.flattenContent({ content: [], structuredContent: { ok: 1 } }), '{"ok":1}'); +}); + +test('RESULTS: output is capped with a marker', () => { + const out = P.flattenContent({ content: [{ type: 'text', text: 'x'.repeat(5000) }] }, { cap: 100 }); + assert.ok(out.length < 200, 'should be capped, got ' + out.length); + assert.ok(/truncated at 100 chars/.test(out)); +}); + +test('RESULTS: empty / malformed results never return empty-string', () => { + for (const r of [null, undefined, {}, { content: [] }, { content: 'nope' }, 42]) { + // @ts-expect-error — deliberately wrong shapes + const out = P.flattenContent(r); + assert.ok(typeof out === 'string' && out.length > 0, JSON.stringify(r) + ' → ' + JSON.stringify(out)); + } +}); + +test('errorText renders a JSON-RPC error object on one line', () => { + assert.strictEqual(P.errorText({ code: -32601, message: 'method not found' }), 'method not found (-32601)'); + assert.strictEqual(P.errorText(null), 'unknown error'); +}); + +test('initializeParams asks for the pinned revision and claims no capabilities', () => { + const p = P.initializeParams('LevelCode', '1.2.3'); + assert.strictEqual(p.protocolVersion, P.PROTOCOL_VERSION); + assert.deepStrictEqual(p.capabilities, {}, 'we consume tools only — claiming more invites server→client requests'); + assert.strictEqual(p.clientInfo.version, '1.2.3'); +}); + +console.log('\nmcpProtocol.js: ' + n + ' tests passed.');