Skip to content

Commit 1ae25d6

Browse files
authored
Merge pull request #57 from levelcodeai/feat/mcp-s5-visibility
feat(mcp): /mcp and an MCP context segment — S5 (visibility)
2 parents d778b01 + 075c2fb commit 1ae25d6

8 files changed

Lines changed: 429 additions & 16 deletions

File tree

docs/MCP.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,17 @@ servers, mirroring the project-rules chip (`agent.js:493`).
182182
- **S4b** — the G1 trust-on-first-use launch gate, which is what finally lets a `.levelcode/mcp.json`
183183
server start at all. With it, every gate in §4 is enforced.
184184

185-
**S5 — visibility.** `/mcp` slash command (a near-copy of `/skills`: `chat.html:2124`
186-
`extension.js:1297`), and an `mcp` segment in the context-usage popover (`contextUsage` already carries a
187-
`tools:` field, `agent.js:618`) so users can see what these servers cost them in context.
185+
**S5 — visibility. DONE.**
186+
- **`/mcp`** lists CONFIGURED servers, not running ones — the questions it answers are "why is my
187+
server not being used?" and "what is this repo asking to run?", and a list of live handles answers
188+
neither. Each row: state (`running` / `needs approval` / `not started`), provenance, the literal
189+
command, and — when live — tool names with their allow-list state, derived from the same
190+
`buildAgentTools` + `classifyMcpTool` the agent uses, so the list can never claim a tool is allowed
191+
while `runTool` refuses it. `summarizeMcp()` is pure and unit-tested.
192+
- **An `MCP tools` segment** in the context-usage popover, carved OUT of the existing `Tools` slice
193+
rather than added alongside it: `tools` already counts every schema, so adding would double-count and
194+
the bar would stop summing to `used`. Hidden entirely when no server contributed one. Every tool
195+
schema rides every turn, so this is the standing cost a chatty server imposes, and it was invisible.
188196

189197
**S6 — later.** Streamable HTTP transport + the `2026-07-28` revision; resources/prompts; a
190198
"Manage MCP servers…" QuickPick on the `pickModel` pattern (`extension.js:1165-1228`).

extensions/levelcode-ai/agent.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,16 @@ async function runAgent(ctx) {
699699
// Recomputed only when MCP actually contributed tools, so the no-MCP path keeps the module constant
700700
// and pays nothing for a feature it isn't using.
701701
const toolsTokensEst = mcp.tools.length ? Math.round(JSON.stringify(tools).length / 4) : TOOLS_TOKENS_EST;
702+
// The MCP SHARE of that, reported separately so the context popover can show what these servers cost
703+
// (docs/MCP.md S5). Every tool schema rides EVERY turn, so a chatty server is a standing tax on the
704+
// window rather than a one-off — and until it has its own segment, that cost is invisible.
705+
//
706+
// Measured as the difference between the two tool arrays rather than by serializing mcp.tools alone:
707+
// the JSON delimiters between entries belong to the total, and attributing them consistently is what
708+
// keeps `tools` and `mcpTools` summing to the number the bar already draws.
709+
const mcpToolsTokensEst = mcp.tools.length
710+
? Math.max(0, toolsTokensEst - Math.round(JSON.stringify(TOOLS).length / 4))
711+
: 0;
702712

703713
const messages = ctx.messages;
704714
let step = 0;
@@ -826,7 +836,7 @@ async function runAgent(ctx) {
826836
if (turn.usage.cost_micros != null) { runCostMicros += turn.usage.cost_micros; }
827837
if (turn.usage.credits_remaining_micros != null) { ctx.credits = turn.usage.credits_remaining_micros; }
828838
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 });
829-
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 });
839+
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 });
830840
}
831841

832842
// Reasoning models (e.g. Kimi K2.7 Code) emit <think>…</think> inline in the text

extensions/levelcode-ai/extension.js

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ const { formatDiagnosticLines, diagKey, createPreviewGate } = 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, isNamespacedToolName, safeCopy } = require('./mcpConfig');
29+
const { reapMcp, listActive, getServer } = require('./mcpClient');
30+
const { userScopedSetting, isNamespacedToolName, safeCopy, loadServerConfig, summarizeMcp } = require('./mcpConfig');
3131

