Skip to content
Open
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
148 changes: 148 additions & 0 deletions __tests__/config-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const originalHome = process.env.HOME;
const originalCwd = process.cwd();
const tempHomes: string[] = [];

async function loadConfigModule() {
vi.resetModules();
return import("../config.ts");
}

function createTestHome(): string {
const home = mkdtempSync(join(tmpdir(), "pi-mcp-adapter-home-"));
tempHomes.push(home);
process.env.HOME = home;
process.chdir(home);
mkdirSync(join(home, ".pi", "agent"), { recursive: true });
mkdirSync(join(home, ".codex"), { recursive: true });
writeFileSync(
join(home, ".pi", "agent", "mcp.json"),
JSON.stringify({ imports: ["codex"], mcpServers: {} }),
);
return home;
}

beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
});

afterEach(() => {
process.env.HOME = originalHome;
process.chdir(originalCwd);
while (tempHomes.length > 0) {
const home = tempHomes.pop();
if (home) {
rmSync(home, { recursive: true, force: true });
}
}
});

describe("loadMcpConfig imports", () => {
it("imports Codex MCP servers from config.toml", async () => {
const home = createTestHome();

writeFileSync(
join(home, ".codex", "config.toml"),
[
'[mcp_servers.context7]',
'url = "https://mcp.context7.com/mcp"',
'',
'[mcp_servers.serena]',
'command = "uvx"',
'args = ["--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server"]',
].join("\n"),
);

const { loadMcpConfig } = await loadConfigModule();
const config = loadMcpConfig();

expect(config.mcpServers.context7).toEqual({
url: "https://mcp.context7.com/mcp",
});
expect(config.mcpServers.serena).toEqual({
command: "uvx",
args: ["--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server"],
});
});

it("falls back to Codex config.json when config.toml is absent", async () => {
const home = createTestHome();

writeFileSync(
join(home, ".codex", "config.json"),
JSON.stringify({
mcpServers: {
exa: {
url: "https://mcp.exa.ai/mcp",
},
},
}),
);

const { loadMcpConfig } = await loadConfigModule();
const config = loadMcpConfig();

expect(config.mcpServers.exa).toEqual({
url: "https://mcp.exa.ai/mcp",
});
});

it("falls back to Codex config.json when config.toml is invalid", async () => {
const home = createTestHome();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

writeFileSync(join(home, ".codex", "config.toml"), "[mcp_servers.exa\nurl = \"broken\"\n");
writeFileSync(
join(home, ".codex", "config.json"),
JSON.stringify({
mcpServers: {
exa: {
url: "https://mcp.exa.ai/mcp",
},
},
}),
);

const { loadMcpConfig } = await loadConfigModule();
const config = loadMcpConfig();

expect(config.mcpServers.exa).toEqual({
url: "https://mcp.exa.ai/mcp",
});
expect(warnSpy).toHaveBeenCalled();
});
});

describe("getServerProvenance imports", () => {
it("falls back to Codex config.json when config.toml is invalid", async () => {
const home = createTestHome();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

writeFileSync(join(home, ".codex", "config.toml"), "[mcp_servers.exa\nurl = \"broken\"\n");
writeFileSync(
join(home, ".codex", "config.json"),
JSON.stringify({
mcpServers: {
exa: {
url: "https://mcp.exa.ai/mcp",
},
},
}),
);

const { getServerProvenance } = await loadConfigModule();
const provenance = getServerProvenance();

expect(provenance.get("exa")).toEqual({
path: join(home, ".pi", "agent", "mcp.json"),
kind: "import",
importKind: "codex",
});
expect(warnSpy).toHaveBeenCalled();
});
});
112 changes: 70 additions & 42 deletions config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve, dirname } from "node:path";
import { parse as parseToml } from "smol-toml";
import type { McpConfig, ServerEntry, McpSettings, ImportKind, ServerProvenance } from "./types.js";

const DEFAULT_CONFIG_PATH = join(homedir(), ".pi", "agent", "mcp.json");
const PROJECT_CONFIG_NAME = ".pi/mcp.json";

