diff --git a/extensions/levelcode-ai/agent.js b/extensions/levelcode-ai/agent.js
index 96bdc7e..02a6877 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, toolCountsByServer, classifyMcpTool, explainMcpRefusal } = 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,27 @@ 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 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 explainMcpRefusal(tu.name, verdict);
+ }
+ 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 +492,79 @@ 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. 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;
+ // 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;
+ } 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 +595,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 +691,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 +729,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 +768,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 0416144..6306015 100644
--- a/extensions/levelcode-ai/extension.js
+++ b/extensions/levelcode-ai/extension.js
@@ -26,6 +26,8 @@ const { formatDiagnosticLines, diagKey } = require('./verify');
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}';
@@ -618,6 +620,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();
@@ -973,6 +976,16 @@ 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.
+ // 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)
@@ -1669,6 +1682,6 @@ function activate(context) {
}
}
-function deactivate() { if (abort) { abort.abort(); } reapCommands(); }
+function deactivate() { if (abort) { abort.abort(); } reapCommands(); reapMcp(); }
module.exports = { activate, deactivate };
diff --git a/extensions/levelcode-ai/mcpConfig.js b/extensions/levelcode-ai/mcpConfig.js
index ed98678..b7dc942 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,23 +58,29 @@ 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.
+ *
+ * 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 safeEnvCopy(raw) {
+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;
@@ -93,7 +102,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
};
@@ -155,6 +164,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 --------------------------------------------------------------------------
/**
@@ -226,6 +259,101 @@ 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;
+}
+
+/**
+ * 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() : '';
+ 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;
+}
+
+/**
+ * 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