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
5 changes: 4 additions & 1 deletion apps/code/src/renderer/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,10 +399,13 @@ container.bind(CODE_REVIEW_WORKSPACE_CLIENT).toConstantValue({
} satisfies CodeReviewWorkspaceClient);
container.bind(REVERT_HUNK_SERVICE).to(RevertHunkService).inSingletonScope();

// local MCP servers (~/.claude.json), read for cloud-import classification
// local MCP servers, read for cloud-import classification
container.bind(LOCAL_MCP_WORKSPACE_CLIENT).toConstantValue({
listLocalMcpServers: (cwd?: string) =>
trpcClient.localMcp.list.query({ cwd }),
getLocalMcpConfig: () => trpcClient.localMcp.config.query(),
updateLocalMcpConfig: (content: string) =>
trpcClient.localMcp.updateConfig.mutate({ content }),
} satisfies LocalMcpWorkspaceClient);

// skills (team publish/install reach workspace-server through this slice)
Expand Down
80 changes: 80 additions & 0 deletions packages/agent/src/adapters/claude/session/mcp-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,52 @@ import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
loadPostHogCodeMcpServers,
loadUserClaudeJsonMcpServerEntries,
loadUserClaudeJsonMcpServers,
toAcpMcpServers,
} from "./mcp-config";

describe("loadPostHogCodeMcpServers", () => {
let tmpHome: string;

beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "posthog-mcp-test-"));
fs.mkdirSync(path.join(tmpHome, ".posthog-code"));
});

afterEach(() => {
fs.rmSync(tmpHome, { recursive: true, force: true });
});

it("loads only transports shared by Claude and Codex", () => {
fs.writeFileSync(
path.join(tmpHome, ".posthog-code", "mcp.json"),
JSON.stringify({
mcpServers: {
command: { command: "npx", args: ["server"] },
remote: { type: "http", url: "https://mcp.example.com" },
legacy: { type: "sse", url: "https://sse.example.com" },
},
}),
);

expect(Object.keys(loadPostHogCodeMcpServers(undefined, tmpHome))).toEqual([
"command",
"remote",
]);
});

it("returns empty for missing or invalid config", () => {
expect(loadPostHogCodeMcpServers(undefined, tmpHome)).toEqual({});
fs.writeFileSync(
path.join(tmpHome, ".posthog-code", "mcp.json"),
"invalid",
);
expect(loadPostHogCodeMcpServers(undefined, tmpHome)).toEqual({});
});
});

describe("loadUserClaudeJsonMcpServers", () => {
let tmpHome: string;

Expand Down Expand Up @@ -114,6 +156,44 @@ describe("loadUserClaudeJsonMcpServers", () => {
});
});

describe("toAcpMcpServers", () => {
it("preserves stdio and HTTP server credentials for another local adapter", () => {
expect(
toAcpMcpServers({
filesystem: {
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem"],
env: { ROOT: "/workspace" },
},
observability: {
type: "http",
url: "https://mcp.example.com/mcp",
headers: { Authorization: "Bearer secret" },
},
}),
).toEqual([
{
name: "filesystem",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem"],
env: [{ name: "ROOT", value: "/workspace" }],
},
{
name: "observability",
type: "http",
url: "https://mcp.example.com/mcp",
headers: [{ name: "Authorization", value: "Bearer secret" }],
},
]);
});

it("drops local entries with an unsupported transport", () => {
expect(
toAcpMcpServers({ invalid: { type: "websocket" } as never }),
).toEqual([]);
});
});

