Skip to content

Commit 2e1b857

Browse files
authored
Merge pull request #31 from levelcodeai/feat/mcp-s3-agent
feat(mcp): wire MCP tools into the agent loop (S3)
2 parents 3c6833f + 499f6e2 commit 2e1b857

5 files changed

Lines changed: 574 additions & 31 deletions

File tree

extensions/levelcode-ai/agent.js

Lines changed: 119 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const providers = require('./providers/index');
1717
const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, looksReady } = require('./verify');
1818
const { classifyCommand, dangerLabel } = require('./commandSafety');
1919
const { loadProjectRules } = require('./projectRules');
20+
const { loadServerConfig, buildAgentTools, toolCountsByServer, classifyMcpTool, explainMcpRefusal } = require('./mcpConfig');
21+
const { connectAll, getServer } = require('./mcpClient');
2022

2123
const SYSTEM_BASE = [
2224
"You are LevelCode's built-in autonomous coding agent. You accomplish the user's goal in their",
@@ -203,12 +205,19 @@ function applyStringEdit(raw, oldStr, newStr) {
203205
return { proposed };
204206
}
205207

206-
/** Compact summary of a tool's input for debug logs (truncate long strings). */
207-
function inputPreview(input) {
208+
/**
209+
* Compact summary of a tool's input for debug logs (truncate long strings).
210+
*
211+
* `redact` is for MCP calls (docs/MCP.md G4): those arguments are headed for a third-party server and
212+
* routinely carry API tokens, record ids, and private query text — and this debug line is POSTED INTO
213+
* THE CHAT when levelcode.ai.debug is on. Log the shape, never the values.
214+
*/
215+
function inputPreview(input, redact) {
208216
if (!input || typeof input !== 'object') { return input; }
209217
const out = {};
210218
for (const k of Object.keys(input)) {
211219
const v = input[k];
220+
if (redact) { out[k] = '‹' + (Array.isArray(v) ? 'array' : typeof v) + '›'; continue; }
212221
out[k] = typeof v === 'string' ? (v.length > 60 ? v.slice(0, 60) + '…(' + v.length + 'ch)' : v) : v;
213222
}
214223
return out;
@@ -444,6 +453,27 @@ async function runTool(tu, ctx) {
444453
ctx.post({ type: 'agentTool', icon: 'sparkle', text: '🧩 using skill: ' + name }); // quiet chip — only on success (🧩 is the marker; 'sparkle' falls back cleanly)
445454
return body; // SKILL.md body → tool_result, steers the next turns
446455
}
456+
// MCP tools (docs/MCP.md S3). An MCP name matches none of the built-in branches above, so every
457+
// MCP call necessarily arrives HERE — which is why the router is one block at one line rather
458+
// than a dispatch scattered through runTool.
459+
const route = ctx.mcpRoutes && ctx.mcpRoutes.get(tu.name);
460+
if (route) {
461+
const verdict = classifyMcpTool(tu.name, ctx.mcp && ctx.mcp.toolPolicy, route.annotations);
462+
if (verdict.approve !== 'allow') {
463+
// S3 deliberately ships no approval CARD (S4 owns it), so anything the user has not
464+
// explicitly allow-listed is REFUSED rather than run — the alternative would be silently
465+
// executing third-party code on the user's behalf with no way to say no. The explanation
466+
// lives in mcpConfig beside the classifier so it can't drift from it (PR #31 review): a
467+
// destructive tool is refused for a reason the allow-list cannot fix, and must not be
468+
// described as allow-listable.
469+
ctx.post({ type: 'agentTool', icon: 'shield', text: '🔌 mcp · refused ' + tu.name + ' — ' + verdict.reason });
470+
return explainMcpRefusal(tu.name, verdict);
471+
}
472+
const server = getServer(route.server);
473+
if (!server || !server.alive) { return 'ERROR: the MCP server "' + route.server + '" is not running.'; }
474+
ctx.post({ type: 'agentTool', icon: 'plug', text: '🔌 ' + route.server + ' · ' + route.tool });
475+
return await server.call(route.tool, input); // never throws — failures come back as `ERROR: …`
476+
}
447477
return 'ERROR: unknown tool ' + tu.name;
448478
} catch (e) {
449479
return 'ERROR: ' + ((e && e.message) || e);
@@ -467,6 +497,79 @@ function isAgentAuthError(e) {
467497
return /\bAPI 401\b|signature has expired/i.test(String((e && e.message) || e));
468498
}
469499

500+
/**
501+
* Connect the user's MCP servers and turn their tools into this run's extra TOOLS entries (docs/MCP.md
502+
* S3). Returns `{tools, routes}` — empty when MCP is unconfigured, which is the overwhelming common case
503+
* and must cost nothing.
504+
*
505+
* TRUST: only `source:'settings'` servers are started. A `.levelcode/mcp.json` is REPO-authored — i.e.
506+
* attacker-controlled for any repo you clone — and a server entry names a process to spawn, so starting
507+
* one here would make this slice exactly the RCE-on-clone hole that the S4 launch gate exists to close.
508+
* They are reported, not silently skipped, so the gap looks like a missing feature rather than a bug.
509+
*
510+
* Never throws: MCP is an enhancement, and no server misconfiguration may take down an agent run.
511+
*/
512+
async function setupMcp(ctx, wsFolders, dbg) {
513+
const empty = { tools: [], routes: null };
514+
const cfg = ctx.mcp || {};
515+
try {
516+
const { servers, problems } = loadServerConfig({
517+
settings: cfg.servers,
518+
folders: wsFolders,
519+
readFile: (abs) => { try { return fs.readFileSync(abs, 'utf8'); } catch { return null; } }
520+
});
521+
for (const p of problems) { dbg('mcp.config', p); }
522+
if (!servers.length) { return empty; }
523+
524+
const deferred = servers.filter((s) => s.source !== 'settings');
525+
if (deferred.length) {
526+
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' });
527+
}
528+
const trusted = servers.filter((s) => s.source === 'settings');
529+
if (!trusted.length) { return empty; }
530+
531+
// Connecting is up-front work: the tool list must be complete before turn one, so there is no
532+
// lazy option. Only the FIRST run of a session pays it — mcpClient keeps handles in a module
533+
// registry, and connectAll reuses a live one.
534+
ctx.post({ type: 'agentStatus', text: 'starting MCP servers…' });
535+
const { handles, problems: connectProblems } = await connectAll(trusted, { cwd: ctx.root });
536+
for (const p of connectProblems) {
537+
dbg('mcp.connect', p);
538+
ctx.post({ type: 'agentTool', icon: 'warning', text: '🔌 mcp · "' + p.server + '" failed to start — ' + p.message });
539+
}
540+
if (!handles.length) { return empty; }
541+
542+
const built = buildAgentTools(handles.map((h) => ({ name: h.name, tools: h.tools })));
543+
for (const p of built.problems) {
544+
dbg('mcp.tools', p);
545+
ctx.post({ type: 'agentTool', icon: 'warning', text: '🔌 mcp · ' + p.message });
546+
}
547+
if (!built.tools.length) { return empty; }
548+
549+
// Show the allow-listed count up front: with no policy set it reads "0/12 allow-listed", which is
550+
// what makes a later refusal legible instead of looking broken. Pass the SAME annotations runTool
551+
// will (PR #31 review) — otherwise a destructive-but-allow-listed tool is counted here yet refused
552+
// there, and the chip lies. The route always exists for a built tool; guard defensively anyway.
553+
const allowed = built.tools.filter((t) => {
554+
const route = built.routes.get(t.name);
555+
return classifyMcpTool(t.name, cfg.toolPolicy, route && route.annotations).approve === 'allow';
556+
}).length;
557+
// Per-server counts come from what was actually EXPOSED (built.routes), not the raw tools/list
558+
// length — a server capped at MAX_TOOLS_PER_SERVER or listing junk exposes fewer than it
559+
// advertised, and showing the raw number would contradict the allowed/total denominator below
560+
// (PR #31 review). A server that exposed nothing still shows "(0)": honest, and a useful signal.
561+
const perServer = toolCountsByServer(built.routes);
562+
const summary = handles.map((h) => h.name + ' (' + (perServer.get(h.name) || 0) + ')').join(', ');
563+
dbg('mcp.ready', { servers: handles.map((h) => h.name), tools: built.tools.length, allowed });
564+
ctx.post({ type: 'agentTool', icon: 'plug', text: '🔌 mcp · ' + summary + ' · ' + allowed + '/' + built.tools.length + ' allow-listed' });
565+
return built;
566+
} catch (e) {
567+
dbg('mcp.failed', { error: (e && e.message) || String(e) });
568+
ctx.post({ type: 'agentTool', icon: 'warning', text: '🔌 mcp · setup failed — ' + ((e && e.message) || e) });
569+
return empty;
570+
}
571+
}
572+
470573
async function runAgent(ctx) {
471574
const root = workspaceRoot();
472575
if (!root) { ctx.post({ type: 'agentError', message: 'Open a folder first — the agent works on your workspace.' }); ctx.post({ type: 'agentDone', reason: 'error' }); return; }
@@ -497,6 +600,17 @@ async function runAgent(ctx) {
497600
// (mirrors the skill chip). Reuses the agentTool → addAgentLine rendering — no webview change.
498601
ctx.post({ type: 'agentTool', icon: 'file', text: '📋 project rules · ' + rules.sources.join(', ') });
499602
}
603+
604+
// MCP (docs/MCP.md S3): the tool list becomes PER-RUN. It was a module constant only because it was
605+
// the same every time; a run's servers are whatever is configured and reachable right now. Same shape
606+
// as `system`/`systemTokensEst` two lines up — built once per run, then used for every turn.
607+
const mcp = await setupMcp(ctx, wsFolders, dbg);
608+
ctx.mcpRoutes = mcp.routes; // runTool's router reads this
609+
const tools = mcp.tools.length ? TOOLS.concat(mcp.tools) : TOOLS;
610+
// Recomputed only when MCP actually contributed tools, so the no-MCP path keeps the module constant
611+
// and pays nothing for a feature it isn't using.
612+
const toolsTokensEst = mcp.tools.length ? Math.round(JSON.stringify(tools).length / 4) : TOOLS_TOKENS_EST;
613+
500614
const messages = ctx.messages;
501615
let step = 0;
502616
let reason = 'done';
@@ -582,7 +696,7 @@ async function runAgent(ctx) {
582696
const turnOpts = {
583697
providerId: ctx.providerId, baseURL: ctx.baseURL,
584698
apiKey: ctx.apiKey, model: ctx.model, maxTokens: perTurnMax, system: system,
585-
messages, tools: TOOLS, signal: ctx.signal,
699+
messages, tools: tools, signal: ctx.signal,
586700
onText: (t) => { streamed = true; textChars += t.length; ctx.post({ type: 'agentDelta', text: t }); },
587701
onToolStart: (name) => {
588702
dbg('tool.start', { name });
@@ -620,7 +734,7 @@ async function runAgent(ctx) {
620734
if (turn.usage.cost_micros != null) { runCostMicros += turn.usage.cost_micros; }
621735
if (turn.usage.credits_remaining_micros != null) { ctx.credits = turn.usage.credits_remaining_micros; }
622736
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 });
623-
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 });
737+
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 });
624738
}
625739

626740
// Reasoning models (e.g. Kimi K2.7 Code) emit <think>…</think> inline in the text
@@ -659,7 +773,7 @@ async function runAgent(ctx) {
659773
for (const tu of toolUses) {
660774
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; }
661775
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; }
662-
dbg('tool.call', { name: tu.name, input: inputPreview(tu.input) });
776+
dbg('tool.call', { name: tu.name, input: inputPreview(tu.input, !!(ctx.mcpRoutes && ctx.mcpRoutes.has(tu.name))) });
663777
const out = await runTool(tu, ctx);
664778
dbg('tool.result', { name: tu.name, chars: String(out).length, error: String(out).startsWith('ERROR') });
665779
results.push({ type: 'tool_result', tool_use_id: tu.id, content: String(out) });

extensions/levelcode-ai/extension.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const { formatDiagnosticLines, diagKey } = require('./verify');
2626
const { loadSkills, skillsMenu, getSkillBody } = require('./skills');
2727
const { openCustomize } = require('./customize');
2828
const { importFromVscode } = require('./importVscode');
29+
const { reapMcp } = require('./mcpClient');
30+
const { userScopedSetting } = require('./mcpConfig');
2931

3032
const SECRET_KEY = 'levelcode.ai.anthropicKey'; // legacy Anthropic key location (kept for back-compat)
3133
const FILE_EXCLUDES = '{**/node_modules/**,**/.git/**,**/out/**,**/dist/**,**/.vscode-test/**,**/*.map}';
@@ -618,6 +620,7 @@ function newChat() {
618620
contextFiles = [];
619621
if (abort) { abort.abort(); }
620622
reapCommands(); // kill any background servers/watchers from the old session
623+
reapMcp(); // …and any MCP servers: they are detached children too
621624
if (review) { review.finalizeAll(); } // drop review UI without reverting the user's files
622625
post({ type: 'reset' });
623626
postContextFiles();
@@ -973,6 +976,16 @@ async function agentFlow(text) {
973976
return (await refreshGatewayToken()) ? await ctx.secrets.get(ACCOUNT_TOKEN_KEY) : null;
974977
},
975978
skills: skillsObj, // M6.5: implicit skills (name+desc menu in SYSTEM + use_skill resolver)
979+
// MCP (docs/MCP.md S3). Config is read HERE and handed in, like verify/commandTimeout, so
980+
// agent.js keeps doing the loading + connecting + naming without reaching for the editor API.
981+
// SECURITY (PR #31 review): these two settings name processes to spawn and tools to auto-allow,
982+
// so they must be USER-authored only — read via inspect() and take the global tier alone, never
983+
// the workspace/folder tier a repo's .vscode/settings.json could supply. They are also declared
984+
// application-scoped in package.json; this is the defense-in-depth half. See userScopedSetting.
985+
mcp: {
986+
servers: userScopedSetting(cfg.inspect('mcp.servers'), {}),
987+
toolPolicy: userScopedSetting(cfg.inspect('mcp.toolPolicy'), {})
988+
},
976989
contextLimit: contextLimitFor(req.providerId, capsModel(req.model)), // Auto → flagship window; the model SENT stays req.model
977990
commandStops: commandStops, // runId → stop() (process-group kill); used by Stop button / ■
978991
commandRuns: bgRuns, // runId → background-process registry (read_command_output reads it)
@@ -1672,6 +1685,6 @@ function activate(context) {
16721685
}
16731686
}
16741687

1675-
function deactivate() { if (abort) { abort.abort(); } reapCommands(); }
1688+
function deactivate() { if (abort) { abort.abort(); } reapCommands(); reapMcp(); }
16761689

16771690
module.exports = { activate, deactivate };

0 commit comments

Comments
 (0)