diff --git a/pi-slate/CHANGELOG.md b/pi-slate/CHANGELOG.md index 9c99721..e5a95ea 100644 --- a/pi-slate/CHANGELOG.md +++ b/pi-slate/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- Native MCP validation is used on newer Pi versions by default. Missing validation is a warning, not a configuration failure. + ## 0.1.18 Pi 1.4.0 compatibility, session titles, and an opt-in effort-colored composer frame. diff --git a/pi-slate/extensions/pi-slate/sidebar-data.ts b/pi-slate/extensions/pi-slate/sidebar-data.ts index 7e0f4ca..8d54a96 100644 --- a/pi-slate/extensions/pi-slate/sidebar-data.ts +++ b/pi-slate/extensions/pi-slate/sidebar-data.ts @@ -13,7 +13,8 @@ export type SidebarSkill = { name: string; path: string; source: string; loaded: export type SidebarCommand = { name: string; source: string; description?: string }; export type SidebarPrompt = { name: string; source: string; description?: string }; export type SidebarMcp = { name: string; enabled: boolean | null; source: string }; -export type SidebarResources = { skills: SidebarSkill[]; commands: SidebarCommand[]; prompts: SidebarPrompt[]; mcp: SidebarMcp[]; mcpError?: string }; +export type SidebarMcpResult = { mcp: SidebarMcp[]; mcpError?: string; mcpWarning?: string }; +export type SidebarResources = { skills: SidebarSkill[]; commands: SidebarCommand[]; prompts: SidebarPrompt[] } & SidebarMcpResult; export type SidebarSession = { id: string; name?: string; pid: number; cwd: string; model: string; thinking: string; tokens: number | null; percent: number | null; contextWindow: number | null; @@ -208,7 +209,7 @@ export function sidebarMcp( function resolveMcp( registered: readonly { name: string; config: unknown }[], global: McpFile, project: McpFile | undefined, host: SidebarMcpHost | undefined, -): { mcp: SidebarMcp[]; mcpError?: string } { +): SidebarMcpResult { const files = new Map; source: string }>(); const invalid = new Map(); let rejected = false; @@ -242,8 +243,11 @@ function resolveMcp( } for (const [name, server] of files) rows.set(name, { name: sidebarText(name), source: server.source, enabled: server.config.enabled !== false }); for (const [name, row] of invalid) if (!rows.has(name)) rows.set(name, row); + // File errors take precedence; a missing adapter may still warn alongside them. + const mcpError = project?.error ?? global.error ?? (rejected ? "MCP configuration has rejected entries" : undefined); + const mcpWarning = !host && rows.size > 0 ? "MCP configuration validation unavailable" : undefined; return { mcp: [...rows.values()].sort((a, b) => a.name.localeCompare(b.name)), - ...(!host && rows.size > 0 ? { mcpError: "MCP configuration validation unavailable" } : rejected ? { mcpError: "MCP configuration has rejected entries" } : {}) }; + ...(mcpError ? { mcpError } : {}), ...(mcpWarning ? { mcpWarning } : {}) }; } /** File reads are bounded and cached outside render; never evaluate env or credential commands. */ @@ -269,12 +273,10 @@ export class SidebarMcpFiles { return (error as NodeJS.ErrnoException).code === "ENOENT" ? { servers: {} } : { servers: {}, error: "MCP config unreadable" }; } } - snapshot(agentDir: string, cwd: string, trusted: boolean, registered: readonly { name: string; config: unknown }[]): { mcp: SidebarMcp[]; mcpError?: string } { + snapshot(agentDir: string, cwd: string, trusted: boolean, registered: readonly { name: string; config: unknown }[]): SidebarMcpResult { const global = this.read(join(agentDir, "mcp.json")); const project = trusted ? this.read(join(cwd, ".pi", "mcp.json")) : undefined; - const result = resolveMcp(registered, global, project, this.host); - const error = project?.error ?? global.error ?? result.mcpError; - return { mcp: result.mcp, ...(error ? { mcpError: error } : {}) }; + return resolveMcp(registered, global, project, this.host); } clear(): void { this.cached.clear(); } } diff --git a/pi-slate/extensions/pi-slate/sidebar-mcp.ts b/pi-slate/extensions/pi-slate/sidebar-mcp.ts index 7e826c8..dbb70cf 100644 --- a/pi-slate/extensions/pi-slate/sidebar-mcp.ts +++ b/pi-slate/extensions/pi-slate/sidebar-mcp.ts @@ -9,27 +9,25 @@ export type SidebarMcpHost = { stripBom: boolean; }; -// Verified against native file parsers; unknown formats still fail closed. -const HOST_FORMATS = new Map([ - ["0.99.0", { projectOverrides: false, stripBom: false }], - ["1.0.0", { projectOverrides: false, stripBom: false }], - ["1.1.0", { projectOverrides: true, stripBom: false }], - ["1.3.0", { projectOverrides: true, stripBom: false }], - ["1.4.0", { projectOverrides: true, stripBom: true }], -]); +/** Version only selects legacy parsing exceptions; capabilities determine availability. */ +function hostParsing(version: string): { projectOverrides: boolean; stripBom: boolean } { + if (version === "0.99.0" || version === "1.0.0") return { projectOverrides: false, stripBom: false }; + const release = /^1\.(\d+)\.(\d+)$/.exec(version); + const minor = release ? Number(release[1]) : Number.NaN; + const patch = release ? Number(release[2]) : Number.NaN; + // 1.0.4+ and the released 1.1–1.3 minors merge project overrides and reject a BOM. 1.4+ strips it. + if ((minor === 0 && patch >= 4) || (minor >= 1 && minor < 4)) return { projectOverrides: true, stripBom: false }; + return { projectOverrides: true, stripBom: true }; +} export async function loadSidebarMcpHost(packageDir: string, version: string): Promise { try { - const patch = /^1\.0\.(\d+)$/.exec(version); - const format = HOST_FORMATS.get(version) - ?? (patch !== null && Number(patch[1]) >= 4 ? { projectOverrides: true, stripBom: false } : undefined); - if (!format) return undefined; + const { projectOverrides, stripBom } = hostParsing(version); const native = await import(pathToFileURL(join(packageDir, "dist/core/mcp-servers.js")).href) as { validateMcpServerConfig?: SidebarMcpHost["validate"]; mcpNamespace?: SidebarMcpHost["namespace"]; }; if (typeof native.validateMcpServerConfig !== "function") return undefined; - const { projectOverrides, stripBom } = format; if (projectOverrides && typeof native.mcpNamespace !== "function") return undefined; return { validate: native.validateMcpServerConfig, namespace: projectOverrides ? native.mcpNamespace! : name => name, projectOverrides, stripBom }; diff --git a/pi-slate/extensions/pi-slate/sidebar.ts b/pi-slate/extensions/pi-slate/sidebar.ts index 23660e2..6b50d1f 100644 --- a/pi-slate/extensions/pi-slate/sidebar.ts +++ b/pi-slate/extensions/pi-slate/sidebar.ts @@ -275,17 +275,19 @@ export class Sidebar implements Component { } private middle(): Row[] { const enabled = this.resources.mcp.filter(x => x.enabled === true).length; - const unknown = this.resources.mcp.some(x => x.enabled === null); - const rejected = this.resources.mcp.filter(x => x.enabled === null).length; + const unknownCount = this.resources.mcp.filter(x => x.enabled === null).length; const errors = this.resources.mcpError - ? (this.resources.mcpError === "MCP configuration has rejected entries" && rejected > 0 ? rejected : 1) + ? (this.resources.mcpError === "MCP configuration has rejected entries" && unknownCount > 0 ? unknownCount : 1) : 0; + const allUnknown = unknownCount > 0 && unknownCount === this.resources.mcp.length; + const warning = !errors && Boolean(this.resources.mcpWarning); const loaded = this.resources.skills.filter(x => x.loaded).length; - const resourceHeading = (label: string, count: string, failed = 0): string => + const resourceHeading = (label: string, count: string, failed = 0, warned = false): string => this.heading(label) + this.paint("muted", ` · ${count}`) - + (failed ? this.paint("error", ` · ${failed} ${failed === 1 ? "Error" : "Errors"}`) : ""); + + (failed ? this.paint("error", ` · ${failed} ${failed === 1 ? "Error" : "Errors"}`) : "") + + (warned ? this.paint("warning", " · 1 Warning") : ""); const rows: Row[] = [{ text: "" }, { - text: resourceHeading(`${this.folds.mcp ? "⌄" : "›"} MCP`, errors || !unknown ? `${enabled} Enabled` : "—", errors), + text: resourceHeading(`${this.folds.mcp ? "⌄" : "›"} MCP`, allUnknown || (!errors && unknownCount > 0) ? "—" : `${enabled} Enabled`, errors, warning), action: () => this.toggleSection("mcp"), }]; if (this.folds.mcp) rows.push(...this.resourceRows("mcp")); @@ -306,8 +308,9 @@ export class Sidebar implements Component { if (list === "mcp") { const server = this.resources.mcp[i]!; const state = server.enabled === null ? "—" : server.enabled ? "enabled" : "disabled"; + const validationWarning = !this.resources.mcpError && server.enabled === null ? this.resources.mcpWarning : undefined; rows.push({ text: this.paint(server.enabled === null ? "warning" : server.enabled ? "muted" : "dim", `${server.enabled === null ? "—" : server.enabled ? "●" : "○"} ${sidebarText(server.name)}`), list, item: i, - action: () => this.actions?.inspect?.(server.name, `${state} (configuration, not connection status)\nSource: ${server.source}\nUse /mcp to manage this server.`) }); + action: () => this.actions?.inspect?.(server.name, `${state} (configuration, not connection status)\nSource: ${server.source}${validationWarning ? `\n${validationWarning}` : ""}\nUse /mcp to manage this server.`) }); } else if (list === "skills") { const skill = this.resources.skills[i]!; rows.push({ text: this.paint(skill.loaded ? "muted" : "dim", `${skill.loaded ? "●" : "○"} ${sidebarText(skill.name)}`), list, item: i, diff --git a/pi-slate/tests/sidebar-mcp.test.ts b/pi-slate/tests/sidebar-mcp.test.ts index 92359a7..513d6f8 100644 --- a/pi-slate/tests/sidebar-mcp.test.ts +++ b/pi-slate/tests/sidebar-mcp.test.ts @@ -31,6 +31,7 @@ for (const [packageDir, version] of packages) { [{ x: { url: "https://example.com", headers: { token: 123 } }, y: { url: "https://example.com", oauth: { callbackPort: -1, clientSecret: "SECRET" } } }, {}], [{}, { x: { url: "https://example.com", auth: { provider: "fixture" } } }], [{ x: { command: "server", enabled: false, exposure: "codemode-deferred" } }, {}], + [{ auto: { url: "https://example.com/mcp", oauth: { clientRegistration: "auto" } } }, {}], ]; for (const [global, project] of fixtures) { await writeFile(join(agentDir, "mcp.json"), JSON.stringify({ mcpServers: global })); @@ -41,6 +42,7 @@ for (const [packageDir, version] of packages) { const configured = new Set([...Object.keys(global), ...Object.keys(project)]); const nativeRows = loaded.servers.filter(row => configured.has(row.name)).map(row => [row.name, row.config.enabled !== false]).sort(); assert.equal(Boolean(result.mcpError), loaded.errors.length > 0); + assert.equal(result.mcpWarning, undefined); assert.deepEqual( result.mcp.filter(row => row.enabled !== null && configured.has(row.name)).map(row => [row.name, row.enabled]).sort(), nativeRows, @@ -59,33 +61,53 @@ for (const [packageDir, version] of packages) { await writeFile(join(agentDir, "mcp.json"), '{"mcpServers":{}}'); await writeFile(join(cwd, ".pi/mcp.json"), '{"mcpServers":{"registered":{"enabled":false}}}'); cache.clear(); assert.equal(cache.snapshot(agentDir, cwd, true, [{ name: "registered", config: { command: "server" } }]).mcp[0]!.enabled, true); + await writeFile(join(agentDir, "mcp.json"), JSON.stringify({ mcpServers: { auto: { url: "https://example.com/mcp", oauth: { clientRegistration: "auto" } } } })); + await writeFile(join(cwd, ".pi/mcp.json"), '{"mcpServers":{}}'); cache.clear(); + const autoLoaded = native.loadMcpConfig({ agentDir, cwd, projectTrusted: true }); + const autoResult = cache.snapshot(agentDir, cwd, true, []); + const autoEnabled = autoResult.mcp.find(row => row.name === "auto")?.enabled ?? null; + assert.equal(autoEnabled === true, autoLoaded.servers.some(row => row.name === "auto"), `${version} uses the running native validator`); + assert.equal(Boolean(autoResult.mcpError), autoLoaded.errors.length > 0); + assert.equal(autoResult.mcpWarning, undefined); + if (version === "1.4.0") assert.equal(autoEnabled, null, "Pi 1.4 rejects oauth.clientRegistration auto"); + if (version === "1.5.0") assert.equal(autoEnabled, true, "Pi 1.5 accepts oauth.clientRegistration auto"); }); } test("host adapter requires native validation and modern namespaces", async t => { const root = await mkdtemp(join(tmpdir(), "slate-mcp-exports-")); t.after(() => rm(root, { recursive: true, force: true })); const fixtures = [ - { name: "missing-validator", exports: 'export const mcpNamespace = name => name;', modern: false, legacy: false }, - { name: "invalid-validator", exports: 'export const validateMcpServerConfig = true; export const mcpNamespace = name => name;', modern: false, legacy: false }, - { name: "missing-namespace", exports: 'export const validateMcpServerConfig = (_name, value) => value;', modern: false, legacy: true }, - { name: "invalid-namespace", exports: 'export const validateMcpServerConfig = (_name, value) => value; export const mcpNamespace = true;', modern: false, legacy: true }, - { name: "complete", exports: 'export const validateMcpServerConfig = (_name, value) => value; export const mcpNamespace = name => name;', modern: true, legacy: true }, + { name: "missing-validator", exports: "export const mcpNamespace = name => name;", modern: false, legacy: false }, + { name: "invalid-validator", exports: "export const validateMcpServerConfig = true; export const mcpNamespace = name => name;", modern: false, legacy: false }, + { name: "missing-namespace", exports: "export const validateMcpServerConfig = (_name, value) => value;", modern: false, legacy: true }, + { name: "invalid-namespace", exports: "export const validateMcpServerConfig = (_name, value) => value; export const mcpNamespace = true;", modern: false, legacy: true }, + { name: "complete", exports: "export const validateMcpServerConfig = (_name, value) => value; export const mcpNamespace = name => name;", modern: true, legacy: true }, + { name: "throws", exports: "throw new Error(\"load failed\");", modern: false, legacy: false }, ]; + const modernVersions = ["1.1.0", "1.4.0", "2.0.0", "not-a-version"]; for (const fixture of fixtures) { const packageDir = join(root, fixture.name); await mkdir(join(packageDir, "dist/core"), { recursive: true }); await writeFile(join(packageDir, "package.json"), '{"type":"module"}'); await writeFile(join(packageDir, "dist/core/mcp-servers.js"), fixture.exports); - assert.equal(Boolean(await loadSidebarMcpHost(packageDir, "1.4.0")), fixture.modern, fixture.name); + for (const version of modernVersions) { + assert.equal(Boolean(await loadSidebarMcpHost(packageDir, version)), fixture.modern, `${fixture.name}@${version}`); + } + assert.equal(Boolean(await loadSidebarMcpHost(packageDir, "0.99.0")), fixture.legacy, fixture.name); assert.equal(Boolean(await loadSidebarMcpHost(packageDir, "1.0.0")), fixture.legacy, fixture.name); } for (const [version, projectOverrides, stripBom] of [ - ["0.99.0", false, false], ["1.0.0", false, false], ["1.0.4", true, false], - ["1.0.99", true, false], ["1.1.0", true, false], ["1.3.0", true, false], ["1.4.0", true, true], + ["0.99.0", false, false], ["1.0.0", false, false], ["1.0.3", true, true], ["1.0.4", true, false], + ["1.0.99", true, false], ["1.1.0", true, false], ["1.2.0", true, false], ["1.3.0", true, false], + ["1.3.1", true, false], ["1.4.0", true, true], ["1.4.1", true, true], ["1.5.0", true, true], + ["1.5.1", true, true], ["1.10.0", true, true], ["2.0.0", true, true], ["9.1.0", true, true], + ["unknown", true, true], ["1.5.0-rc.1", true, true], ["", true, true], ] as const) { const host = await loadSidebarMcpHost(join(root, "complete"), version); assert(host, version); assert.equal(host.projectOverrides, projectOverrides, version); assert.equal(host.stripBom, stripBom, version); } + assert.equal(await loadSidebarMcpHost(join(root, "absent"), "1.5.0"), undefined); + assert.equal(await loadSidebarMcpHost(join(root, "absent"), "not-a-version"), undefined); }); test("unavailable validation is not an error without entries, but file errors remain visible", async t => { @@ -93,28 +115,43 @@ test("unavailable validation is not an error without entries, but file errors re await mkdir(join(root, ".pi")); const cache = new SidebarMcpFiles(); assert.deepEqual(cache.snapshot(root, root, true, []), { mcp: [] }, "missing files need no validation"); - await writeFile(join(root, "mcp.json"), '{}'); + await writeFile(join(root, "mcp.json"), "{}"); await writeFile(join(root, ".pi/mcp.json"), '{"mcpServers":{}}'); assert.deepEqual(cache.snapshot(root, root, true, []), { mcp: [] }, "empty files need no validation"); await writeFile(join(root, ".pi/mcp.json"), '{"mcpServers":{"project":{"command":"server"}}}'); assert.deepEqual(cache.snapshot(root, root, false, []), { mcp: [] }, "untrusted entries are not read"); const project = cache.snapshot(root, root, true, []); - assert.equal(project.mcp[0]!.enabled, null); assert.match(project.mcpError!, /unavailable/); - await writeFile(join(root, ".pi/mcp.json"), '{bad'); - assert.equal(cache.snapshot(root, root, true, []).mcpError, "MCP config unreadable"); + assert.equal(project.mcp[0]!.enabled, null); + assert.equal(project.mcpError, undefined); + assert.match(project.mcpWarning!, /unavailable/); + await writeFile(join(root, ".pi/mcp.json"), "{bad"); + const unreadable = cache.snapshot(root, root, true, []); + assert.equal(unreadable.mcpError, "MCP config unreadable"); + assert.equal(unreadable.mcpWarning, undefined); await writeFile(join(root, "mcp.json"), '{"mcpServers":[]}'); - assert.equal(cache.snapshot(root, root, false, []).mcpError, "MCP config invalid"); + const invalid = cache.snapshot(root, root, false, []); + assert.equal(invalid.mcpError, "MCP config invalid"); + assert.equal(invalid.mcpWarning, undefined); await writeFile(join(root, "mcp.json"), '{"mcpServers":{"global":{"command":"server"}}}'); const global = cache.snapshot(root, root, false, []); - assert.equal(global.mcp[0]!.enabled, null); assert.match(global.mcpError!, /unavailable/); + assert.equal(global.mcp[0]!.enabled, null); + assert.equal(global.mcpError, undefined); + assert.match(global.mcpWarning!, /unavailable/); + await writeFile(join(root, "mcp.json"), "{bad"); + const fileAndAdapter = cache.snapshot(root, root, false, [{ name: "registered", config: { command: "server", env: { TOKEN: "SECRET" } } }]); + assert.equal(fileAndAdapter.mcpError, "MCP config unreadable"); + assert.match(fileAndAdapter.mcpWarning!, /unavailable/); + assert.equal(fileAndAdapter.mcp.find(row => row.name === "registered")?.enabled, null); + assert(!JSON.stringify(fileAndAdapter).includes("SECRET")); }); test("unavailable host adapter never fabricates enabled/disabled state", async () => { - assert.equal(await loadSidebarMcpHost("/missing", "1.4.0"), undefined); - for (const version of ["unknown", "1.2.0", "1.4.1", "2.0.0"]) { - assert.equal(await loadSidebarMcpHost(getPackageDir(), version), undefined); - } + assert.equal(await loadSidebarMcpHost("/missing", "1.5.0"), undefined); + assert.equal(await loadSidebarMcpHost("/missing", "2.0.0"), undefined); const cache = new SidebarMcpFiles(); const result = cache.snapshot("/missing", "/missing", false, [{ name: "registered", config: { command: "server" } }]); - assert.equal(result.mcp[0]!.enabled, null); assert.match(result.mcpError!, /unavailable/); + assert.equal(result.mcp[0]!.enabled, null); + assert.equal(result.mcpError, undefined); + assert.match(result.mcpWarning!, /unavailable/); + assert.deepEqual(cache.snapshot("/missing", "/missing", false, []), { mcp: [] }); }); diff --git a/pi-slate/tests/sidebar-preview.test.ts b/pi-slate/tests/sidebar-preview.test.ts index 35405d2..01b4892 100644 --- a/pi-slate/tests/sidebar-preview.test.ts +++ b/pi-slate/tests/sidebar-preview.test.ts @@ -1,9 +1,12 @@ import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; import type { Theme } from "@earendil-works/pi-coding-agent"; import { visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui"; import { Sidebar, DOUBLE_CLICK_MS } from "../extensions/pi-slate/sidebar.ts"; -import type { SidebarResources, SidebarSession } from "../extensions/pi-slate/sidebar-data.ts"; +import { SidebarMcpFiles, type SidebarResources, type SidebarSession } from "../extensions/pi-slate/sidebar-data.ts"; import { sessionSidebarSlots } from "../extensions/pi-slate/sidebar-layout.ts"; import { DiffWorkspaceView, type WorkspaceView } from "../extensions/pi-slate/workspace.ts"; import type { BackgroundTask } from "../extensions/pi-slate/background-tasks.ts"; @@ -443,6 +446,64 @@ test("rejected MCP entries count as Errors without dumping the reason", () => { assert(!lines.some(line => line.includes("rejected entries") || line.includes("config error"))); }); +test("unavailable MCP validation renders as a warning when folded or expanded", () => { + const { sidebar } = fixture(); + const details: string[] = []; + sidebar.setActions({ copy() {}, openFile() {}, inspect: (_title, text) => details.push(text) }); + sidebar.setResources({ + ...resources, + mcp: [{ name: "unknown-server", enabled: null, source: "global" }], + mcpWarning: "MCP configuration validation unavailable", + }); + const folded = plain(sidebar.render(80)); + assert(folded.some(line => line.includes("› MCP · — · 1 Warning"))); + assert(!folded.some(line => line.includes("Error") || line.includes("0 Enabled") || line.includes("validation unavailable"))); + sidebar.setFolds({ mcp: true, skills: false, prompts: false }); + const expanded = plain(sidebar.render(80)); + assert(expanded.some(line => line.includes("⌄ MCP · — · 1 Warning"))); + assert(expanded.some(line => line.trim() === "— unknown-server")); + assert(!expanded.some(line => line.includes("Error") || line.includes("0 Enabled") || line.includes("validation unavailable"))); + sidebar.setThemeProvider(() => ({ fg: (tone: string, text: string) => tone === "warning" ? `\x1b[33m${text}\x1b[0m` : tone === "error" ? `\x1b[31m${text}\x1b[0m` : text, bold: (text: string) => text }) as Theme); + const painted = sidebar.render(80).join("\n"); + assert(painted.includes("\x1b[33m · 1 Warning\x1b[0m")); + assert(!painted.includes("\x1b[31m")); + sidebar.handleMouse(mouse(row(sidebar, "unknown-server"))); + assert.match(details[0]!, /MCP configuration validation unavailable/); + assert(!details[0]!.includes("SECRET")); +}); + +test("MCP file errors stay numeric and outrank a validation warning", () => { + const { sidebar } = fixture(); + sidebar.setResources({ ...resources, mcpError: "MCP config unreadable", mcpWarning: "MCP configuration validation unavailable" }); + const mixed = plain(sidebar.render(80)); + assert(mixed.some(line => line.includes("MCP · 6 Enabled · 1 Error"))); + assert(!mixed.some(line => line.includes("Warning") || line.includes("validation unavailable"))); + sidebar.setResources({ + skills: [], commands: [], prompts: [], + mcp: [{ name: "bad", enabled: null, source: "global" }], + mcpError: "MCP config unreadable", + mcpWarning: "MCP configuration validation unavailable", + }); + const unknown = plain(sidebar.render(80)); + assert(unknown.some(line => line.includes("MCP · — · 1 Error"))); + assert(!unknown.some(line => line.includes("0 Enabled") || line.includes("Warning"))); +}); + +test("a missing MCP host snapshot renders a warning instead of a config error", async t => { + const root = await mkdtemp(join(tmpdir(), "slate-mcp-warning-")); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, "mcp.json"), JSON.stringify({ mcpServers: { docs: { command: "server" } } })); + const snapshot = new SidebarMcpFiles().snapshot(root, root, false, []); + const { sidebar } = fixture(); + sidebar.setResources({ skills: [], commands: [], prompts: [], ...snapshot }); + const lines = plain(sidebar.render(80)); + assert.equal(snapshot.mcpError, undefined); + assert.equal(snapshot.mcp[0]!.enabled, null); + assert.match(snapshot.mcpWarning!, /unavailable/); + assert(lines.some(line => line.includes("MCP · — · 1 Warning"))); + assert(!lines.some(line => line.includes("Error") || line.includes("0 Enabled") || line.includes("validation unavailable"))); +}); + test("reset discards old titles, images, task rows and hit targets without changing fold preference", () => { const { sidebar } = fixture(); const copied: string[] = [];