';
+
+ 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');