diff --git a/packages/agent/src/adapters/claude/session/mcp-config.test.ts b/packages/agent/src/adapters/claude/session/mcp-config.test.ts new file mode 100644 index 000000000..0ea631ad4 --- /dev/null +++ b/packages/agent/src/adapters/claude/session/mcp-config.test.ts @@ -0,0 +1,112 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { loadUserClaudeJsonMcpServers } from "./mcp-config"; + +describe("loadUserClaudeJsonMcpServers", () => { + let tmpHome: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "claude-json-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it.each([ + { name: "~/.claude.json is missing", setup: () => undefined }, + { + name: "~/.claude.json contains invalid JSON", + setup: (home: string) => + fs.writeFileSync(path.join(home, ".claude.json"), "not json"), + }, + ])("returns empty when $name", ({ setup }) => { + setup(tmpHome); + expect( + loadUserClaudeJsonMcpServers("/some/cwd", undefined, tmpHome), + ).toEqual({}); + }); + + it("returns top-level mcpServers", () => { + const cfg = { + mcpServers: { + top: { type: "stdio", command: "npx", args: ["pkg"] }, + }, + }; + fs.writeFileSync(path.join(tmpHome, ".claude.json"), JSON.stringify(cfg)); + const servers = loadUserClaudeJsonMcpServers( + "/some/cwd", + undefined, + tmpHome, + ); + expect(servers.top).toBeDefined(); + }); + + it("returns project-scoped mcpServers when cwd matches a project entry", () => { + const cwd = "/Users/jane/proj"; + const cfg = { + projects: { + [cwd]: { + mcpServers: { + playwright: { + type: "stdio", + command: "npx", + args: ["@playwright/mcp@latest"], + }, + }, + }, + }, + }; + fs.writeFileSync(path.join(tmpHome, ".claude.json"), JSON.stringify(cfg)); + const servers = loadUserClaudeJsonMcpServers(cwd, undefined, tmpHome); + expect(servers.playwright).toBeDefined(); + }); + + it("project-scoped servers override top-level on key collision", () => { + const cwd = "/Users/jane/proj"; + const cfg = { + mcpServers: { + shared: { type: "stdio", command: "global", args: [] }, + }, + projects: { + [cwd]: { + mcpServers: { + shared: { type: "stdio", command: "scoped", args: [] }, + }, + }, + }, + }; + fs.writeFileSync(path.join(tmpHome, ".claude.json"), JSON.stringify(cfg)); + const servers = loadUserClaudeJsonMcpServers(cwd, undefined, tmpHome); + expect((servers.shared as { command: string }).command).toBe("scoped"); + }); + + it("ignores CLAUDE_CONFIG_DIR redirect (reads real ~/.claude.json)", () => { + const altDir = fs.mkdtempSync(path.join(os.tmpdir(), "alt-claude-")); + fs.writeFileSync( + path.join(altDir, ".claude.json"), + JSON.stringify({ + mcpServers: { wrong: { type: "stdio", command: "x" } }, + }), + ); + fs.writeFileSync( + path.join(tmpHome, ".claude.json"), + JSON.stringify({ + mcpServers: { right: { type: "stdio", command: "y" } }, + }), + ); + const original = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = altDir; + try { + const servers = loadUserClaudeJsonMcpServers("/cwd", undefined, tmpHome); + expect(servers.right).toBeDefined(); + expect(servers.wrong).toBeUndefined(); + } finally { + if (original === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = original; + fs.rmSync(altDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/agent/src/adapters/claude/session/mcp-config.ts b/packages/agent/src/adapters/claude/session/mcp-config.ts index dd28d2bcf..1c169f9b0 100644 --- a/packages/agent/src/adapters/claude/session/mcp-config.ts +++ b/packages/agent/src/adapters/claude/session/mcp-config.ts @@ -1,5 +1,50 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import type { NewSessionRequest } from "@agentclientprotocol/sdk"; import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk"; +import type { Logger } from "../../../utils/logger"; + +export function loadUserClaudeJsonMcpServers( + cwd: string, + logger?: Logger, + homeDir: string = os.homedir(), +): Record { + const claudeJsonPath = path.join(homeDir, ".claude.json"); + + let raw: string; + try { + raw = fs.readFileSync(claudeJsonPath, "utf8"); + } catch { + return {}; + } + + let cfg: { + mcpServers?: unknown; + projects?: Record; + }; + try { + cfg = JSON.parse(raw); + } catch (err) { + logger?.warn("Failed to parse ~/.claude.json", { + error: err instanceof Error ? err.message : String(err), + }); + return {}; + } + + const topLevel = + cfg.mcpServers && typeof cfg.mcpServers === "object" + ? (cfg.mcpServers as Record) + : {}; + + const project = cfg.projects?.[cwd]; + const projectScoped = + project?.mcpServers && typeof project.mcpServers === "object" + ? (project.mcpServers as Record) + : {}; + + return { ...topLevel, ...projectScoped }; +} export function parseMcpServers( params: Pick, diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index c8686edf2..fd7a0365f 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -24,6 +24,7 @@ import { import type { CodeExecutionMode } from "../tools"; import type { EffortLevel } from "../types"; import { APPENDED_INSTRUCTIONS } from "./instructions"; +import { loadUserClaudeJsonMcpServers } from "./mcp-config"; import { DEFAULT_MODEL } from "./models"; import type { SettingsManager } from "./settings"; @@ -91,8 +92,10 @@ export function buildSystemPrompt( function buildMcpServers( userServers: Record | undefined, acpServers: Record, + projectScopedServers: Record, ): Record { return { + ...projectScopedServers, ...(userServers || {}), ...acpServers, }; @@ -330,6 +333,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { mcpServers: buildMcpServers( params.userProvidedOptions?.mcpServers, params.mcpServers, + loadUserClaudeJsonMcpServers(params.cwd, params.logger), ), env: buildEnvironment(), hooks: buildHooks(