// Import source paths for other tools
const IMPORT_PATHS: Record<ImportKind, string> = {
"cursor": join(homedir(), ".cursor", "mcp.json"),
"claude-code": join(homedir(), ".claude", "claude_desktop_config.json"),
"claude-desktop": join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json"),
"codex": join(homedir(), ".codex", "config.json"),
"windsurf": join(homedir(), ".windsurf", "mcp.json"),
"vscode": ".vscode/mcp.json", // Relative to project
const IMPORT_PATHS: Record<ImportKind, string[]> = {
"cursor": [join(homedir(), ".cursor", "mcp.json")],
"claude-code": [join(homedir(), ".claude", "claude_desktop_config.json")],
"claude-desktop": [join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json")],
"codex": [
join(homedir(), ".codex", "config.toml"),
join(homedir(), ".codex", "config.json"),
],
"windsurf": [join(homedir(), ".windsurf", "mcp.json")],
"vscode": [".vscode/mcp.json"], // Relative to project
};

export function loadMcpConfig(overridePath?: string): McpConfig {
Expand All @@ -35,27 +39,19 @@ export function loadMcpConfig(overridePath?: string): McpConfig {
// Process imports from other tools
if (config.imports?.length) {
for (const importKind of config.imports) {
const importPath = IMPORT_PATHS[importKind];
if (!importPath) continue;

const fullPath = importPath.startsWith(".")
? resolve(process.cwd(), importPath)
: importPath;

if (!existsSync(fullPath)) continue;

try {
const imported = JSON.parse(readFileSync(fullPath, "utf-8"));
const servers = extractServers(imported, importKind);

// Merge - local config takes precedence over imports
for (const [name, def] of Object.entries(servers)) {
if (!config.mcpServers[name]) {
config.mcpServers[name] = def;
}
const importPaths = IMPORT_PATHS[importKind];
if (!importPaths?.length) continue;

const imported = loadImportedConfig(importPaths, importKind, "Failed to import MCP config from");
if (!imported) continue;

const servers = extractServers(imported, importKind);

// Merge - local config takes precedence over imports
for (const [name, def] of Object.entries(servers)) {
if (!config.mcpServers[name]) {
config.mcpServers[name] = def;
}
} catch (error) {
console.warn(`Failed to import MCP config from ${importKind}:`, error);
}
}
}
Expand All @@ -80,6 +76,41 @@ export function loadMcpConfig(overridePath?: string): McpConfig {
return config;
}

function resolveImportPaths(paths: string[]): string[] {
return paths.map((importPath) => importPath.startsWith(".")
? resolve(process.cwd(), importPath)
: importPath,
);
}

function readImportedConfig(path: string): unknown {
const raw = readFileSync(path, "utf-8");

if (path.endsWith(".toml")) {
return parseToml(raw);
}

return JSON.parse(raw);
}

function loadImportedConfig(
paths: string[],
importKind: ImportKind,
warningPrefix: string,
): unknown | undefined {
for (const fullPath of resolveImportPaths(paths)) {
if (!existsSync(fullPath)) continue;

try {
return readImportedConfig(fullPath);
} catch (error) {
console.warn(`${warningPrefix} ${importKind}:`, error);
}
}

return undefined;
}

function validateConfig(raw: unknown): McpConfig {
if (!raw || typeof raw !== "object") {
return { mcpServers: {} };
Expand Down Expand Up @@ -109,9 +140,11 @@ function extractServers(config: unknown, kind: ImportKind): Record<string, Serve
switch (kind) {
case "claude-desktop":
case "claude-code":
case "codex":
servers = obj.mcpServers;
break;
case "codex":
servers = obj.mcp_servers ?? obj.mcpServers;
break;
case "cursor":
case "windsurf":
case "vscode":
Expand Down Expand Up @@ -144,21 +177,16 @@ export function getServerProvenance(overridePath?: string): Map<string, ServerPr

if (userConfig.imports?.length) {
for (const importKind of userConfig.imports) {
const importPath = IMPORT_PATHS[importKind];
if (!importPath) continue;
const fullPath = importPath.startsWith(".")
? resolve(process.cwd(), importPath)
: importPath;
if (!existsSync(fullPath)) continue;
try {
const imported = JSON.parse(readFileSync(fullPath, "utf-8"));
const servers = extractServers(imported, importKind);
for (const name of Object.keys(servers)) {
if (!provenance.has(name)) {
provenance.set(name, { path: userPath, kind: "import", importKind });
}
const importPaths = IMPORT_PATHS[importKind];
if (!importPaths?.length) continue;
const imported = loadImportedConfig(importPaths, importKind, "Failed to inspect imported MCP config from");
if (!imported) continue;
const servers = extractServers(imported, importKind);
for (const name of Object.keys(servers)) {
if (!provenance.has(name)) {
provenance.set(name, { path: userPath, kind: "import", importKind });
}
} catch {}
}
}
}

Expand Down
23 changes: 15 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading