From 023d3a86ef2712ab3d6ecc0cafd8db3ae6705b67 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Fri, 24 Jul 2026 16:04:18 -0400 Subject: [PATCH 1/2] feat: support shared local MCP configuration Generated-By: PostHog Code Task-Id: 70579fde-8f5a-4edf-8d7b-5a3e4dc8f9f4 --- apps/code/src/renderer/di/container.ts | 5 +- .../claude/session/mcp-config.test.ts | 80 ++++++++ .../src/adapters/claude/session/mcp-config.ts | 83 +++++++- .../src/adapters/claude/session/options.ts | 8 +- .../codex-app-server-agent.ts | 13 +- .../codex-app-server/mcp-config.test.ts | 9 +- .../adapters/codex-app-server/mcp-config.ts | 9 +- .../core/src/local-mcp/localMcpImport.test.ts | 23 +++ packages/core/src/local-mcp/localMcpImport.ts | 17 +- .../src/routers/local-mcp.router.ts | 15 ++ packages/shared/src/index.ts | 6 + packages/shared/src/local-mcp-domain.test.ts | 81 ++++++++ packages/shared/src/local-mcp-domain.ts | 97 ++++++++- .../local-mcp/LocalMcpRailSection.tsx | 53 ++--- .../useLocalMcpCloudServers.test.tsx | 26 ++- .../local-mcp/useLocalMcpCloudServers.ts | 26 ++- .../mcp-servers/components/McpServersView.tsx | 21 +- .../parts/LocalMcpConfigView.test.tsx | 103 ++++++++++ .../components/parts/LocalMcpConfigView.tsx | 184 ++++++++++++++++++ .../components/parts/McpInstalledRail.tsx | 8 +- .../src/services/local-mcp/identifiers.ts | 6 +- .../src/services/local-mcp/local-mcp.test.ts | 122 ++++++++---- .../src/services/local-mcp/local-mcp.ts | 100 +++++++++- .../src/services/local-mcp/schemas.ts | 7 + 24 files changed, 993 insertions(+), 109 deletions(-) create mode 100644 packages/shared/src/local-mcp-domain.test.ts create mode 100644 packages/ui/src/features/mcp-servers/components/parts/LocalMcpConfigView.test.tsx create mode 100644 packages/ui/src/features/mcp-servers/components/parts/LocalMcpConfigView.tsx diff --git a/apps/code/src/renderer/di/container.ts b/apps/code/src/renderer/di/container.ts index 5d7d27ddf4..1158d0d81a 100644 --- a/apps/code/src/renderer/di/container.ts +++ b/apps/code/src/renderer/di/container.ts @@ -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) diff --git a/packages/agent/src/adapters/claude/session/mcp-config.test.ts b/packages/agent/src/adapters/claude/session/mcp-config.test.ts index 47934dc117..6390321e78 100644 --- a/packages/agent/src/adapters/claude/session/mcp-config.test.ts +++ b/packages/agent/src/adapters/claude/session/mcp-config.test.ts @@ -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; @@ -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; diff --git a/packages/agent/src/adapters/claude/session/mcp-config.ts b/packages/agent/src/adapters/claude/session/mcp-config.ts index d629f61829..bba435bbad 100644 --- a/packages/agent/src/adapters/claude/session/mcp-config.ts +++ b/packages/agent/src/adapters/claude/session/mcp-config.ts @@ -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 { @@ -85,6 +89,83 @@ export function loadUserClaudeJsonMcpServers( return servers; } +export function loadPostHogCodeMcpServers( + logger?: Logger, + homeDir: string = os.homedir(), +): Record { + 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 = {}; + 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, +): 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 | undefined { diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index 5b8808e807..27e925ab28 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -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"; @@ -140,10 +140,10 @@ export function buildSystemPrompt( function buildMcpServers( userServers: Record | undefined, acpServers: Record, - projectScopedServers: Record, + posthogCodeServers: Record, ): Record { return { - ...projectScopedServers, + ...posthogCodeServers, ...(userServers || {}), ...acpServers, }; @@ -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( diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index 74a7c15390..7cdff2f30a 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -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 { @@ -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); diff --git a/packages/agent/src/adapters/codex-app-server/mcp-config.test.ts b/packages/agent/src/adapters/codex-app-server/mcp-config.test.ts index 01c1196e20..f1b6280546 100644 --- a/packages/agent/src/adapters/codex-app-server/mcp-config.test.ts +++ b/packages/agent/src/adapters/codex-app-server/mcp-config.test.ts @@ -23,6 +23,7 @@ describe("toCodexMcpServers", () => { expect(toCodexMcpServers(servers)).toEqual({ posthog: { + enabled: true, command: "node", args: ["server.js"], env: { TOKEN: "abc", BASE: "http://x" }, @@ -36,7 +37,7 @@ describe("toCodexMcpServers", () => { ] as unknown as McpServer[]; expect(toCodexMcpServers(servers)).toEqual({ - bare: { command: "run", args: [] }, + bare: { enabled: true, command: "run", args: [] }, }); }); @@ -52,6 +53,7 @@ describe("toCodexMcpServers", () => { expect(toCodexMcpServers(servers)).toEqual({ remote: { + enabled: true, url: "https://mcp.example/mcp", http_headers: { Authorization: "Bearer t" }, }, @@ -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" }, }); }); @@ -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" }, }); }); }); diff --git a/packages/agent/src/adapters/codex-app-server/mcp-config.ts b/packages/agent/src/adapters/codex-app-server/mcp-config.ts index 36465825a2..49097bd3aa 100644 --- a/packages/agent/src/adapters/codex-app-server/mcp-config.ts +++ b/packages/agent/src/adapters/codex-app-server/mcp-config.ts @@ -6,6 +6,7 @@ interface CodexMcpServerToolConfig { } interface CodexMcpServerPolicyConfig { + enabled: true; tools?: Record; } @@ -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] = { diff --git a/packages/core/src/local-mcp/localMcpImport.test.ts b/packages/core/src/local-mcp/localMcpImport.test.ts index c792c5118e..1e6ec7e280 100644 --- a/packages/core/src/local-mcp/localMcpImport.test.ts +++ b/packages/core/src/local-mcp/localMcpImport.test.ts @@ -177,6 +177,11 @@ describe("LocalMcpImportService", () => { it("classifies everything the workspace client reports, passing cwd through", async () => { const listed: Array = []; const workspace: LocalMcpWorkspaceClient = { + getLocalMcpConfig: async () => ({ path: "/config", content: null }), + updateLocalMcpConfig: async (content) => ({ + path: "/config", + content, + }), listLocalMcpServers: async (cwd) => { listed.push(cwd); return [ @@ -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", () => { diff --git a/packages/core/src/local-mcp/localMcpImport.ts b/packages/core/src/local-mcp/localMcpImport.ts index bafab32ec2..2e5267311d 100644 --- a/packages/core/src/local-mcp/localMcpImport.ts +++ b/packages/core/src/local-mcp/localMcpImport.ts @@ -11,6 +11,10 @@ import { LOCAL_MCP_WORKSPACE_CLIENT } from "./identifiers"; /** The slice of workspace-server this service needs, bound by the host. */ export interface LocalMcpWorkspaceClient { listLocalMcpServers(cwd?: string): Promise; + getLocalMcpConfig(): Promise<{ path: string; content: string | null }>; + updateLocalMcpConfig( + content: string, + ): Promise<{ path: string; content: string | null }>; } export type LocalMcpCloudAvailability = @@ -199,8 +203,7 @@ export class LocalMcpImportService { /** * Classifies the user's local MCP servers by whether they can be imported - * into a cloud sandbox. `cwd` picks up ~/.claude.json project-scoped - * servers for that checkout in addition to user-scoped ones. + * into a cloud sandbox. */ async getCloudAvailability( cwd?: string, @@ -208,4 +211,14 @@ export class LocalMcpImportService { const servers = await this.workspace.listLocalMcpServers(cwd); return servers.map(classifyLocalMcpServer); } + + async getConfigFile(): Promise<{ path: string; content: string | null }> { + return this.workspace.getLocalMcpConfig(); + } + + async updateConfigFile( + content: string, + ): Promise<{ path: string; content: string | null }> { + return this.workspace.updateLocalMcpConfig(content); + } } diff --git a/packages/host-router/src/routers/local-mcp.router.ts b/packages/host-router/src/routers/local-mcp.router.ts index 0841086375..757dfb718b 100644 --- a/packages/host-router/src/routers/local-mcp.router.ts +++ b/packages/host-router/src/routers/local-mcp.router.ts @@ -6,6 +6,8 @@ import { import { listLocalMcpServersInput, listLocalMcpServersOutput, + localMcpConfigFileOutput, + updateLocalMcpConfigFileInput, } from "@posthog/workspace-server/services/local-mcp/schemas"; export const localMcpRouter = router({ @@ -17,4 +19,17 @@ export const localMcpRouter = router({ .get(LOCAL_MCP_SERVICE) .listServers(input.cwd), ), + config: publicProcedure + .output(localMcpConfigFileOutput) + .query(({ ctx }) => + ctx.container.get(LOCAL_MCP_SERVICE).getConfigFile(), + ), + updateConfig: publicProcedure + .input(updateLocalMcpConfigFileInput) + .output(localMcpConfigFileOutput) + .mutation(({ ctx, input }) => + ctx.container + .get(LOCAL_MCP_SERVICE) + .updateConfigFile(input.content), + ), }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ff03602e7e..a9d7001adb 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -166,6 +166,12 @@ export type { LocalMcpServerDescriptor, LocalMcpServerScope, LocalMcpTransport, + ParsedPostHogMcpServer, + PostHogMcpServerConfig, +} from "./local-mcp-domain"; +export { + parsePostHogMcpServers, + validatePostHogMcpConfig, } from "./local-mcp-domain"; export { formatMention, diff --git a/packages/shared/src/local-mcp-domain.test.ts b/packages/shared/src/local-mcp-domain.test.ts new file mode 100644 index 0000000000..32cdc5d7f2 --- /dev/null +++ b/packages/shared/src/local-mcp-domain.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { + parsePostHogMcpServers, + validatePostHogMcpConfig, +} from "./local-mcp-domain"; + +describe("parsePostHogMcpServers", () => { + it("validates the shared stdio and HTTP configuration", () => { + expect( + parsePostHogMcpServers({ + mcpServers: { + command: { + command: "npx", + args: ["server"], + env: { TOKEN: "secret" }, + }, + remote: { + type: "http", + url: "https://mcp.example.com", + headers: { Authorization: "Bearer secret" }, + }, + }, + }), + ).toEqual([ + { + name: "command", + config: { + type: "stdio", + command: "npx", + args: ["server"], + env: { TOKEN: "secret" }, + }, + }, + { + name: "remote", + config: { + type: "http", + url: "https://mcp.example.com", + headers: { Authorization: "Bearer secret" }, + }, + }, + ]); + }); + + it.each([ + { name: "array root", value: [] }, + { name: "array server map", value: { mcpServers: [] } }, + ])("rejects an $name", ({ value }) => { + expect(parsePostHogMcpServers(value)).toEqual([]); + }); + + it.each([ + { name: "unsupported transport", config: { type: "sse", url: "x" } }, + { name: "non-string argument", config: { command: "x", args: [1] } }, + { name: "non-string environment", config: { command: "x", env: { A: 1 } } }, + { + name: "non-string header", + config: { type: "http", url: "x", headers: { A: 1 } }, + }, + ])("retains the name of an invalid $name", ({ config }) => { + expect(parsePostHogMcpServers({ mcpServers: { broken: config } })).toEqual([ + { name: "broken", config: null }, + ]); + }); +}); + +describe("validatePostHogMcpConfig", () => { + it("reports invalid roots and named server entries", () => { + expect(validatePostHogMcpConfig(null)).toEqual([ + "Configuration must be a JSON object", + ]); + expect(validatePostHogMcpConfig({ mcpServers: [] })).toEqual([ + "mcpServers must be an object", + ]); + expect( + validatePostHogMcpConfig({ + mcpServers: { broken: { type: "sse", url: "https://example.com" } }, + }), + ).toEqual(["Invalid MCP server configuration: broken"]); + }); +}); diff --git a/packages/shared/src/local-mcp-domain.ts b/packages/shared/src/local-mcp-domain.ts index c779a7c95e..bf96f9d942 100644 --- a/packages/shared/src/local-mcp-domain.ts +++ b/packages/shared/src/local-mcp-domain.ts @@ -1,9 +1,8 @@ // Host-agnostic shapes for the user's locally configured MCP servers -// (~/.claude.json) as they relate to cloud task runs. The workspace-server +// (~/.posthog-code/mcp.json) as they relate to task runs. The workspace-server // reads the config from disk; @posthog/core classifies each server by whether // it can be imported into a cloud sandbox. -/** Where a local MCP server definition came from in ~/.claude.json. */ export type LocalMcpServerScope = "user" | "project"; /** @@ -16,6 +15,100 @@ export type LocalMcpTransport = | { type: "stdio"; command: string; args?: string[] } | { type: "unknown" }; +export type PostHogMcpServerConfig = + | { + type?: "stdio"; + command: string; + args?: string[]; + env?: Record; + } + | { + type: "http"; + url: string; + headers?: Record; + }; + +export interface ParsedPostHogMcpServer { + name: string; + config: PostHogMcpServerConfig | null; +} + +function stringRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const entries = Object.entries(value); + if (entries.some(([, entry]) => typeof entry !== "string")) return null; + return Object.fromEntries(entries) as Record; +} + +export function parsePostHogMcpServers( + value: unknown, +): ParsedPostHogMcpServer[] { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const servers = (value as { mcpServers?: unknown }).mcpServers; + if (!servers || typeof servers !== "object" || Array.isArray(servers)) { + return []; + } + + return Object.entries(servers).map(([name, raw]) => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return { name, config: null }; + } + const config = raw as Record; + if (config.type === "http" && typeof config.url === "string") { + const headers = + config.headers === undefined ? undefined : stringRecord(config.headers); + if (headers === null) return { name, config: null }; + return { + name, + config: { + type: "http" as const, + url: config.url, + ...(headers !== undefined ? { headers } : {}), + }, + }; + } + if ( + (config.type === undefined || config.type === "stdio") && + typeof config.command === "string" + ) { + const args = config.args; + const env = + config.env === undefined ? undefined : stringRecord(config.env); + if ( + (args !== undefined && + (!Array.isArray(args) || + args.some((entry) => typeof entry !== "string"))) || + env === null + ) { + return { name, config: null }; + } + return { + name, + config: { + type: "stdio" as const, + command: config.command, + ...(args !== undefined ? { args: args as string[] } : {}), + ...(env !== undefined ? { env } : {}), + }, + }; + } + return { name, config: null }; + }); +} + +export function validatePostHogMcpConfig(value: unknown): string[] { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return ["Configuration must be a JSON object"]; + } + const servers = (value as { mcpServers?: unknown }).mcpServers; + if (!servers || typeof servers !== "object" || Array.isArray(servers)) { + return ["mcpServers must be an object"]; + } + return parsePostHogMcpServers(value) + .filter((entry) => entry.config === null) + .map((entry) => `Invalid MCP server configuration: ${entry.name}`); +} + /** * A locally configured MCP server as reported by the workspace-server. * Deliberately excludes stdio `env` values, which routinely hold secrets the diff --git a/packages/ui/src/features/local-mcp/LocalMcpRailSection.tsx b/packages/ui/src/features/local-mcp/LocalMcpRailSection.tsx index 63247b3f15..2e00ae04f1 100644 --- a/packages/ui/src/features/local-mcp/LocalMcpRailSection.tsx +++ b/packages/ui/src/features/local-mcp/LocalMcpRailSection.tsx @@ -1,37 +1,33 @@ -import { Plugs } from "@phosphor-icons/react"; +import { FolderOpen, Plugs } from "@phosphor-icons/react"; import type { LocalMcpCloudClassification } from "@posthog/core/local-mcp/localMcpImport"; -import { Flex, Text } from "@radix-ui/themes"; +import { Button, Flex, Text } from "@radix-ui/themes"; -const AVAILABILITY_LABELS: Record< - LocalMcpCloudClassification["availability"], - string -> = { - importable: "Available in cloud", - requires_desktop: "Relayed via your machine", - built_in: "Built into cloud runs", - unsupported: "Not available in cloud", -}; +function transportLabel(server: LocalMcpCloudClassification): string { + if (server.reason === "reserved_name") return "Reserved name"; + if (server.reason === "stdio_transport") return "Local command"; + if (server.reason === "public_url" || server.reason === "private_url") { + return "HTTP server"; + } + return "Unsupported configuration"; +} interface LocalMcpRailSectionProps { servers: LocalMcpCloudClassification[]; search: string; + onOpenConfig: () => void; } -/** - * Rail section listing the user's local (~/.claude.json) MCP servers and - * whether each will be available inside cloud task runs. Purely - * informational: these servers are configured outside the app, so the rows - * open no detail view. - */ +/** Local PostHog Code MCP servers shared by local agent adapters. */ export function LocalMcpRailSection({ servers, search, + onOpenConfig, }: LocalMcpRailSectionProps) { const query = search.trim().toLowerCase(); const visible = query ? servers.filter((server) => server.name.toLowerCase().includes(query)) : servers; - if (visible.length === 0) return null; + if (visible.length === 0 && query) return null; return ( <> @@ -46,16 +42,20 @@ export function LocalMcpRailSection({ Local - - {visible.length} - + + {visible.map((server) => (
- {AVAILABILITY_LABELS[server.availability]} + {transportLabel(server)}
))} + {visible.length === 0 && ( + + No local servers configured. + + )} ); } diff --git a/packages/ui/src/features/local-mcp/useLocalMcpCloudServers.test.tsx b/packages/ui/src/features/local-mcp/useLocalMcpCloudServers.test.tsx index db776d46bf..5f1657b880 100644 --- a/packages/ui/src/features/local-mcp/useLocalMcpCloudServers.test.tsx +++ b/packages/ui/src/features/local-mcp/useLocalMcpCloudServers.test.tsx @@ -19,7 +19,10 @@ vi.mock("../feature-flags/useFeatureFlag", () => ({ useFeatureFlag: () => mocks.flagEnabled, })); -import { useLocalMcpCloudServers } from "./useLocalMcpCloudServers"; +import { + useLocalMcpCloudServers, + useLocalMcpServers, +} from "./useLocalMcpCloudServers"; function wrapper({ children }: { children: ReactNode }) { const queryClient = new QueryClient({ @@ -43,7 +46,7 @@ describe("useLocalMcpCloudServers", () => { mocks.getCloudAvailability.mockResolvedValue([server]); }); - it("does not inspect local MCP servers while the feature flag is disabled", () => { + it("does not import local MCP servers into cloud while the flag is disabled", () => { const { result } = renderHook(() => useLocalMcpCloudServers(true), { wrapper, }); @@ -52,9 +55,8 @@ describe("useLocalMcpCloudServers", () => { expect(mocks.getCloudAvailability).not.toHaveBeenCalled(); }); - it("returns local MCP servers while the feature flag is enabled", async () => { + it("returns local MCP servers for cloud while the flag is enabled", async () => { mocks.flagEnabled = true; - const { result } = renderHook(() => useLocalMcpCloudServers(true), { wrapper, }); @@ -64,16 +66,20 @@ describe("useLocalMcpCloudServers", () => { ); }); - it("hides cached local MCP servers when the feature flag is disabled", async () => { - mocks.flagEnabled = true; + it("always returns local MCP servers for the settings page", async () => { + const { result } = renderHook(() => useLocalMcpServers(true), { wrapper }); + + await waitFor(() => expect(result.current.servers).toEqual([server])); + }); + + it("hides cached local MCP servers when disabled", async () => { const { result, rerender } = renderHook( - () => useLocalMcpCloudServers(true), - { wrapper }, + ({ enabled }) => useLocalMcpServers(enabled), + { wrapper, initialProps: { enabled: true } }, ); await waitFor(() => expect(result.current.servers).toEqual([server])); - mocks.flagEnabled = false; - rerender(); + rerender({ enabled: false }); expect(result.current).toEqual({ servers: [], isLoading: false }); }); diff --git a/packages/ui/src/features/local-mcp/useLocalMcpCloudServers.ts b/packages/ui/src/features/local-mcp/useLocalMcpCloudServers.ts index 8ff7976ee6..f809e4fe7e 100644 --- a/packages/ui/src/features/local-mcp/useLocalMcpCloudServers.ts +++ b/packages/ui/src/features/local-mcp/useLocalMcpCloudServers.ts @@ -19,24 +19,21 @@ export interface LocalMcpCloudServersResult { } /** - * The user's local (~/.claude.json) MCP servers classified by cloud + * The user's local (~/.posthog-code/mcp.json) MCP servers classified by cloud * availability. Empty on hosts without a local workspace (web/mobile — the - * service is only bound on desktop) and while the feature flag is off. + * service is only bound on desktop). */ -export function useLocalMcpCloudServers( - enabled: boolean, -): LocalMcpCloudServersResult { +function useLocalMcpServersQuery(enabled: boolean): LocalMcpCloudServersResult { const service = useServiceOptional( LOCAL_MCP_IMPORT_SERVICE, ); - const flagEnabled = useFeatureFlag(LOCAL_MCP_IMPORT_FLAG); - const queryEnabled = enabled && flagEnabled && !!service; + const queryEnabled = enabled && !!service; const query = useQuery({ queryKey: ["local-mcp-cloud-availability"], queryFn: () => (service ? service.getCloudAvailability() : NO_SERVERS), enabled: queryEnabled, - staleTime: 30_000, + refetchOnWindowFocus: true, }); return { @@ -44,3 +41,16 @@ export function useLocalMcpCloudServers( isLoading: queryEnabled && query.isLoading, }; } + +export function useLocalMcpServers( + enabled: boolean, +): LocalMcpCloudServersResult { + return useLocalMcpServersQuery(enabled); +} + +export function useLocalMcpCloudServers( + enabled: boolean, +): LocalMcpCloudServersResult { + const flagEnabled = useFeatureFlag(LOCAL_MCP_IMPORT_FLAG); + return useLocalMcpServersQuery(enabled && flagEnabled); +} diff --git a/packages/ui/src/features/mcp-servers/components/McpServersView.tsx b/packages/ui/src/features/mcp-servers/components/McpServersView.tsx index 791d4d702e..7c50459905 100644 --- a/packages/ui/src/features/mcp-servers/components/McpServersView.tsx +++ b/packages/ui/src/features/mcp-servers/components/McpServersView.tsx @@ -2,7 +2,7 @@ import type { McpRecommendedServer, McpServerInstallation, } from "@posthog/api-client/posthog-client"; -import { useLocalMcpCloudServers } from "@posthog/ui/features/local-mcp/useLocalMcpCloudServers"; +import { useLocalMcpServers } from "@posthog/ui/features/local-mcp/useLocalMcpCloudServers"; import { AddCustomServerForm } from "@posthog/ui/features/mcp-server-manager/AddCustomServerForm"; import { MarketplaceView } from "@posthog/ui/features/mcp-servers/components/parts/MarketplaceView"; import { McpInstalledRail } from "@posthog/ui/features/mcp-servers/components/parts/McpInstalledRail"; @@ -18,13 +18,15 @@ import { } from "@radix-ui/themes"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { LocalMcpConfigView } from "./parts/LocalMcpConfigView"; import { ServerDetailView } from "./parts/ServerDetailView"; type SceneView = | { kind: "marketplace" } | { kind: "detail-installation"; installationId: string } | { kind: "detail-template"; templateId: string } - | { kind: "add-custom" }; + | { kind: "add-custom" } + | { kind: "local-config"; openKey: number }; export function McpServersView() { const queryClient = useQueryClient(); @@ -59,11 +61,14 @@ export function McpServersView() { reauthorizePending, } = useMcpServers(); - const { servers: localServers } = useLocalMcpCloudServers(true); + const { servers: localServers } = useLocalMcpServers(true); useEffect(() => { const refreshMcpState = () => { queryClient.invalidateQueries({ queryKey: ["mcp"] }); + queryClient.invalidateQueries({ + queryKey: ["local-mcp-cloud-availability"], + }); }; const handleVisibilityChange = () => { if (document.visibilityState === "visible") refreshMcpState(); @@ -185,6 +190,10 @@ export function McpServersView() { ); } + if (view.kind === "local-config") { + return ; + } + if ( view.kind === "detail-installation" || view.kind === "detail-template" @@ -264,6 +273,12 @@ export function McpServersView() { localServers={localServers} selectedInstallationId={selectedInstallationId} onAddCustom={() => setView({ kind: "add-custom" })} + onOpenLocalConfig={() => + setView((current) => ({ + kind: "local-config", + openKey: current.kind === "local-config" ? current.openKey + 1 : 1, + })) + } onSelectInstallation={(installationId) => setView({ kind: "detail-installation", installationId }) } diff --git a/packages/ui/src/features/mcp-servers/components/parts/LocalMcpConfigView.test.tsx b/packages/ui/src/features/mcp-servers/components/parts/LocalMcpConfigView.test.tsx new file mode 100644 index 0000000000..28a2c40f50 --- /dev/null +++ b/packages/ui/src/features/mcp-servers/components/parts/LocalMcpConfigView.test.tsx @@ -0,0 +1,103 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getConfigFile: vi.fn(), + updateConfigFile: vi.fn(), +})); + +vi.mock("@posthog/di/react", () => ({ + useService: () => mocks, +})); + +vi.mock("@posthog/ui/features/skills/SkillCodeEditor", () => ({ + SkillCodeEditor: ({ + initialContent, + onDocChanged, + }: { + initialContent: string; + onDocChanged: (content: string) => void; + }) => ( +