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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

All notable changes to this project will be documented in this file.

## Unreleased

- Load project-local MCP configs from the opened directory and parent project roots, including `.mcp.json`, `.pi/mcp.json`, `.cursor/mcp.json`, `.windsurf/mcp.json`, `.vscode/mcp.json`, `.claude/mcp.json`, and `.codex/config.json`.
- Project-local server provenance now points to the originating file, so direct tool toggles can be written back to the right project config.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,17 @@ Supported: `cursor`, `claude-code`, `claude-desktop`, `vscode`, `windsurf`, `cod

### Project Config

Add `.pi/mcp.json` in a project root for project-specific servers. Project config overrides global and imported servers.
Project-local MCP definitions are loaded from the opened directory (and nearest parent project roots). Supported files:

- `.mcp.json`
- `.pi/mcp.json`
- `.cursor/mcp.json`
- `.windsurf/mcp.json`
- `.vscode/mcp.json`
- `.claude/mcp.json`
- `.codex/config.json`

Project config overrides global and imported servers. When multiple project files exist, the one closest to the current working directory wins.

## Usage

Expand Down
106 changes: 106 additions & 0 deletions __tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

let tempDir: string;

beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "pi-mcp-config-"));
});

afterEach(() => {
process.chdir("/");
rmSync(tempDir, { recursive: true, force: true });
});

describe("config loading", () => {
it("loads .mcp.json from the opened project directory", async () => {
process.chdir(tempDir);
writeFileSync(join(tempDir, ".mcp.json"), JSON.stringify({
mcpServers: {
local: { command: "npx", args: ["-y", "local-server"] },
},
}));

const { loadMcpConfig } = await import("../config.js");
const config = loadMcpConfig(join(tempDir, "missing-user-config.json"));

expect(config.mcpServers.local).toEqual({ command: "npx", args: ["-y", "local-server"] });
});

it("loads editor-specific local project MCP files", async () => {
process.chdir(tempDir);
mkdirSync(join(tempDir, ".cursor"), { recursive: true });
mkdirSync(join(tempDir, ".windsurf"), { recursive: true });
mkdirSync(join(tempDir, ".claude"), { recursive: true });
mkdirSync(join(tempDir, ".codex"), { recursive: true });
mkdirSync(join(tempDir, ".vscode"), { recursive: true });

writeFileSync(join(tempDir, ".cursor", "mcp.json"), JSON.stringify({
mcpServers: { cursorLocal: { command: "cursor" } },
}));
writeFileSync(join(tempDir, ".windsurf", "mcp.json"), JSON.stringify({
"mcp-servers": { windsurfLocal: { command: "windsurf" } },
}));
writeFileSync(join(tempDir, ".claude", "mcp.json"), JSON.stringify({
mcpServers: { claudeLocal: { command: "claude" } },
}));
writeFileSync(join(tempDir, ".codex", "config.json"), JSON.stringify({
mcpServers: { codexLocal: { command: "codex" } },
}));
writeFileSync(join(tempDir, ".vscode", "mcp.json"), JSON.stringify({
mcpServers: { vscodeLocal: { command: "code" } },
}));

const { loadMcpConfig } = await import("../config.js");
const config = loadMcpConfig(join(tempDir, "missing-user-config.json"));

expect(Object.keys(config.mcpServers).sort()).toEqual([
"claudeLocal",
"codexLocal",
"cursorLocal",
"vscodeLocal",
"windsurfLocal",
]);
});

it("prefers nearer project configs over parent directories", async () => {
const root = join(tempDir, "workspace");
const child = join(root, "apps", "demo");
mkdirSync(child, { recursive: true });

writeFileSync(join(root, ".mcp.json"), JSON.stringify({
mcpServers: { shared: { command: "root" } },
}));
writeFileSync(join(child, ".mcp.json"), JSON.stringify({
mcpServers: { shared: { command: "child" } },
}));

process.chdir(child);
const { loadMcpConfig, getServerProvenance } = await import("../config.js");
const config = loadMcpConfig(join(tempDir, "missing-user-config.json"));
const provenance = getServerProvenance(join(tempDir, "missing-user-config.json"));

expect(config.mcpServers.shared).toEqual({ command: "child" });
expect(provenance.get("shared")).toEqual({ path: join(child, ".mcp.json"), kind: "project" });
});

it("lets .pi/mcp.json override imported and local project configs", async () => {
process.chdir(tempDir);
writeFileSync(join(tempDir, ".mcp.json"), JSON.stringify({
mcpServers: { same: { command: "generic" } },
}));
mkdirSync(join(tempDir, ".pi"), { recursive: true });
writeFileSync(join(tempDir, ".pi", "mcp.json"), JSON.stringify({
mcpServers: { same: { command: "pi" } },
settings: { toolPrefix: "none" },
}));

const { loadMcpConfig } = await import("../config.js");
const config = loadMcpConfig(join(tempDir, "missing-user-config.json"));

expect(config.mcpServers.same).toEqual({ command: "pi" });
expect(config.settings).toEqual({ toolPrefix: "none" });
});
});
72 changes: 57 additions & 15 deletions config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
// config.ts - Config loading with import support
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve, dirname } from "node:path";
import { join, resolve, dirname, parse } from "node:path";
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";
const PROJECT_LOCAL_CONFIGS: Array<{ kind: ImportKind | "pi" | "generic"; path: string }> = [
{ kind: "generic", path: ".mcp.json" },
{ kind: "pi", path: ".pi/mcp.json" },
{ kind: "cursor", path: ".cursor/mcp.json" },
{ kind: "windsurf", path: ".windsurf/mcp.json" },
{ kind: "vscode", path: ".vscode/mcp.json" },
{ kind: "claude-code", path: ".claude/mcp.json" },
{ kind: "codex", path: ".codex/config.json" },
];

// Import source paths for other tools
const IMPORT_PATHS: Record<ImportKind, string> = {
Expand Down Expand Up @@ -60,20 +68,21 @@ export function loadMcpConfig(overridePath?: string): McpConfig {
}
}

// Check for project-local config (skip if it's the same as the main config)
const projectPath = resolve(process.cwd(), PROJECT_CONFIG_NAME);
if (existsSync(projectPath) && projectPath !== configPath) {
// Check for project-local configs (skip files already used as the main config)
for (const projectConfig of findProjectLocalConfigs(process.cwd())) {
if (projectConfig.path === configPath) continue;

try {
const projectConfig = JSON.parse(readFileSync(projectPath, "utf-8"));
const validated = validateConfig(projectConfig);
// Project config overrides everything
const projectRaw = JSON.parse(readFileSync(projectConfig.path, "utf-8"));
const validated = validateProjectConfig(projectRaw, projectConfig.kind);

// Project config overrides everything. Later entries are closer to cwd and win.
config.mcpServers = { ...config.mcpServers, ...validated.mcpServers };
if (validated.settings) {
config.settings = { ...config.settings, ...validated.settings };
}
} catch (error) {
console.warn(`Failed to load project MCP config:`, error);
console.warn(`Failed to load project MCP config from ${projectConfig.path}:`, error);
}
}

Expand All @@ -100,6 +109,14 @@ function validateConfig(raw: unknown): McpConfig {
};
}

function validateProjectConfig(raw: unknown, kind: ImportKind | "pi" | "generic"): McpConfig {
if (kind === "pi" || kind === "generic") {
return validateConfig(raw);
}

return { mcpServers: extractServers(raw, kind) };
}

function extractServers(config: unknown, kind: ImportKind): Record<string, ServerEntry> {
if (!config || typeof config !== "object") return {};

Expand Down Expand Up @@ -128,6 +145,30 @@ function extractServers(config: unknown, kind: ImportKind): Record<string, Serve
return servers as Record<string, ServerEntry>;
}

function findProjectLocalConfigs(startDir: string): Array<{ kind: ImportKind | "pi" | "generic"; path: string }> {
const roots: string[] = [];
let current = resolve(startDir);

while (true) {
roots.push(current);
const parent = dirname(current);
if (parent === current || current === parse(current).root) break;
current = parent;
}

const discovered: Array<{ kind: ImportKind | "pi" | "generic"; path: string }> = [];
for (const root of roots.reverse()) {
for (const config of PROJECT_LOCAL_CONFIGS) {
const fullPath = resolve(root, config.path);
if (existsSync(fullPath)) {
discovered.push({ kind: config.kind, path: fullPath });
}
}
}

return discovered;
}

export function getServerProvenance(overridePath?: string): Map<string, ServerProvenance> {
const provenance = new Map<string, ServerProvenance>();
const userPath = overridePath ? resolve(overridePath) : DEFAULT_CONFIG_PATH;
Expand Down Expand Up @@ -162,12 +203,13 @@ export function getServerProvenance(overridePath?: string): Map<string, ServerPr
}
}

const projectPath = resolve(process.cwd(), PROJECT_CONFIG_NAME);
if (existsSync(projectPath) && projectPath !== userPath) {
for (const projectConfig of findProjectLocalConfigs(process.cwd())) {
if (projectConfig.path === userPath) continue;

try {
const projectConfig = validateConfig(JSON.parse(readFileSync(projectPath, "utf-8")));
for (const name of Object.keys(projectConfig.mcpServers)) {
provenance.set(name, { path: projectPath, kind: "project" });
const validated = validateProjectConfig(JSON.parse(readFileSync(projectConfig.path, "utf-8")), projectConfig.kind);
for (const name of Object.keys(validated.mcpServers)) {
provenance.set(name, { path: projectConfig.path, kind: "project" });
}
} catch {}
}
Expand Down