Skip to content
Draft
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
65 changes: 65 additions & 0 deletions __tests__/config-provenance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

const fsMock = vi.hoisted(() => ({
existsSync: vi.fn<(path: string) => boolean>(),
readFileSync: vi.fn<(path: string, encoding: string) => string>(),
}));

vi.mock("node:fs", () => ({
existsSync: fsMock.existsSync,
readFileSync: fsMock.readFileSync,
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
renameSync: vi.fn(),
}));

import { getServerProvenance } from "../config.js";

describe("getServerProvenance multi-file provenance", () => {
const originalWarn = console.warn;
let files: Record<string, string>;

beforeEach(() => {
files = {};
fsMock.existsSync.mockImplementation((path: string) => path in files);
fsMock.readFileSync.mockImplementation((path: string) => {
if (!(path in files)) {
throw new Error(`ENOENT: ${path}`);
}
return files[path];
});
console.warn = vi.fn();
});

afterEach(() => {
console.warn = originalWarn;
vi.restoreAllMocks();
});

it("tracks the source file for each server across multiple override files", () => {
files["/a.json"] = JSON.stringify({
mcpServers: { alpha: { command: "a" } },
});
files["/b.json"] = JSON.stringify({
mcpServers: { beta: { command: "b" } },
});

const provenance = getServerProvenance(["/a.json", "/b.json"]);

expect(provenance.get("alpha")).toEqual({ path: "/a.json", kind: "user" });
expect(provenance.get("beta")).toEqual({ path: "/b.json", kind: "user" });
});

it("uses the later file as provenance when the same server is overridden", () => {
files["/a.json"] = JSON.stringify({
mcpServers: { playwright: { command: "old" } },
});
files["/b.json"] = JSON.stringify({
mcpServers: { playwright: { command: "new" } },
});

const provenance = getServerProvenance(["/a.json", "/b.json"]);

expect(provenance.get("playwright")).toEqual({ path: "/b.json", kind: "user" });
});
});
173 changes: 173 additions & 0 deletions __tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

const fsMock = vi.hoisted(() => ({
existsSync: vi.fn<(path: string) => boolean>(),
readFileSync: vi.fn<(path: string, encoding: string) => string>(),
}));

vi.mock("node:fs", () => ({
existsSync: fsMock.existsSync,
readFileSync: fsMock.readFileSync,
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
renameSync: vi.fn(),
}));

import os from "node:os";
import { loadMcpConfig } from "../config.js";

const cwd = process.cwd();
const defaultConfigPath = `${os.homedir()}/.pi/agent/mcp.json`;
const projectConfigPath = `${cwd}/.pi/mcp.json`;

describe("loadMcpConfig multi-file merge", () => {
const originalArgv = process.argv;
const originalWarn = console.warn;
let files: Record<string, string>;
let warnSpy: ReturnType<typeof vi.fn>;

beforeEach(() => {
process.argv = [...originalArgv];
files = {};
fsMock.existsSync.mockImplementation((path: string) => path in files);
fsMock.readFileSync.mockImplementation((path: string) => {
if (!(path in files)) {
throw new Error(`ENOENT: ${path}`);
}
return files[path];
});
warnSpy = vi.fn();
console.warn = warnSpy;
});

afterEach(() => {
process.argv = originalArgv;
console.warn = originalWarn;
vi.restoreAllMocks();
});

it("loads a single file from an array override", () => {
files["/one.json"] = JSON.stringify({
mcpServers: { one: { command: "node" } },
});

expect(loadMcpConfig(["/one.json"])).toEqual({
mcpServers: { one: { command: "node" } },
});
});

it("loads multiple files and merges servers", () => {
files["/a.json"] = JSON.stringify({
mcpServers: { alpha: { command: "a" } },
});
files["/b.json"] = JSON.stringify({
mcpServers: { beta: { command: "b" } },
});

expect(loadMcpConfig(["/a.json", "/b.json"]).mcpServers).toEqual({
alpha: { command: "a" },
beta: { command: "b" },
});
});

it("lets later files override the same server", () => {
files["/a.json"] = JSON.stringify({
mcpServers: { playwright: { command: "old", args: ["--stdio"] } },
});
files["/b.json"] = JSON.stringify({
mcpServers: { playwright: { command: "new", args: ["serve"] } },
});

expect(loadMcpConfig(["/a.json", "/b.json"]).mcpServers.playwright).toEqual({
command: "new",
args: ["serve"],
});
});

it("merges settings shallowly across files", () => {
files["/a.json"] = JSON.stringify({
mcpServers: {},
settings: { toolPrefix: "short" },
});
files["/b.json"] = JSON.stringify({
mcpServers: {},
settings: { idleTimeout: 30 },
});

expect(loadMcpConfig(["/a.json", "/b.json"]).settings).toEqual({
toolPrefix: "short",
idleTimeout: 30,
});
});

it("merges imports as a deduplicated union", () => {
files["/a.json"] = JSON.stringify({
mcpServers: {},
imports: ["cursor"],
});
files["/b.json"] = JSON.stringify({
mcpServers: {},
imports: ["claude-code", "cursor"],
});

expect(loadMcpConfig(["/a.json", "/b.json"]).imports).toEqual(["cursor", "claude-code"]);
});

it("skips missing files gracefully and loads existing ones", () => {
files["/exists.json"] = JSON.stringify({
mcpServers: { only: { command: "node" } },
});

expect(loadMcpConfig(["/missing.json", "/exists.json"]).mcpServers).toEqual({
only: { command: "node" },
});
});

it("falls back to the default config when given an empty array", () => {
files[defaultConfigPath] = JSON.stringify({
mcpServers: { defaulted: { command: "node" } },
});

expect(loadMcpConfig([])).toEqual(loadMcpConfig(undefined));
});

it("still accepts a string override path", () => {
files["/single.json"] = JSON.stringify({
mcpServers: { single: { command: "node" } },
});

expect(loadMcpConfig("/single.json").mcpServers).toEqual({
single: { command: "node" },
});
});

it("skips invalid JSON files, warns, and still loads valid files", () => {
files["/bad.json"] = "{";
files["/good.json"] = JSON.stringify({
mcpServers: { good: { command: "node" } },
});

expect(loadMcpConfig(["/bad.json", "/good.json"]).mcpServers).toEqual({
good: { command: "node" },
});
expect(warnSpy).toHaveBeenCalled();
});

it("lets project config override merged multi-file config", () => {
files["/a.json"] = JSON.stringify({
mcpServers: { playwright: { command: "a" }, alpha: { command: "alpha" } },
});
files["/b.json"] = JSON.stringify({
mcpServers: { playwright: { command: "b" }, beta: { command: "beta" } },
});
files[projectConfigPath] = JSON.stringify({
mcpServers: { playwright: { command: "project" } },
});

expect(loadMcpConfig(["/a.json", "/b.json"]).mcpServers).toEqual({
alpha: { command: "alpha" },
beta: { command: "beta" },
playwright: { command: "project" },
});
});
});
79 changes: 79 additions & 0 deletions __tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import os from "node:os";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { getConfigPathFromArgv, getConfigPathsFromArgv, parseConfigFlag } from "../utils.js";

describe("config path parsing", () => {
const originalArgv = process.argv;

beforeEach(() => {
process.argv = [...originalArgv];
vi.restoreAllMocks();
});

afterEach(() => {
process.argv = originalArgv;
vi.restoreAllMocks();
});

describe("getConfigPathsFromArgv", () => {
it("returns undefined when no --mcp-config flag is present", () => {
process.argv = ["node", "pi"];

expect(getConfigPathsFromArgv()).toBeUndefined();
});

it("returns a single path after one --mcp-config flag", () => {
process.argv = ["node", "pi", "--mcp-config", "a.json"];

expect(getConfigPathsFromArgv()).toEqual(["a.json"]);
});

it("returns multiple paths after one --mcp-config flag", () => {
process.argv = ["node", "pi", "--mcp-config", "a.json", "b.json"];

expect(getConfigPathsFromArgv()).toEqual(["a.json", "b.json"]);
});

it("collects paths from repeated --mcp-config flags", () => {
process.argv = ["node", "pi", "--mcp-config", "a.json", "--mcp-config", "b.json"];

expect(getConfigPathsFromArgv()).toEqual(["a.json", "b.json"]);
});

it("expands ~ to the current home directory", () => {
process.argv = ["node", "pi", "--mcp-config", "~/a.json"];

expect(getConfigPathsFromArgv()).toEqual([`${os.homedir()}/a.json`]);
});

it("stops collecting paths at the next flag", () => {
process.argv = ["node", "pi", "--mcp-config", "a.json", "--other"];

expect(getConfigPathsFromArgv()).toEqual(["a.json"]);
});
});

describe("getConfigPathFromArgv", () => {
it("returns the first parsed config path for legacy compatibility", () => {
process.argv = ["node", "pi", "--mcp-config", "~/a.json", "b.json"];

expect(getConfigPathFromArgv()).toBe(`${os.homedir()}/a.json`);
});
});

describe("parseConfigFlag", () => {
it("preserves a string flag as a single config path", () => {
expect(parseConfigFlag("a.json")).toEqual(["a.json"]);
});

it("preserves spaces inside a single config path", () => {
expect(parseConfigFlag("~/Library/Application Support/mcp.json")).toEqual([
`${os.homedir()}/Library/Application Support/mcp.json`,
]);
});

it("returns undefined when the flag is undefined", () => {
expect(parseConfigFlag(undefined)).toBeUndefined();
});
});
});
50 changes: 50 additions & 0 deletions __tests__/wiring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, it, expect } from "vitest";