3232
const SECRET_KEY = 'levelcode.ai.anthropicKey'; // legacy Anthropic key location (kept for back-compat)
3333
const FILE_EXCLUDES = '{**/node_modules/**,**/.git/**,**/out/**,**/dist/**,**/.vscode-test/**,**/*.map}';
@@ -807,6 +807,48 @@ async function saveMcpLaunchTrust(store) {
807807
}
808808
}
809809

810+
/**
811+
* Everything `/mcp` needs, gathered from the four places that know a piece of it: the merged config,
812+
* the live client registry, the allow-list setting, and the G1 launch-trust store.
813+
*
814+
* Config is re-read here rather than reused from the last run: `/mcp` is most useful exactly when the
815+
* user has just edited settings or a `.levelcode/mcp.json` and is asking why nothing happened.
816+
*
817+
* Never throws — a diagnostic command that can fail is worse than useless, because it fails on the
818+
* broken configuration it exists to explain.
819+
*/
820+
function mcpOverview() {
821+
try {
822+
const cfg = aiConfig();
823+
const folders = (vscode.workspace.workspaceFolders || []).map((f) => ({ name: f.name, root: f.uri.fsPath }));
824+
const { servers, problems } = loadServerConfig({
825+
settings: userScopedSetting(cfg.inspect('mcp.servers'), {}),
826+
folders: folders,
827+
readFile: (abs) => { try { return fs.readFileSync(abs, 'utf8'); } catch { return null; } }
828+
});
829+
830+
// listActive reports counts; getServer carries the raw tools/list, which is what lets the row
831+
// show per-tool allow state instead of just a number.
832+
const active = listActive().map((h) => {
833+
const handle = getServer(h.name);
834+
return Object.assign({}, h, { tools: (handle && handle.tools) || null });
835+
});
836+
837+
return summarizeMcp({
838+
servers: servers,
839+
problems: problems,
840+
active: active,
841+
policy: userScopedSetting(cfg.inspect('mcp.toolPolicy'), {}),
842+
trust: mcpLaunchTrust()
843+
});
844+
} catch (e) {
845+
dbg('mcp.overview.failed', { error: String((e && e.message) || e) });
846+
return { configured: 0, running: 0, awaitingTrust: 0, servers: [], problems: [
847+
{ level: 'error', message: 'could not read the MCP configuration: ' + String((e && e.message) || e) }
848+
] };
849+
}
850+
}
851+
810852
async function mcpAllowAlways(name) {
811853
// isNamespacedToolName owns the rule (mcpConfig.js), rather than a second regex here: this used to
812854
// hand-roll one that required a `__` separator, which REJECTED names namespaceToolName legitimately
@@ -1458,6 +1500,7 @@ class ChatViewProvider {
14581500
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;
14591501
case 'restoreCheckpoint': dbg('restoreCheckpoint', { turnId: msg.turnId, running: !!abort }); await restoreCheckpoint(msg.turnId); break;
14601502
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; }
1503+
case 'listMcp': post({ type: 'mcpList', mcp: mcpOverview() }); break;
14611504
case 'feedback': dbg('feedback', { value: msg.value, model: msg.model }); await recordFeedback(msg.value, msg.model); break;
14621505
case 'openFile': await openWorkspaceFile(msg.path); break;
14631506
case 'reviewKeepFile': dbg('review.keep', { id: msg.id }); review.keepFile(msg.id, 'kept'); break;

extensions/levelcode-ai/mcpConfig.js

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,79 @@ function describeMcpLaunch(server) {
592592
};
593593
}
594594

595+
// ---- S5: visibility --------------------------------------------------------
596+
597+
/**
598+
* What `/mcp` shows: one row per CONFIGURED server, whether or not it is running.
599+
*
600+
* Configured-first, not running-first, on purpose. The interesting questions are "why is my server not
601+
* being used?" and "what is this repo asking to run?", and both are about servers that are absent from
602+
* the live set. A list built from live handles answers neither.
603+
*
604+
* Pure so it can be tested without the editor: the caller passes what it knows.
605+
*
606+
* @param {{servers?:Array<object>, problems?:Array<object>, active?:Array<object>,
607+
* policy?:object, trust?:object}} input
608+
* active — from mcpClient.listActive(), optionally carrying `tools` (the raw tools/list result) so
609+
* per-tool allow state can be reported; without it the row still shows a tool count.
610+
* policy — levelcode.ai.mcp.toolPolicy trust — the G1 workspaceState launch-trust map
611+
*/
612+
function summarizeMcp(input) {
613+
const o = input || {};
614+
const servers = Array.isArray(o.servers) ? o.servers : [];
615+
const policy = (o.policy && typeof o.policy === 'object' && !Array.isArray(o.policy)) ? o.policy : {};
616+
const trust = (o.trust && typeof o.trust === 'object' && !Array.isArray(o.trust)) ? o.trust : {};
617+
618+
const live = new Map();
619+
for (const a of (Array.isArray(o.active) ? o.active : [])) {
620+
if (a && typeof a.name === 'string') { live.set(a.name, a); }
621+
}
622+
623+
const rows = servers.map((s) => {
624+
const handle = live.get(s.name);
625+
const workspace = s.source !== 'settings';
626+
const row = {
627+
name: s.name,
628+
source: s.source,
629+
origin: s.origin,
630+
running: !!(handle && handle.alive),
631+
// Only meaningful for a repo-authored server; a settings server needs no consent, and
632+
// reporting `false` for one would read as "blocked".
633+
trusted: workspace ? isLaunchTrusted(s, trust) : null,
634+
commandLine: describeMcpLaunch(s).commandLine,
635+
tools: handle ? (handle.toolCount || 0) : 0,
636+
allowed: null,
637+
toolNames: []
638+
};
639+
640+
// Names and allow state come from the SAME functions the agent uses, so the list cannot claim a
641+
// tool is allow-listed while runTool refuses it.
642+
if (handle && Array.isArray(handle.tools) && handle.tools.length) {
643+
const built = buildAgentTools([ { name: s.name, tools: handle.tools } ]);
644+
row.tools = built.tools.length;
645+
row.toolNames = built.tools.map((t) => t.name);
646+
row.allowed = built.tools.filter((t) => {
647+
const route = built.routes.get(t.name);
648+
return classifyMcpTool(t.name, policy, route && route.annotations).approve === 'allow';
649+
}).length;
650+
}
651+
return row;
652+
});
653+
654+
return {
655+
configured: rows.length,
656+
running: rows.filter((r) => r.running).length,
657+
// Repo-authored servers still waiting on the G1 consent card — the answer to "why is it not
658+
// running?" for the case that is a gate rather than a fault.
659+
awaitingTrust: rows.filter((r) => r.trusted === false && !r.running).length,
660+
servers: rows,
661+
problems: (Array.isArray(o.problems) ? o.problems : []).map((p) => ({
662+
level: String((p && p.level) || 'warn'),
663+
message: String((p && p.message) || '')
664+
}))
665+
};
666+
}
667+
595668
function describeMcpCall(name, args, route) {
596669
const r = route || {};
597670
const fallback = String(name == null ? '' : name).split(NAME_SEPARATOR);
@@ -605,6 +678,6 @@ module.exports = {
605678
loadServerConfig, userScopedSetting, namespaceToolName, isNamespacedToolName, assignToolNames,
606679
buildAgentTools, safeCopy,
607680
toolCountsByServer, classifyMcpTool, explainMcpRefusal, describeMcpCall,
608-
launchFingerprint, isLaunchTrusted, rememberLaunchTrust, describeMcpLaunch,
681+
launchFingerprint, isLaunchTrusted, rememberLaunchTrust, describeMcpLaunch, summarizeMcp,
609682
BUILTIN_TOOL_NAMES, MAX_TOOL_NAME, MAX_TOOL_DESC, MAX_ARG_CHARS, MAX_SERVERS, MAX_TOOLS_PER_SERVER, WORKSPACE_CONFIG_PATH
610683
};

0 commit comments

Comments
 (0)