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
112 changes: 112 additions & 0 deletions packages/agent/src/adapters/claude/session/mcp-config.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
45 changes: 45 additions & 0 deletions packages/agent/src/adapters/claude/session/mcp-config.ts
Original file line number Diff line number Diff line change
@@ -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<string, McpServerConfig> {
const claudeJsonPath = path.join(homeDir, ".claude.json");

let raw: string;
try {
raw = fs.readFileSync(claudeJsonPath, "utf8");
} catch {
return {};
}

let cfg: {
mcpServers?: unknown;
projects?: Record<string, { mcpServers?: unknown }>;
};
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<string, McpServerConfig>)
: {};

const project = cfg.projects?.[cwd];
const projectScoped =
project?.mcpServers && typeof project.mcpServers === "object"
? (project.mcpServers as Record<string, McpServerConfig>)
: {};

return { ...topLevel, ...projectScoped };
}

export function parseMcpServers(
params: Pick<NewSessionRequest, "mcpServers">,
Expand Down
4 changes: 4 additions & 0 deletions packages/agent/src/adapters/claude/session/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -91,8 +92,10 @@ export function buildSystemPrompt(
function buildMcpServers(
userServers: Record<string, McpServerConfig> | undefined,
acpServers: Record<string, McpServerConfig>,
projectScopedServers: Record<string, McpServerConfig>,
): Record<string, McpServerConfig> {
return {
...projectScopedServers,
...(userServers || {}),
...acpServers,
};
Expand Down Expand Up @@ -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(
Expand Down
Loading