function readSource(file: string): string {
return readFileSync(resolve(import.meta.dirname, "..", file), "utf-8");
}

describe("multi-config call-site wiring", () => {
it("index.ts imports getConfigPathsFromArgv instead of getConfigPathFromArgv", () => {
const source = readSource("index.ts");

expect(source).toMatch(/import\s+\{[^}]*getConfigPathsFromArgv[^}]*\}\s+from\s+"\.\/utils\.js";/);
expect(source).not.toContain('import { getConfigPathFromArgv } from "./utils.js";');
});

it("index.ts passes earlyConfigPaths into loadMcpConfig", () => {
const source = readSource("index.ts");

expect(source).toContain("const earlyConfigPaths = getConfigPathsFromArgv();");
expect(source).toContain("const earlyConfig = loadMcpConfig(earlyConfigPaths);");
expect(source).not.toContain("const earlyConfigPath = getConfigPathFromArgv();");
expect(source).not.toContain("const earlyConfig = loadMcpConfig(earlyConfigPath);");
});

it("index.ts describes the mcp-config flag as supporting repeated paths", () => {
const source = readSource("index.ts");

expect(source).toMatch(/description:\s*"[^"]*(Path\(s\)|can be repeated)[^"]*"/);
});

it("init.ts uses getConfigPathsFromArgv so repeated mcp-config flags are preserved", () => {
const source = readSource("init.ts");

expect(source).toContain('import { getConfigPathsFromArgv, openUrl, parallelLimit } from "./utils.js";');
expect(source).toContain('const configPaths = getConfigPathsFromArgv();');
expect(source).toContain('const config = loadMcpConfig(configPaths);');
expect(source).not.toContain('parseConfigFlag(pi.getFlag("mcp-config"))');
});

it("commands.ts uses getConfigPathsFromArgv-derived config paths for provenance so repeated flags survive end-to-end", () => {
const source = readSource("commands.ts");

expect(source).toMatch(/import\s+\{[^}]*getConfigPathsFromArgv[^}]*\}\s+from\s+"\.\/utils\.js";/);
expect(source).toContain('const configPaths = getConfigPathsFromArgv();');
expect(source).toContain('const provenanceMap = getServerProvenance(configPaths);');
expect(source).not.toContain('parseConfigFlag(pi.getFlag("mcp-config"))');
expect(source).not.toContain('configOverridePath');
});
});
5 changes: 3 additions & 2 deletions commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { lazyConnect, updateMetadataCache, updateStatusBar, getFailureAgeSeconds
import { loadMetadataCache } from "./metadata-cache.js";
import { getStoredTokens } from "./oauth-handler.js";
import { buildToolMetadata } from "./tool-metadata.js";
import { getConfigPathsFromArgv } from "./utils.js";

export async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
if (!ctx.hasUI) return;
Expand Down Expand Up @@ -164,11 +165,11 @@ export async function openMcpPanel(
state: McpExtensionState,
pi: ExtensionAPI,
ctx: ExtensionContext,
configOverridePath?: string,
): Promise<void> {
const config = state.config;
const cache = loadMetadataCache();
const provenanceMap = getServerProvenance(pi.getFlag("mcp-config") as string | undefined ?? configOverridePath);
const configPaths = getConfigPathsFromArgv();
const provenanceMap = getServerProvenance(configPaths);

const callbacks: McpPanelCallbacks = {
reconnect: async (serverName: string) => {
Expand Down
Loading