describe("loadUserClaudeJsonMcpServerEntries", () => {
let tmpHome: string;

Expand Down
83 changes: 82 additions & 1 deletion packages/agent/src/adapters/claude/session/mcp-config.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
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 { McpServer, NewSessionRequest } from "@agentclientprotocol/sdk";
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import type {
LocalMcpServerDescriptor,
LocalMcpServerScope,
LocalMcpTransport,
} from "@posthog/shared";
import {
parsePostHogMcpServers,
validatePostHogMcpConfig,
} from "@posthog/shared";
import type { Logger } from "../../../utils/logger";

export interface ClaudeJsonMcpServerEntry {
Expand Down Expand Up @@ -85,6 +89,83 @@ export function loadUserClaudeJsonMcpServers(
return servers;
}

export function loadPostHogCodeMcpServers(
logger?: Logger,
homeDir: string = os.homedir(),
): Record<string, McpServerConfig> {
const configPath = path.join(homeDir, ".posthog-code", "mcp.json");
let parsed: { mcpServers?: unknown };
try {
parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
logger?.warn("Failed to parse PostHog Code MCP config", {
error: err instanceof Error ? err.message : String(err),
});
}
return {};
}
const servers: Record<string, McpServerConfig> = {};
const issues = validatePostHogMcpConfig(parsed);
if (issues.length > 0) {
logger?.warn("Ignoring invalid PostHog Code MCP configuration", { issues });
}
for (const entry of parsePostHogMcpServers(parsed)) {
if (entry.config) servers[entry.name] = entry.config as McpServerConfig;
}
return servers;
}

/**
* Converts local Claude config entries into ACP's transport-neutral MCP shape.
* This lets non-Claude adapters use the same explicit local configuration
* without inheriting ambient agent configuration or plugins.
*/
export function toAcpMcpServers(
servers: Record<string, McpServerConfig>,
): McpServer[] {
const result: McpServer[] = [];
for (const [name, config] of Object.entries(servers)) {
const transport = parseClaudeJsonTransport(config);
switch (transport.kind) {
case "http":
result.push({
name,
type: "http",
url: transport.url,
headers: Object.entries(transport.headers ?? {}).map(
([headerName, value]) => ({ name: headerName, value }),
),
});
break;
case "sse":
result.push({
name,
type: "sse",
url: transport.url,
headers: Object.entries(transport.headers ?? {}).map(
([headerName, value]) => ({ name: headerName, value }),
),
});
break;
case "stdio":
result.push({
name,
command: transport.command,
args: transport.args ?? [],
env: Object.entries(transport.env ?? {}).map(([envName, value]) => ({
name: envName,
value,
})),
});
break;
default:
break;
}
}
return result;
}

export function sanitizeHeaders(
headers: unknown,
): Record<string, string> | undefined {
Expand Down
8 changes: 4 additions & 4 deletions packages/agent/src/adapters/claude/session/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
import { type CodeExecutionMode, toSdkPermissionMode } from "../tools";
import type { EffortLevel } from "../types";
import { buildAppendedInstructions } from "./instructions";
import { loadUserClaudeJsonMcpServers } from "./mcp-config";
import { loadPostHogCodeMcpServers } from "./mcp-config";
import { DEFAULT_MODEL, FALLBACK_MODEL } from "./models";
import { createRtkRewriteHook, resolveRtkPrefix } from "./rtk";
import type { SettingsManager } from "./settings";
Expand Down Expand Up @@ -140,10 +140,10 @@ export function buildSystemPrompt(
function buildMcpServers(
userServers: Record<string, McpServerConfig> | undefined,
acpServers: Record<string, McpServerConfig>,
projectScopedServers: Record<string, McpServerConfig>,
posthogCodeServers: Record<string, McpServerConfig>,
): Record<string, McpServerConfig> {
return {
...projectScopedServers,
...posthogCodeServers,
...(userServers || {}),
...acpServers,
};
Expand Down Expand Up @@ -465,7 +465,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
mcpServers: buildMcpServers(
params.userProvidedOptions?.mcpServers,
params.mcpServers,
loadUserClaudeJsonMcpServers(params.cwd, params.logger),
loadPostHogCodeMcpServers(params.logger),
),
env: buildEnvironment(params.gatewayEnv),
hooks: buildHooks(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ import {
emptyBaseline,
estimateTokens,
} from "../claude/context-breakdown";
import {
loadPostHogCodeMcpServers,
toAcpMcpServers,
} from "../claude/session/mcp-config";
import { isLocalSkillCommandChunk } from "../local-skill";
import { resolveSpokenNarration } from "../session-meta";
import {
Expand Down Expand Up @@ -540,8 +544,15 @@ export class CodexAppServerAgent extends BaseAcpAgent {
{ message },
),
);
const localMcpServers = toAcpMcpServers(
loadPostHogCodeMcpServers(this.logger),
);
const mcpServers = toCodexMcpServers(
[...(params.mcpServers ?? []), ...(localTools ? [localTools] : [])],
[
...localMcpServers,
...(params.mcpServers ?? []),
...(localTools ? [localTools] : []),
],
{ gatePosthogExec: true },
);
const config = buildThreadConfig(mcpServers, params.additionalDirectories);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe("toCodexMcpServers", () => {

expect(toCodexMcpServers(servers)).toEqual({
posthog: {
enabled: true,
command: "node",
args: ["server.js"],
env: { TOKEN: "abc", BASE: "http://x" },
Expand All @@ -36,7 +37,7 @@ describe("toCodexMcpServers", () => {
] as unknown as McpServer[];

expect(toCodexMcpServers(servers)).toEqual({
bare: { command: "run", args: [] },
bare: { enabled: true, command: "run", args: [] },
});
});

Expand All @@ -52,6 +53,7 @@ describe("toCodexMcpServers", () => {

expect(toCodexMcpServers(servers)).toEqual({
remote: {
enabled: true,
url: "https://mcp.example/mcp",
http_headers: { Authorization: "Bearer t" },
},
Expand All @@ -74,10 +76,11 @@ describe("toCodexMcpServers", () => {

expect(toCodexMcpServers(servers, { gatePosthogExec: true })).toEqual({
posthog_cloud: {
enabled: true,
url: "https://mcp.example/mcp",
tools: { exec: { approval_mode: "prompt" } },
},
other: { url: "https://other.example/mcp" },
other: { enabled: true, url: "https://other.example/mcp" },
});
});

Expand All @@ -91,7 +94,7 @@ describe("toCodexMcpServers", () => {
] as unknown as McpServer[];

expect(toCodexMcpServers(servers)).toEqual({
posthog: { url: "https://mcp.example/mcp" },
posthog: { enabled: true, url: "https://mcp.example/mcp" },
});
});
});
9 changes: 6 additions & 3 deletions packages/agent/src/adapters/codex-app-server/mcp-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ interface CodexMcpServerToolConfig {
}

interface CodexMcpServerPolicyConfig {
enabled: true;
tools?: Record<string, CodexMcpServerToolConfig>;
}

Expand Down Expand Up @@ -42,11 +43,13 @@ export function toCodexMcpServers(
// `approval_mode: "prompt"` makes codex ask before every exec call; the
// per-sub-tool regex filtering happens in the adapter's approval handlers,
// which auto-accept calls the session's permission policy does not gate.
const policy =
const policy: CodexMcpServerPolicyConfig = { enabled: true };
if (
options?.gatePosthogExec &&
isPostHogExecDescriptor({ server: server.name, tool: "exec" })
? { tools: { exec: { approval_mode: "prompt" as const } } }
: {};
) {
policy.tools = { exec: { approval_mode: "prompt" } };
}
if ("command" in server && server.command) {
const env = pairsToRecord(server.env);
out[server.name] = {
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/local-mcp/localMcpImport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ describe("LocalMcpImportService", () => {
it("classifies everything the workspace client reports, passing cwd through", async () => {
const listed: Array<string | undefined> = [];
const workspace: LocalMcpWorkspaceClient = {
getLocalMcpConfig: async () => ({ path: "/config", content: null }),
updateLocalMcpConfig: async (content) => ({
path: "/config",
content,
}),
listLocalMcpServers: async (cwd) => {
listed.push(cwd);
return [
Expand All @@ -196,6 +201,24 @@ describe("LocalMcpImportService", () => {
["local", "requires_desktop"],
]);
});

it("delegates config reads and writes to the workspace boundary", async () => {
const workspace: LocalMcpWorkspaceClient = {
listLocalMcpServers: async () => [],
getLocalMcpConfig: async () => ({ path: "/config", content: "old" }),
updateLocalMcpConfig: async (content) => ({ path: "/config", content }),
};
const service = new LocalMcpImportService(workspace);

await expect(service.getConfigFile()).resolves.toEqual({
path: "/config",
content: "old",
});
await expect(service.updateConfigFile("new")).resolves.toEqual({
path: "/config",
content: "new",
});
});
});

describe("partitionLocalMcpServersForRun", () => {
Expand Down
Loading
Loading