@@ -17,6 +17,8 @@ const providers = require('./providers/index');
1717const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, looksReady } = require ( './verify' ) ;
1818const { classifyCommand, dangerLabel } = require ( './commandSafety' ) ;
1919const { loadProjectRules } = require ( './projectRules' ) ;
20+ const { loadServerConfig, buildAgentTools, toolCountsByServer, classifyMcpTool, explainMcpRefusal } = require ( './mcpConfig' ) ;
21+ const { connectAll, getServer } = require ( './mcpClient' ) ;
2022
2123const 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 / \b A P I 4 0 1 \b | s i g n a t u r e h a s e x p i r e d / 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+
470573async 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 ) } ) ;
0 commit comments