Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
12 changes: 11 additions & 1 deletion extensions/levelcode-ai/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <think>…</think> inline in the text
Expand Down
47 changes: 45 additions & 2 deletions extensions/levelcode-ai/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
75 changes: 74 additions & 1 deletion extensions/levelcode-ai/mcpConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>, problems?:Array<object>, active?:Array<object>,
* 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);
Expand All @@ -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
};
Loading