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
4 changes: 4 additions & 0 deletions pi-slate/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 9 additions & 7 deletions pi-slate/extensions/pi-slate/sidebar-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, { config: Record<string, unknown>; source: string }>();
const invalid = new Map<string, SidebarMcp>();
let rejected = false;
Expand Down Expand Up @@ -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. */
Expand All @@ -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(); }
}
24 changes: 11 additions & 13 deletions pi-slate/extensions/pi-slate/sidebar-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SidebarMcpHost | undefined> {
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 };
Expand Down
17 changes: 10 additions & 7 deletions pi-slate/extensions/pi-slate/sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -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,
Expand Down
75 changes: 56 additions & 19 deletions pi-slate/tests/sidebar-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand All @@ -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,
Expand All @@ -59,62 +61,97 @@ 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 => {
const root = await mkdtemp(join(tmpdir(), "slate-mcp-empty-")); t.after(() => rm(root, { recursive: true, force: true }));
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: [] });
});
Loading
Loading