diff --git a/docs/MCP.md b/docs/MCP.md index 12c1024..9b072b8 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -182,9 +182,17 @@ servers, mirroring the project-rules chip (`agent.js:493`). - **S4b** — the G1 trust-on-first-use launch gate, which is what finally lets a `.levelcode/mcp.json` server start at all. With it, every gate in §4 is enforced. -**S5 — visibility.** `/mcp` slash command (a near-copy of `/skills`: `chat.html:2124` → -`extension.js:1297`), and an `mcp` segment in the context-usage popover (`contextUsage` already carries a -`tools:` field, `agent.js:618`) so users can see what these servers cost them in context. +**S5 — visibility. DONE.** +- **`/mcp`** lists CONFIGURED servers, not running ones — the questions it answers are "why is my + server not being used?" and "what is this repo asking to run?", and a list of live handles answers + neither. Each row: state (`running` / `needs approval` / `not started`), provenance, the literal + command, and — when live — tool names with their allow-list state, derived from the same + `buildAgentTools` + `classifyMcpTool` the agent uses, so the list can never claim a tool is allowed + while `runTool` refuses it. `summarizeMcp()` is pure and unit-tested. +- **An `MCP tools` segment** in the context-usage popover, carved OUT of the existing `Tools` slice + rather than added alongside it: `tools` already counts every schema, so adding would double-count and + the bar would stop summing to `used`. Hidden entirely when no server contributed one. Every tool + schema rides every turn, so this is the standing cost a chatty server imposes, and it was invisible. **S6 — later.** Streamable HTTP transport + the `2026-07-28` revision; resources/prompts; a "Manage MCP servers…" QuickPick on the `pickModel` pattern (`extension.js:1165-1228`). diff --git a/extensions/levelcode-ai/agent.js b/extensions/levelcode-ai/agent.js index 0ad220a..38aca4a 100644 --- a/extensions/levelcode-ai/agent.js +++ b/extensions/levelcode-ai/agent.js @@ -699,6 +699,16 @@ async function runAgent(ctx) { // 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; + // The MCP SHARE of that, reported separately so the context popover can show what these servers cost + // (docs/MCP.md S5). Every tool schema rides EVERY turn, so a chatty server is a standing tax on the + // window rather than a one-off — and until it has its own segment, that cost is invisible. + // + // Measured as the difference between the two tool arrays rather than by serializing mcp.tools alone: + // the JSON delimiters between entries belong to the total, and attributing them consistently is what + // keeps `tools` and `mcpTools` summing to the number the bar already draws. + const mcpToolsTokensEst = mcp.tools.length + ? Math.max(0, toolsTokensEst - Math.round(JSON.stringify(TOOLS).length / 4)) + : 0; const messages = ctx.messages; let step = 0; @@ -826,7 +836,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: toolsTokensEst, 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, mcpTools: mcpToolsTokensEst, 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 diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index e778b13..8d0eb09 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -26,8 +26,8 @@ const { formatDiagnosticLines, diagKey, createPreviewGate } = require('./verify' const { loadSkills, skillsMenu, getSkillBody } = require('./skills'); const { openCustomize } = require('./customize'); const { importFromVscode } = require('./importVscode'); -const { reapMcp } = require('./mcpClient'); -const { userScopedSetting, isNamespacedToolName, safeCopy } = require('./mcpConfig'); +const { reapMcp, listActive, getServer } = require('./mcpClient'); +const { userScopedSetting, isNamespacedToolName, safeCopy, loadServerConfig, summarizeMcp } = 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}'; @@ -807,6 +807,48 @@ async function saveMcpLaunchTrust(store) { } } +/** + * Everything `/mcp` needs, gathered from the four places that know a piece of it: the merged config, + * the live client registry, the allow-list setting, and the G1 launch-trust store. + * + * Config is re-read here rather than reused from the last run: `/mcp` is most useful exactly when the + * user has just edited settings or a `.levelcode/mcp.json` and is asking why nothing happened. + * + * Never throws — a diagnostic command that can fail is worse than useless, because it fails on the + * broken configuration it exists to explain. + */ +function mcpOverview() { + try { + const cfg = aiConfig(); + const folders = (vscode.workspace.workspaceFolders || []).map((f) => ({ name: f.name, root: f.uri.fsPath })); + const { servers, problems } = loadServerConfig({ + settings: userScopedSetting(cfg.inspect('mcp.servers'), {}), + folders: folders, + readFile: (abs) => { try { return fs.readFileSync(abs, 'utf8'); } catch { return null; } } + }); + + // listActive reports counts; getServer carries the raw tools/list, which is what lets the row + // show per-tool allow state instead of just a number. + const active = listActive().map((h) => { + const handle = getServer(h.name); + return Object.assign({}, h, { tools: (handle && handle.tools) || null }); + }); + + return summarizeMcp({ + servers: servers, + problems: problems, + active: active, + policy: userScopedSetting(cfg.inspect('mcp.toolPolicy'), {}), + trust: mcpLaunchTrust() + }); + } catch (e) { + dbg('mcp.overview.failed', { error: String((e && e.message) || e) }); + return { configured: 0, running: 0, awaitingTrust: 0, servers: [], problems: [ + { level: 'error', message: 'could not read the MCP configuration: ' + String((e && e.message) || e) } + ] }; + } +} + async function mcpAllowAlways(name) { // isNamespacedToolName owns the rule (mcpConfig.js), rather than a second regex here: this used to // hand-roll one that required a `__` separator, which REJECTED names namespaceToolName legitimately @@ -1458,6 +1500,7 @@ class ChatViewProvider { case 'continueAgent': if (!abort) { const g = lastAgentGoal; dbg('continue', { transcriptMsgs: agentMessages.length }); await agentFlow('Continue from where you left off and finish the task. Pick up exactly where you stopped — do not restart or repeat work that is already done.'); lastAgentGoal = g; } break; case 'restoreCheckpoint': dbg('restoreCheckpoint', { turnId: msg.turnId, running: !!abort }); await restoreCheckpoint(msg.turnId); break; case 'listSkills': { const en = aiConfig().get('skills.enabled', true); const idx = en ? loadSkills(ctx.extensionPath, dbg) : new Map(); dbg('listSkills', { enabled: en, count: idx.size }); post({ type: 'skillsList', enabled: en, skills: skillsMenu(idx) }); break; } + case 'listMcp': post({ type: 'mcpList', mcp: mcpOverview() }); break; case 'feedback': dbg('feedback', { value: msg.value, model: msg.model }); await recordFeedback(msg.value, msg.model); break; case 'openFile': await openWorkspaceFile(msg.path); break; case 'reviewKeepFile': dbg('review.keep', { id: msg.id }); review.keepFile(msg.id, 'kept'); break; diff --git a/extensions/levelcode-ai/mcpConfig.js b/extensions/levelcode-ai/mcpConfig.js index 7888f9f..d2a8058 100644 --- a/extensions/levelcode-ai/mcpConfig.js +++ b/extensions/levelcode-ai/mcpConfig.js @@ -592,6 +592,79 @@ function describeMcpLaunch(server) { }; } +// ---- S5: visibility -------------------------------------------------------- + +/** + * What `/mcp` shows: one row per CONFIGURED server, whether or not it is running. + * + * Configured-first, not running-first, on purpose. The interesting questions are "why is my server not + * being used?" and "what is this repo asking to run?", and both are about servers that are absent from + * the live set. A list built from live handles answers neither. + * + * Pure so it can be tested without the editor: the caller passes what it knows. + * + * @param {{servers?:Array, problems?:Array, active?:Array, + * policy?:object, trust?:object}} input + * active — from mcpClient.listActive(), optionally carrying `tools` (the raw tools/list result) so + * per-tool allow state can be reported; without it the row still shows a tool count. + * policy — levelcode.ai.mcp.toolPolicy trust — the G1 workspaceState launch-trust map + */ +function summarizeMcp(input) { + const o = input || {}; + const servers = Array.isArray(o.servers) ? o.servers : []; + const policy = (o.policy && typeof o.policy === 'object' && !Array.isArray(o.policy)) ? o.policy : {}; + const trust = (o.trust && typeof o.trust === 'object' && !Array.isArray(o.trust)) ? o.trust : {}; + + const live = new Map(); + for (const a of (Array.isArray(o.active) ? o.active : [])) { + if (a && typeof a.name === 'string') { live.set(a.name, a); } + } + + const rows = servers.map((s) => { + const handle = live.get(s.name); + const workspace = s.source !== 'settings'; + const row = { + name: s.name, + source: s.source, + origin: s.origin, + running: !!(handle && handle.alive), + // Only meaningful for a repo-authored server; a settings server needs no consent, and + // reporting `false` for one would read as "blocked". + trusted: workspace ? isLaunchTrusted(s, trust) : null, + commandLine: describeMcpLaunch(s).commandLine, + tools: handle ? (handle.toolCount || 0) : 0, + allowed: null, + toolNames: [] + }; + + // Names and allow state come from the SAME functions the agent uses, so the list cannot claim a + // tool is allow-listed while runTool refuses it. + if (handle && Array.isArray(handle.tools) && handle.tools.length) { + const built = buildAgentTools([ { name: s.name, tools: handle.tools } ]); + row.tools = built.tools.length; + row.toolNames = built.tools.map((t) => t.name); + row.allowed = built.tools.filter((t) => { + const route = built.routes.get(t.name); + return classifyMcpTool(t.name, policy, route && route.annotations).approve === 'allow'; + }).length; + } + return row; + }); + + return { + configured: rows.length, + running: rows.filter((r) => r.running).length, + // Repo-authored servers still waiting on the G1 consent card — the answer to "why is it not + // running?" for the case that is a gate rather than a fault. + awaitingTrust: rows.filter((r) => r.trusted === false && !r.running).length, + servers: rows, + problems: (Array.isArray(o.problems) ? o.problems : []).map((p) => ({ + level: String((p && p.level) || 'warn'), + message: String((p && p.message) || '') + })) + }; +} + function describeMcpCall(name, args, route) { const r = route || {}; const fallback = String(name == null ? '' : name).split(NAME_SEPARATOR); @@ -605,6 +678,6 @@ module.exports = { loadServerConfig, userScopedSetting, namespaceToolName, isNamespacedToolName, assignToolNames, buildAgentTools, safeCopy, toolCountsByServer, classifyMcpTool, explainMcpRefusal, describeMcpCall, - launchFingerprint, isLaunchTrusted, rememberLaunchTrust, describeMcpLaunch, + launchFingerprint, isLaunchTrusted, rememberLaunchTrust, describeMcpLaunch, summarizeMcp, BUILTIN_TOOL_NAMES, MAX_TOOL_NAME, MAX_TOOL_DESC, MAX_ARG_CHARS, MAX_SERVERS, MAX_TOOLS_PER_SERVER, WORKSPACE_CONFIG_PATH }; diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html index 4df11d1..5ad43e3 100644 --- a/extensions/levelcode-ai/media/chat.html +++ b/extensions/levelcode-ai/media/chat.html @@ -61,6 +61,26 @@ .skrow { border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px; } .skname { display: block; font-weight: 600; font-family: var(--vscode-editor-font-family, monospace); color: var(--vscode-foreground); } .skdesc { display: block; color: var(--muted); font-size: 12px; margin-top: 3px; line-height: 1.45; } + /* /mcp command — configured servers, their state, and what they would run */ + .mcprow { border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px; } + .mcptop { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; } + .mcptop .skname { display: inline; } + .mcpstate { font-size: 11px; padding: 1px 6px; border-radius: 999px; border: 1px solid var(--border); } + .mcpstate.ok { color: var(--ok, #98c379); border-color: color-mix(in srgb, var(--ok, #98c379) 45%, transparent); } + .mcpstate.warn { color: #d19a66; border-color: color-mix(in srgb, #d19a66 45%, transparent); } + .mcpstate.off { color: var(--muted); } + .mcpsrc { font-size: 11px; color: var(--muted); margin-left: auto; } + /* The command is the security-relevant line, so it is monospaced and wraps rather than truncating — + an elided command line is exactly the part a reader needs whole. */ + .mcpcmd { + font-family: var(--vscode-editor-font-family, monospace); font-size: 11.5px; + color: var(--muted); margin-top: 4px; overflow-wrap: anywhere; white-space: pre-wrap; + } + .mcptools { margin-top: 5px; display: flex; flex-wrap: wrap; gap: 4px; } + .mcptools code { font-size: 11px; opacity: .85; } + .mcpproblem { font-size: 12px; margin-top: 7px; line-height: 1.45; } + .mcpproblem.warn { color: #d19a66; } + .mcpproblem.bad { color: var(--vscode-errorForeground, #e06c75); } /* ---- jump-to-latest affordance (shown only when auto-scroll is paused) ---- */ #jumpLatest { position: fixed; right: 16px; bottom: 96px; z-index: 20; @@ -2410,26 +2430,37 @@ let ctxState = null; function fmtTok(n){ n = Math.max(0, Math.round(n)); if (n >= 1000000){ return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; } return n >= 1000 ? (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k' : String(n); } // Break used tokens into segments (system + tools ride in every request; the remainder is messages). - function ctxSegments(used, limit, system, tools){ - used = Math.max(0, used || 0); limit = Math.max(1, limit || 200000); - const overhead = Math.min(used, Math.max(0, system || 0) + Math.max(0, tools || 0)); - const sys = Math.min(Math.max(0, system || 0), overhead); + function ctxSegments(used, limit, system, tools, mcpTools){ + // `x || 0` alone is not enough: it rescues NaN/null but passes a STRING straight through, and + // Math.max(0, 'x') is NaN — which propagates into every later slice and silently un-sums the bar. + const num = (v) => (typeof v === 'number' && isFinite(v) ? v : 0); + used = Math.max(0, num(used)); limit = Math.max(1, num(limit) || 200000); + const overhead = Math.min(used, Math.max(0, num(system)) + Math.max(0, num(tools))); + const sys = Math.min(Math.max(0, num(system)), overhead); const tul = Math.max(0, overhead - sys); + // The MCP share is carved OUT of the tools slice, never added alongside it: `tools` already counts + // every schema, MCP included, so adding would double-count and the bar would stop summing to `used`. + // Clamped to `tul` because both numbers are estimates that survived their own clamping above. + const mcp = Math.min(Math.max(0, num(mcpTools)), tul); + const own = Math.max(0, tul - mcp); const msgs = Math.max(0, used - sys - tul); const free = Math.max(0, limit - used); const seg = [ { key: 'messages', label: 'Messages', tokens: msgs }, { key: 'system', label: 'System prompt', tokens: sys }, - { key: 'tools', label: 'Tools', tokens: tul }, - { key: 'free', label: 'Free space', tokens: free } + { key: 'tools', label: 'Tools', tokens: own } ]; + // Only shown when MCP is actually in play — an always-present "MCP tools 0" row would be noise for + // the overwhelming majority of runs, which configure no servers at all. + if (mcp > 0){ seg.push({ key: 'mcp', label: 'MCP tools', tokens: mcp }); } + seg.push({ key: 'free', label: 'Free space', tokens: free }); for (const s of seg){ s.pct = Math.round(s.tokens / limit * 1000) / 10; } return seg; } const CTX_C = 2 * Math.PI * 14; // donut circumference (r=14) const ctxOverlay = document.getElementById('ctxOverlay'); const ctxCard = document.getElementById('ctxCard'); - const ctxSegColor = { messages: 'var(--accent)', system: 'color-mix(in srgb, var(--accent) 60%, transparent)', tools: 'color-mix(in srgb, var(--accent) 36%, transparent)', free: 'rgba(127,127,127,.28)' }; + const ctxSegColor = { messages: 'var(--accent)', system: 'color-mix(in srgb, var(--accent) 60%, transparent)', tools: 'color-mix(in srgb, var(--accent) 36%, transparent)', mcp: 'color-mix(in srgb, var(--accent) 20%, transparent)', free: 'rgba(127,127,127,.28)' }; let ctxOpen = false; // The cache-read line (below the breakdown). Only appears once a turn has reported usage. Distinguishes // reading from the prefix cache (the win), priming it (first turn writes it), and no caching at all. @@ -2477,7 +2508,7 @@ const used = ctxState.input, limit = ctxState.limit; const pct = Math.min(100, Math.round(used / limit * 100)); const level = pct >= 92 ? 'crit' : pct >= 80 ? 'warn' : 'ok'; - ctxCard.innerHTML = ctxCardHtml(used, limit, ctxSegments(used, limit, ctxState.system, ctxState.tools), pct, level, ctxState.cacheRead); + ctxCard.innerHTML = ctxCardHtml(used, limit, ctxSegments(used, limit, ctxState.system, ctxState.tools, ctxState.mcpTools), pct, level, ctxState.cacheRead); const nb = ctxCard.querySelector('#ctxNewBtn'); if (nb){ nb.onclick = () => { vscode.postMessage({ type: 'newChat' }); closeCtx(); }; } // Go busy SYNCHRONOUSLY on click, not on the compactStart round-trip — otherwise a fast double-click // slips a second 'compact' through before 'busy' is set, and the second's stale-recheck failure would @@ -2512,10 +2543,11 @@ const used = (d && typeof d.input === 'number') ? d.input : (ctxState ? ctxState.input : 0); const system = (d && typeof d.system === 'number') ? d.system : (ctxState ? ctxState.system : 0); const tools = (d && typeof d.tools === 'number') ? d.tools : (ctxState ? ctxState.tools : 0); + const mcpTools = (d && typeof d.mcpTools === 'number') ? d.mcpTools : (ctxState ? ctxState.mcpTools : 0); // cacheRead/cacheWrite are null until the first real turn reports usage → the cache line stays hidden. const cacheRead = (d && typeof d.cacheRead === 'number') ? d.cacheRead : (ctxState ? ctxState.cacheRead : null); const cacheWrite = (d && typeof d.cacheWrite === 'number') ? d.cacheWrite : (ctxState ? ctxState.cacheWrite : null); - ctxState = { input: used, limit: limit, system: system, tools: tools, cacheRead: cacheRead, cacheWrite: cacheWrite }; + ctxState = { input: used, limit: limit, system: system, tools: tools, mcpTools: mcpTools, cacheRead: cacheRead, cacheWrite: cacheWrite }; const pct = Math.min(100, Math.round(used / limit * 100)); const level = pct >= 92 ? 'crit' : pct >= 80 ? 'warn' : 'ok'; // 1) footer donut — ALWAYS visible (glanceable), click for the breakdown @@ -2769,6 +2801,7 @@ histIdx = -1; histDraft = ''; // Slash commands — handled locally (deterministic, no LLM call). if (/^\/skills\b/i.test(t)){ add('user', esc(t)); forceStick(); input.value = ''; auto(); vscode.postMessage({ type: 'listSkills' }); return; } + if (/^\/mcp\b/i.test(t)){ add('user', esc(t)); forceStick(); input.value = ''; auto(); vscode.postMessage({ type: 'listMcp' }); return; } if (/^\/rme\b/i.test(t)){ add('user', esc(t)); forceStick(); input.value = ''; auto(); addReminder(); return; } add('user', render(t)); forceStick(); input.value = ''; auto(); vscode.postMessage({ type: 'send', text: t }); @@ -3088,6 +3121,66 @@ note.textContent = (m.reason === 'gone') ? 'That checkpoint is no longer available.' : 'Stop the running agent before restoring a checkpoint.'; log.appendChild(note); scrollIfStuck(); setTimeout(() => { try { note.remove(); } catch(e){} }, 3500); } + // `/mcp` — what is configured, what is actually running, and why not. + // + // Lists CONFIGURED servers, not live ones. The questions this answers are "why is my server not being + // used?" and "what is this repo asking to run?", and a list of what is running answers neither: the + // interesting server is the one that is absent. + function renderMcpList(m){ + clearEmpty(); clearStatus(); agentBubble = null; + const d = document.createElement('div'); d.className = 'msg assistant'; + let inner = '
LevelCode AI
'; + + if (!m.configured){ + inner += 'No MCP servers configured. Add one under levelcode.ai.mcp.servers, ' + + 'or commit a .levelcode/mcp.json to a repo — a repo-defined server asks before it starts.'; + } else { + const bits = [ m.configured + ' configured', m.running + ' running' ]; + if (m.awaitingTrust){ bits.push(m.awaitingTrust + ' awaiting approval'); } + inner += '
' + bits.join(' · ') + '
'; + + for (const s of (m.servers || [])){ + // State, most actionable first: a repo server that has never been approved is not broken, it is + // waiting for the user, and saying "not running" alone would send them debugging. + let state, cls; + if (s.running){ state = 'running'; cls = 'ok'; } + else if (s.trusted === false){ state = 'needs approval'; cls = 'warn'; } + else { state = 'not started'; cls = 'off'; } + + const counts = s.running + ? (s.tools + ' tool' + (s.tools === 1 ? '' : 's') + + (s.allowed == null ? '' : ' · ' + s.allowed + ' allow-listed')) + : ''; + + inner += '
' + + '
' + + '' + esc(s.name) + '' + + '' + esc(state) + '' + + '' + esc(s.source === 'settings' ? 'your settings' : (s.origin || 'workspace')) + '' + + '
' + + '
' + esc(s.commandLine || '') + '
' + + (counts ? '
' + esc(counts) + '
' : '') + + (s.toolNames && s.toolNames.length + ? '
' + s.toolNames.map(t => '' + esc(t) + '').join(' ') + '
' + : '') + + '
'; + } + inner += '
'; + + if (m.awaitingTrust){ + inner += '
A repo-defined server starts only after you approve the exact command it would run. ' + + 'Ask the agent to do something that needs it, and the approval card appears.
'; + } + } + + for (const p of (m.problems || [])){ + inner += '
' + esc(p.message) + '
'; + } + + d.innerHTML = inner + '
'; + log.appendChild(d); scrollIfStuck(); + } + function renderSkillsList(m){ clearEmpty(); clearStatus(); agentBubble = null; const d = document.createElement('div'); d.className = 'msg assistant'; @@ -3155,6 +3248,7 @@ else if (m.type === 'checkpointRestored'){ applyCheckpointRestored(m); } else if (m.type === 'checkpointBusy'){ checkpointBusyNotice(m); } else if (m.type === 'skillsList'){ renderSkillsList(m); } + else if (m.type === 'mcpList'){ renderMcpList(m.mcp || {}); } else if (m.type === 'verifyRun'){ addVerifyRun(m); } else if (m.type === 'verifyOutput'){ verifyOutputAppend(m); } else if (m.type === 'verifyDone'){ verifyDoneFinish(m); } diff --git a/extensions/levelcode-ai/test/ctxSegments.test.js b/extensions/levelcode-ai/test/ctxSegments.test.js new file mode 100644 index 0000000..cfe6148 --- /dev/null +++ b/extensions/levelcode-ai/test/ctxSegments.test.js @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * The context-usage breakdown — run: node test/ctxSegments.test.js + * + * One invariant carries this whole function: the segments must SUM TO THE WINDOW. The popover draws + * a segmented bar from them and prints a percentage per row, so a slice that double-counts does not + * look like a bug — it looks like the model used more context than it did. + * + * That is the trap the MCP segment walks into (docs/MCP.md S5): `tools` already counts every schema, + * MCP included, so an "MCP tools" row must be carved OUT of it, never added alongside. + * + * Extracted from the shipped chat.html the same way narrativeUi/shHighlight do, so these exercise the + * real code rather than a copy that can drift from it. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const html = fs.readFileSync(path.join(__dirname, '..', 'media', 'chat.html'), 'utf8'); + +function extract(name) { + const start = html.indexOf('function ' + name + '('); + assert.ok(start >= 0, 'chat.html no longer defines ' + name + '()'); + const end = html.indexOf('\n }', start); + assert.ok(end >= 0, 'no closing brace found for ' + name + '()'); + return html.slice(start, end + 4); +} + +const sandbox = {}; +new Function(extract('ctxSegments') + '\nthis.ctxSegments = ctxSegments;').call(sandbox); +const { ctxSegments } = /** @type {any} */ (sandbox); + +let n = 0; +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } + +const byKey = (seg) => Object.fromEntries(seg.map((s) => [ s.key, s.tokens ])); +const sum = (seg) => seg.reduce((a, s) => a + s.tokens, 0); + +test('SUM: the segments always add up to the window, MCP or not', () => { + const cases = [ + [ 50000, 200000, 2000, 8000, 0 ], + [ 50000, 200000, 2000, 8000, 5000 ], + [ 199999, 200000, 2000, 8000, 5000 ], + [ 0, 200000, 2000, 8000, 5000 ], + [ 3000, 200000, 2000, 8000, 5000 ], // used < overhead: the clamps must still balance + [ 50000, 200000, 0, 0, 0 ] + ]; + for (const [ used, limit, sys, tools, mcp ] of cases) { + const seg = ctxSegments(used, limit, sys, tools, mcp); + assert.strictEqual(sum(seg), limit, + 'segments must sum to the LIMIT for ' + JSON.stringify([ used, limit, sys, tools, mcp ])); + for (const s of seg) { assert.ok(s.tokens >= 0, s.key + ' must never go negative'); } + } +}); + +test('MCP is carved OUT of tools, never added alongside it', () => { + const without = byKey(ctxSegments(50000, 200000, 2000, 8000, 0)); + const withMcp = byKey(ctxSegments(50000, 200000, 2000, 8000, 5000)); + + assert.strictEqual(without.tools, 8000, 'no MCP: the whole tools estimate is "Tools"'); + assert.strictEqual(withMcp.tools + withMcp.mcp, 8000, 'with MCP: the two still total the estimate'); + assert.strictEqual(withMcp.mcp, 5000); + assert.strictEqual(withMcp.tools, 3000); + + // The rest of the breakdown must not shift just because the tools slice was split. + assert.strictEqual(withMcp.messages, without.messages, 'splitting tools must not move Messages'); + assert.strictEqual(withMcp.free, without.free, 'nor Free space'); +}); + +test('the MCP row is absent when no server contributed a schema', () => { + // The overwhelming majority of runs configure no MCP at all; a permanent "MCP tools 0" row would be + // noise on the one popover people open when they are worried about space. + assert.ok(!('mcp' in byKey(ctxSegments(50000, 200000, 2000, 8000, 0)))); + assert.ok(!('mcp' in byKey(ctxSegments(50000, 200000, 2000, 8000, undefined)))); + assert.ok('mcp' in byKey(ctxSegments(50000, 200000, 2000, 8000, 1))); +}); + +test('an over-large or junk MCP estimate cannot eat another segment', () => { + // Both numbers are `length / 4` estimates, so they can disagree. The clamp must fail toward a + // truthful bar rather than a negative slice. + const over = byKey(ctxSegments(50000, 200000, 2000, 8000, 999999)); + assert.strictEqual(over.mcp, 8000, 'clamped to the tools slice'); + assert.strictEqual(over.tools, 0); + assert.strictEqual(sum(ctxSegments(50000, 200000, 2000, 8000, 999999)), 200000); + + for (const junk of [ -5000, NaN, null, undefined, 'x' ]) { + const seg = ctxSegments(50000, 200000, 2000, 8000, junk); + assert.strictEqual(sum(seg), 200000, 'junk mcpTools must not unbalance the bar: ' + String(junk)); + } +}); + +test('percentages are computed against the window, not the used total', () => { + const seg = ctxSegments(50000, 200000, 2000, 8000, 5000); + const mcp = seg.find((s) => s.key === 'mcp'); + assert.strictEqual(mcp.pct, 2.5, '5000 / 200000'); +}); + +console.log('\nctxSegments: ' + n + ' tests passed.'); diff --git a/extensions/levelcode-ai/test/mcpConfig.test.js b/extensions/levelcode-ai/test/mcpConfig.test.js index fa94b0d..e35a393 100644 --- a/extensions/levelcode-ai/test/mcpConfig.test.js +++ b/extensions/levelcode-ai/test/mcpConfig.test.js @@ -795,4 +795,72 @@ test('G1: the card and the fingerprint read the SAME normalized material', () => assert.strictEqual(M.launchFingerprint(weird), M.launchFingerprint({ name: 'x', command: 'c' })); }); +// ---- S5: /mcp visibility ---- +// The list answers "why is my server not being used?", so it is built from CONFIGURED servers, not +// from live handles — a list of what is running cannot explain what is missing. + +const settingsSrv = (over) => Object.assign({ + name: 'gh', command: 'npx', args: ['-y', 'server-github'], env: {}, + source: 'settings', origin: 'levelcode.ai.mcp.servers' +}, over || {}); + +test('S5: a configured-but-not-running server still appears, with its command', () => { + const out = M.summarizeMcp({ servers: [ settingsSrv(), srv() ] }); + + assert.strictEqual(out.configured, 2); + assert.strictEqual(out.running, 0); + assert.strictEqual(out.servers.length, 2, 'a server that never started is the interesting case'); + assert.ok(out.servers[0].commandLine.startsWith('npx '), 'the row carries what it would run'); +}); + +test('S5: trust state is reported only for repo-authored servers', () => { + const workspace = srv(); // source: 'workspace' + const trusted = M.rememberLaunchTrust(workspace, {}); + + const cold = M.summarizeMcp({ servers: [ settingsSrv(), workspace ] }); + assert.strictEqual(cold.servers[0].trusted, null, 'a settings server needs no consent — not `false`'); + assert.strictEqual(cold.servers[1].trusted, false, 'an untrusted workspace server'); + assert.strictEqual(cold.awaitingTrust, 1, 'the answer to "why is it not running?"'); + + const warm = M.summarizeMcp({ servers: [ workspace ], trust: trusted }); + assert.strictEqual(warm.servers[0].trusted, true); + assert.strictEqual(warm.awaitingTrust, 0); +}); + +test('S5: tool names and allow state come from the same functions the agent uses', () => { + const active = [ { + name: 'gh', alive: true, toolCount: 2, + tools: [ { name: 'list_issues' }, { name: 'delete_repo', annotations: { destructiveHint: true } } ] + } ]; + const policy = { 'gh__list_issues': 'allow', 'gh__delete_repo': 'allow' }; + + const out = M.summarizeMcp({ servers: [ settingsSrv() ], active: active, policy: policy }); + const row = out.servers[0]; + + assert.strictEqual(out.running, 1); + assert.strictEqual(row.tools, 2); + assert.deepStrictEqual(row.toolNames.slice().sort(), [ 'gh__delete_repo', 'gh__list_issues' ]); + + // The destructive one is allow-listed but classifyMcpTool still refuses it, so the count must not + // claim 2 — a list that disagrees with runTool is worse than no list. + assert.strictEqual(row.allowed, 1, 'a destructive tool is never counted as allowed, even if listed'); +}); + +test('S5: without live tool details the row degrades instead of inventing numbers', () => { + const out = M.summarizeMcp({ servers: [ settingsSrv() ], active: [ { name: 'gh', alive: true, toolCount: 7 } ] }); + assert.strictEqual(out.servers[0].tools, 7, 'the count listActive gives is still shown'); + assert.strictEqual(out.servers[0].allowed, null, 'unknown, not zero'); + assert.deepStrictEqual(out.servers[0].toolNames, []); +}); + +test('S5: config problems are carried through, and junk input never throws', () => { + const out = M.summarizeMcp({ servers: [], problems: [ { level: 'error', message: 'bad entry' } ] }); + assert.deepStrictEqual(out.problems, [ { level: 'error', message: 'bad entry' } ]); + + assert.doesNotThrow(() => M.summarizeMcp(null)); + assert.doesNotThrow(() => M.summarizeMcp({})); + assert.doesNotThrow(() => M.summarizeMcp({ servers: 'nope', active: 'nope', policy: 'nope', trust: 5 })); + assert.strictEqual(M.summarizeMcp(null).configured, 0); +}); + console.log('\nmcpConfig.js: ' + n + ' tests passed.'); diff --git a/extensions/levelcode-ai/test/webviewCss.test.js b/extensions/levelcode-ai/test/webviewCss.test.js index 4f9dc9c..1c10989 100644 --- a/extensions/levelcode-ai/test/webviewCss.test.js +++ b/extensions/levelcode-ai/test/webviewCss.test.js @@ -167,4 +167,21 @@ test('the mcpLaunch card shows the literal command and offers no always-allow', assert.ok(/remember: false/.test(fn), 'never rides the tool-policy remember path'); }); +test('the /mcp command is wired end to end and lists CONFIGURED servers', () => { + assert.ok(/\/\^\\\/mcp\\b\/i\.test\(t\)/.test(html) || /\/\^\\\/mcp/.test(html), 'the composer intercepts /mcp'); + assert.ok(/type: 'listMcp'/.test(html), 'it asks the extension for the list'); + assert.ok(/m\.type === 'mcpList'/.test(html), 'and renders the reply'); + + const fn = html.slice(html.indexOf('function renderMcpList'), html.indexOf('function renderSkillsList')); + assert.ok(fn.length > 200, 'found the renderer'); + + // The three states a row can be in. "needs approval" is the one that must not read as a fault: + // a repo server waiting on the G1 card is working exactly as designed. + assert.ok(/needs approval/.test(fn), 'an untrusted repo server says so rather than just "not started"'); + assert.ok(/not started/.test(fn) && /running/.test(fn), 'the other two states'); + // The command line is the security-relevant part of this list, so it must be rendered escaped. + assert.ok(/esc\(s\.commandLine/.test(fn), 'the command is shown, and escaped'); + assert.ok(/mcpcmd/.test(html) && /overflow-wrap: anywhere/.test(html), 'and wraps rather than truncating'); +}); + console.log('webviewCss: ' + n + ' tests passed');