diff --git a/apps/code/src/main/services/agent/auth-adapter.test.ts b/apps/code/src/main/services/agent/auth-adapter.test.ts index 8bda73c1b..4d3aaf1ff 100644 --- a/apps/code/src/main/services/agent/auth-adapter.test.ts +++ b/apps/code/src/main/services/agent/auth-adapter.test.ts @@ -85,7 +85,7 @@ describe("AgentAuthAdapter", () => { }); it("builds the default PostHog MCP server routed through the local proxy", async () => { - const servers = await adapter.buildMcpServers(baseCredentials); + const { servers } = await adapter.buildMcpServers(baseCredentials); expect(deps.mcpProxy.register).toHaveBeenCalledWith( "posthog", @@ -126,7 +126,7 @@ describe("AgentAuthAdapter", () => { }), }); - const servers = await adapter.buildMcpServers(baseCredentials); + const { servers } = await adapter.buildMcpServers(baseCredentials); expect(deps.mcpProxy.register).toHaveBeenCalledWith( "installation-inst-2", @@ -143,6 +143,81 @@ describe("AgentAuthAdapter", () => { ); }); + it("fetches tool approval states for installations", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { + id: "inst-3", + url: "https://tools.example.com", + proxy_url: "https://proxy.posthog.com/inst-3/", + name: "tool-server", + display_name: "Tool Server", + auth_type: "oauth", + is_enabled: true, + pending_oauth: false, + needs_reauth: false, + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { tool_name: "read_data", approval_state: "approved" }, + { tool_name: "write_data", approval_state: "do_not_use" }, + { tool_name: "query", approval_state: "needs_approval" }, + ], + }), + }); + + const { toolApprovals, toolInstallations } = + await adapter.buildMcpServers(baseCredentials); + + expect(toolApprovals).toEqual({ + "mcp__tool-server__read_data": "approved", + "mcp__tool-server__write_data": "do_not_use", + "mcp__tool-server__query": "needs_approval", + }); + expect(toolInstallations["mcp__tool-server__read_data"]).toEqual({ + installationId: "inst-3", + toolName: "read_data", + }); + }); + + it("returns empty approvals when tool fetch fails", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { + id: "inst-4", + url: "https://broken.example.com", + proxy_url: "https://proxy.posthog.com/inst-4/", + name: "broken-server", + display_name: "Broken Server", + auth_type: "oauth", + is_enabled: true, + pending_oauth: false, + needs_reauth: false, + }, + ], + }), + }) + .mockResolvedValueOnce({ ok: false, status: 500 }); + + const { toolApprovals } = await adapter.buildMcpServers(baseCredentials); + + expect(toolApprovals).toEqual({}); + }); + it("configures environment using the gateway proxy and current token", async () => { await adapter.configureProcessEnv({ credentials: baseCredentials, diff --git a/apps/code/src/main/services/agent/auth-adapter.ts b/apps/code/src/main/services/agent/auth-adapter.ts index 8028ec00b..1cfa711fe 100644 --- a/apps/code/src/main/services/agent/auth-adapter.ts +++ b/apps/code/src/main/services/agent/auth-adapter.ts @@ -1,4 +1,9 @@ import { delimiter } from "node:path"; +import { + type McpToolApprovalState, + type McpToolApprovals, + sanitizeMcpServerName, +} from "@posthog/agent/adapters/claude/mcp/tool-metadata"; import { getLlmGatewayUrl } from "@posthog/agent/posthog-api"; import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; @@ -10,6 +15,15 @@ import type { Credentials } from "./schemas"; const log = logger.scope("agent-auth-adapter"); +const VALID_APPROVAL_STATES = new Set([ + "approved", + "needs_approval", + "do_not_use", +]); +function isValidApprovalState(value: string): value is McpToolApprovalState { + return VALID_APPROVAL_STATES.has(value); +} + export interface AcpMcpServer { name: string; type: "http"; @@ -24,6 +38,15 @@ export interface AgentPosthogConfig { projectId: number; } +/** Reference linking an MCP tool key back to its server installation for backend updates. */ +export interface McpToolInstallationRef { + installationId: string; + toolName: string; +} + +/** Maps MCP tool keys (e.g. `mcp__server__tool`) to their installation reference. */ +export type McpToolInstallations = Record; + interface ConfigureProcessEnvInput { credentials: Credentials; mockNodeDir: string; @@ -51,7 +74,11 @@ export class AgentAuthAdapter { }; } - async buildMcpServers(credentials: Credentials): Promise { + async buildMcpServers(credentials: Credentials): Promise<{ + servers: AcpMcpServer[]; + toolApprovals: McpToolApprovals; + toolInstallations: McpToolInstallations; + }> { const servers: AcpMcpServer[] = []; const mcpUrl = this.getPostHogMcpUrl(credentials.apiHost); // Warm the token so authenticatedFetch() has something cached, but do not @@ -95,7 +122,10 @@ export class AgentAuthAdapter { }); } - return servers; + const { approvals: toolApprovals, toolInstallations } = + await this.fetchMcpToolApprovals(credentials, installations); + + return { servers, toolApprovals, toolInstallations }; } async ensureGatewayProxy(apiHost: string): Promise { @@ -154,6 +184,96 @@ export class AgentAuthAdapter { return host.endsWith("/") ? host.slice(0, -1) : host; } + async updateMcpToolApproval( + credentials: Credentials, + installationId: string, + toolName: string, + approvalState: McpToolApprovalState, + ): Promise { + const baseUrl = this.getPostHogApiBaseUrl(credentials.apiHost); + const url = `${baseUrl}/api/environments/${credentials.projectId}/mcp_server_installations/${installationId}/tools/${encodeURIComponent(toolName)}/`; + const response = await this.authService.authenticatedFetch(fetch, url, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ approval_state: approvalState }), + }); + if (!response.ok) { + throw new Error( + `Failed to update MCP tool approval (${response.status}) for ${toolName} on installation ${installationId}`, + ); + } + } + + private async fetchMcpToolApprovals( + credentials: Credentials, + installations: Array<{ + id: string; + url: string; + name: string; + display_name: string; + }>, + ): Promise<{ + approvals: McpToolApprovals; + toolInstallations: McpToolInstallations; + }> { + const baseUrl = this.getPostHogApiBaseUrl(credentials.apiHost); + const approvals: McpToolApprovals = {}; + const toolInstallations: McpToolInstallations = {}; + + const results = await Promise.allSettled( + installations.map(async (installation) => { + const serverName = sanitizeMcpServerName( + installation.name || installation.display_name || installation.url, + ); + const toolsUrl = `${baseUrl}/api/environments/${credentials.projectId}/mcp_server_installations/${installation.id}/tools/`; + + const response = await this.authService.authenticatedFetch( + fetch, + toolsUrl, + { headers: { "Content-Type": "application/json" } }, + ); + if (!response.ok) return []; + + const data = (await response.json()) as { + results?: Array<{ + tool_name: string; + approval_state?: string; + }>; + }; + return (data.results ?? []).map((tool) => ({ + serverName, + installationId: installation.id, + toolName: tool.tool_name, + approvalState: tool.approval_state, + })); + }), + ); + + for (const result of results) { + if (result.status !== "fulfilled") { + log.warn("Failed to fetch tool approvals for an installation", { + error: + result.reason instanceof Error + ? result.reason.message + : String(result.reason), + }); + continue; + } + for (const tool of result.value) { + const key = `mcp__${tool.serverName}__${tool.toolName}`; + if (tool.approvalState && isValidApprovalState(tool.approvalState)) { + approvals[key] = tool.approvalState; + } + toolInstallations[key] = { + installationId: tool.installationId, + toolName: tool.toolName, + }; + } + } + + return { approvals, toolInstallations }; + } + private async fetchMcpInstallations(credentials: Credentials): Promise< Array<{ id: string; diff --git a/apps/code/src/main/services/agent/service.test.ts b/apps/code/src/main/services/agent/service.test.ts index 7bd00aa92..6dcab002c 100644 --- a/apps/code/src/main/services/agent/service.test.ts +++ b/apps/code/src/main/services/agent/service.test.ts @@ -154,14 +154,18 @@ function createMockDependencies() { refreshApiKey: vi.fn().mockResolvedValue("fresh-access-token"), projectId: credentials.projectId, })), - buildMcpServers: vi.fn().mockResolvedValue([ - { - name: "posthog", - type: "http", - url: "https://mcp.posthog.com/mcp", - headers: [], - }, - ]), + buildMcpServers: vi.fn().mockResolvedValue({ + servers: [ + { + name: "posthog", + type: "http", + url: "https://mcp.posthog.com/mcp", + headers: [], + }, + ], + toolApprovals: {}, + toolInstallations: {}, + }), }, mcpAppsService: { setServerConfigs: vi.fn(), @@ -301,6 +305,8 @@ describe("AgentService", () => { config: {}, promptPending: false, inFlightMcpToolCalls: new Map(), + mcpToolApprovals: {}, + toolInstallations: {}, ...overrides, }); } diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 66212f7f6..af80ec302 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -17,6 +17,7 @@ import { isNotification, POSTHOG_NOTIFICATIONS, } from "@posthog/agent"; +import type { McpToolApprovals } from "@posthog/agent/adapters/claude/mcp/tool-metadata"; import { hydrateSessionJsonl } from "@posthog/agent/adapters/claude/session/jsonl-hydration"; import { getReasoningEffortOptions } from "@posthog/agent/adapters/reasoning-effort"; import { Agent } from "@posthog/agent/agent"; @@ -52,7 +53,7 @@ import type { McpAppsService } from "../mcp-apps/service"; import type { PosthogPluginService } from "../posthog-plugin/service"; import type { ProcessTrackingService } from "../process-tracking/service"; import type { SleepService } from "../sleep/service"; -import type { AgentAuthAdapter } from "./auth-adapter"; +import type { AgentAuthAdapter, McpToolInstallations } from "./auth-adapter"; import { discoverExternalPlugins } from "./discover-plugins"; import { AgentServiceEvent, @@ -242,6 +243,10 @@ interface ManagedSession { configOptions?: SessionConfigOption[]; /** Tracks in-flight MCP tool calls (toolCallId → toolKey) for cancellation */ inFlightMcpToolCalls: Map; + /** MCP tool approval states fetched at session start */ + mcpToolApprovals: McpToolApprovals; + /** Maps tool keys to their installation for backend approval updates */ + toolInstallations: McpToolInstallations; } /** Get the agent session ID from a managed session, throwing if not set. */ @@ -647,8 +652,11 @@ When creating pull requests, add the following footer at the end of the PR descr }, }); - const mcpServers = - await this.agentAuthAdapter.buildMcpServers(credentials); + const { + servers: mcpServers, + toolApprovals, + toolInstallations, + } = await this.agentAuthAdapter.buildMcpServers(credentials); // Store server configs for lazy MCP connections — actual connections // are created on-demand when UI resources are first requested. @@ -735,6 +743,7 @@ When creating pull requests, add the following footer at the end of the PR descr taskRunId, sessionId: existingSessionId, systemPrompt, + mcpToolApprovals: toolApprovals, ...(permissionMode && { permissionMode }), ...(model != null && { model }), ...(jsonSchema && { jsonSchema }), @@ -758,6 +767,7 @@ When creating pull requests, add the following footer at the end of the PR descr _meta: { taskRunId, systemPrompt, + mcpToolApprovals: toolApprovals, ...(permissionMode && { permissionMode }), ...(model != null && { model }), ...(jsonSchema && { jsonSchema }), @@ -785,6 +795,8 @@ When creating pull requests, add the following footer at the end of the PR descr promptPending: false, configOptions, inFlightMcpToolCalls: new Map(), + mcpToolApprovals: toolApprovals, + toolInstallations, }; this.sessions.set(taskRunId, session); @@ -1216,19 +1228,23 @@ For git operations while detached: }); if (toolName && isMcpToolReadOnly(toolName)) { - log.info("Auto-approving read-only MCP tool", { - taskRunId, - toolName, - }); - const allowOption = params.options.find( - (o) => o.kind === "allow_once" || o.kind === "allow_always", - ); - return { - outcome: { - outcome: "selected", - optionId: allowOption?.optionId ?? params.options[0].optionId, - }, - }; + const session = service.sessions.get(taskRunId); + const approvalState = session?.mcpToolApprovals?.[toolName]; + if (approvalState === "approved") { + log.info("Auto-approving read-only MCP tool", { + taskRunId, + toolName, + }); + const allowOption = params.options.find( + (o) => o.kind === "allow_once" || o.kind === "allow_always", + ); + return { + outcome: { + outcome: "selected", + optionId: allowOption?.optionId ?? params.options[0].optionId, + }, + }; + } } // If we have a toolCallId, always prompt the user for permission. @@ -1237,7 +1253,7 @@ For git operations while detached: if (toolCallId) { service.sleepService.release(taskRunId); try { - return await new Promise( + const response = await new Promise( (resolve, reject) => { const key = `${taskRunId}:${toolCallId}`; service.pendingPermissions.set(key, { @@ -1258,6 +1274,37 @@ For git operations while detached: }); }, ); + + const approved = + response.outcome?.outcome === "selected" && + (response.outcome.optionId === "allow" || + response.outcome.optionId === "allow_always"); + if (approved && toolName) { + const session = service.sessions.get(taskRunId); + if ( + session?.mcpToolApprovals?.[toolName] === "needs_approval" && + session.toolInstallations[toolName] + ) { + const { installationId, toolName: rawToolName } = + session.toolInstallations[toolName]; + try { + await service.agentAuthAdapter.updateMcpToolApproval( + session.config.credentials, + installationId, + rawToolName, + "approved", + ); + session.mcpToolApprovals[toolName] = "approved"; + } catch (err) { + log.warn("Failed to update tool approval on backend", { + toolName, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + + return response; } finally { // Only re-acquire if session wasn't cleaned up while waiting if (service.sessions.has(taskRunId)) { diff --git a/apps/code/src/renderer/features/settings/components/sections/mcp/ToolRow.tsx b/apps/code/src/renderer/features/settings/components/sections/mcp/ToolRow.tsx index 4d1767d7f..4cf1f6384 100644 --- a/apps/code/src/renderer/features/settings/components/sections/mcp/ToolRow.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/mcp/ToolRow.tsx @@ -49,7 +49,10 @@ export function ToolRow({ tool, onChange }: ToolRowProps) { size="2" weight="medium" truncate + className="select-text" style={{ fontFamily: "var(--code-font-family)" }} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > {tool.tool_name} diff --git a/apps/code/src/renderer/features/settings/hooks/useMcpInstallationTools.ts b/apps/code/src/renderer/features/settings/hooks/useMcpInstallationTools.ts index c94f82432..b32a9f241 100644 --- a/apps/code/src/renderer/features/settings/hooks/useMcpInstallationTools.ts +++ b/apps/code/src/renderer/features/settings/hooks/useMcpInstallationTools.ts @@ -36,7 +36,10 @@ export function useMcpInstallationTools( includeRemoved: options.includeRemoved, }) : Promise.resolve([] as McpInstallationTool[]), - { enabled: !!installationId }, + { + enabled: !!installationId, + refetchOnMount: "always", + }, ); const invalidate = useCallback(() => { diff --git a/packages/agent/package.json b/packages/agent/package.json index 695416b27..58371da1b 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -52,6 +52,10 @@ "types": "./dist/adapters/reasoning-effort.d.ts", "import": "./dist/adapters/reasoning-effort.js" }, + "./adapters/claude/mcp/tool-metadata": { + "types": "./dist/adapters/claude/mcp/tool-metadata.d.ts", + "import": "./dist/adapters/claude/mcp/tool-metadata.js" + }, "./execution-mode": { "types": "./dist/execution-mode.d.ts", "import": "./dist/execution-mode.js" diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 32d590954..c70e8e588 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -72,6 +72,7 @@ import type { EnrichedReadCache } from "./hooks"; import { fetchMcpToolMetadata, getConnectedMcpServerNames, + setMcpToolApprovalStates, } from "./mcp/tool-metadata"; import { canUseTool } from "./permissions/permission-handlers"; import { getAvailableSlashCommands } from "./session/commands"; @@ -1091,6 +1092,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { : {}; const systemPrompt = buildSystemPrompt(meta?.systemPrompt); + if (meta?.mcpToolApprovals) { + setMcpToolApprovalStates(meta.mcpToolApprovals); + } + // Configure structured output via SDK's native outputFormat const outputFormat = meta?.jsonSchema && this.options?.onStructuredOutput diff --git a/packages/agent/src/adapters/claude/mcp/tool-metadata.test.ts b/packages/agent/src/adapters/claude/mcp/tool-metadata.test.ts new file mode 100644 index 000000000..d89652045 --- /dev/null +++ b/packages/agent/src/adapters/claude/mcp/tool-metadata.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + clearMcpToolMetadataCache, + getMcpToolApprovalState, + getMcpToolMetadata, + isMcpToolReadOnly, + sanitizeMcpServerName, + setMcpToolApprovalStates, +} from "./tool-metadata"; + +describe("tool-metadata approval states", () => { + beforeEach(() => { + clearMcpToolMetadataCache(); + }); + + describe("setMcpToolApprovalStates", () => { + it("creates entries for unknown tools", () => { + setMcpToolApprovalStates({ + mcp__server__tool1: "approved", + mcp__server__tool2: "do_not_use", + }); + + expect(getMcpToolApprovalState("mcp__server__tool1")).toBe("approved"); + expect(getMcpToolApprovalState("mcp__server__tool2")).toBe("do_not_use"); + + const meta = getMcpToolMetadata("mcp__server__tool1"); + expect(meta).toBeDefined(); + expect(meta?.readOnly).toBe(false); + }); + + it("merges with existing entries preserving readOnly", () => { + setMcpToolApprovalStates({ + mcp__server__ro_tool: "needs_approval", + }); + + const before = getMcpToolMetadata("mcp__server__ro_tool"); + expect(before?.readOnly).toBe(false); + expect(before?.approvalState).toBe("needs_approval"); + }); + + it("updates approval state on existing entries without overwriting other fields", () => { + setMcpToolApprovalStates({ + mcp__server__tool: "approved", + }); + + setMcpToolApprovalStates({ + mcp__server__tool: "do_not_use", + }); + + expect(getMcpToolApprovalState("mcp__server__tool")).toBe("do_not_use"); + }); + }); + + describe("getMcpToolApprovalState", () => { + it("returns undefined for unknown tools", () => { + expect(getMcpToolApprovalState("mcp__server__unknown")).toBeUndefined(); + }); + + it("returns the correct state", () => { + setMcpToolApprovalStates({ + mcp__s__t: "needs_approval", + }); + expect(getMcpToolApprovalState("mcp__s__t")).toBe("needs_approval"); + }); + }); + + describe("isMcpToolReadOnly with approval states", () => { + it("returns false for tools that only have approval state", () => { + setMcpToolApprovalStates({ + mcp__server__tool: "approved", + }); + expect(isMcpToolReadOnly("mcp__server__tool")).toBe(false); + }); + }); + + describe("sanitizeMcpServerName", () => { + it("passes through simple alphanumeric names", () => { + expect(sanitizeMcpServerName("HubSpot")).toBe("HubSpot"); + }); + + it("replaces spaces with underscores", () => { + expect(sanitizeMcpServerName("My Server")).toBe("My_Server"); + }); + + it("replaces special characters with underscores", () => { + expect(sanitizeMcpServerName("server@v2.0!")).toBe("server_v2_0_"); + }); + + it("preserves hyphens and underscores", () => { + expect(sanitizeMcpServerName("my-server_v2")).toBe("my-server_v2"); + }); + }); +}); diff --git a/packages/agent/src/adapters/claude/mcp/tool-metadata.ts b/packages/agent/src/adapters/claude/mcp/tool-metadata.ts index b8a5cc202..72ef0451e 100644 --- a/packages/agent/src/adapters/claude/mcp/tool-metadata.ts +++ b/packages/agent/src/adapters/claude/mcp/tool-metadata.ts @@ -1,10 +1,16 @@ import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk"; import { Logger } from "../../../utils/logger"; +export type McpToolApprovalState = "approved" | "needs_approval" | "do_not_use"; + +/** Maps MCP tool keys (e.g. `mcp__server__tool`) to their backend approval state. */ +export type McpToolApprovals = Record; + export interface McpToolMetadata { readOnly: boolean; name: string; description?: string; + approvalState?: McpToolApprovalState; } const mcpToolMetadataCache: Map = new Map(); @@ -12,6 +18,10 @@ const mcpToolMetadataCache: Map = new Map(); const PENDING_RETRY_INTERVAL_MS = 1_000; const PENDING_MAX_RETRIES = 10; +export function sanitizeMcpServerName(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + function buildToolKey(serverName: string, toolName: string): string { return `mcp__${serverName}__${toolName}`; } @@ -49,10 +59,12 @@ export async function fetchMcpToolMetadata( const toolKey = buildToolKey(server.name, tool.name); const readOnly = tool.annotations?.readOnly === true; + const existing = mcpToolMetadataCache.get(toolKey); mcpToolMetadataCache.set(toolKey, { readOnly, name: tool.name, description: tool.description, + approvalState: existing?.approvalState, }); if (readOnly) readOnlyCount++; } @@ -104,6 +116,27 @@ export function getConnectedMcpServerNames(): string[] { return [...names]; } +export function getMcpToolApprovalState( + toolName: string, +): McpToolApprovalState | undefined { + return mcpToolMetadataCache.get(toolName)?.approvalState; +} + +export function setMcpToolApprovalStates(approvals: McpToolApprovals): void { + for (const [toolKey, approvalState] of Object.entries(approvals)) { + const existing = mcpToolMetadataCache.get(toolKey); + if (existing) { + existing.approvalState = approvalState; + } else { + mcpToolMetadataCache.set(toolKey, { + readOnly: false, + name: toolKey, + approvalState, + }); + } + } +} + export function clearMcpToolMetadataCache(): void { mcpToolMetadataCache.clear(); } diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts new file mode 100644 index 000000000..6b7139367 --- /dev/null +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearMcpToolMetadataCache, + setMcpToolApprovalStates, +} from "../mcp/tool-metadata"; +import { canUseTool } from "./permission-handlers"; + +function createContext( + toolName: string, + overrides: Record = {}, +) { + return { + session: { + permissionMode: "default" as const, + settingsManager: { + getRepoRoot: vi.fn().mockReturnValue("/repo"), + }, + ...((overrides.session as Record) ?? {}), + }, + toolName, + toolInput: {}, + toolUseID: "test-tool-use-id", + suggestions: undefined, + signal: undefined, + client: { + sessionUpdate: vi.fn().mockResolvedValue(undefined), + requestPermission: vi.fn().mockResolvedValue({ + outcome: { outcome: "selected", optionId: "allow" }, + }), + }, + sessionId: "test-session", + fileContentCache: {}, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + updateConfigOption: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as Parameters[0]; +} + +describe("canUseTool MCP approval enforcement", () => { + beforeEach(() => { + clearMcpToolMetadataCache(); + }); + + it("denies do_not_use MCP tools with correct message", async () => { + setMcpToolApprovalStates({ + mcp__server__blocked_tool: "do_not_use", + }); + + const result = await canUseTool(createContext("mcp__server__blocked_tool")); + + expect(result.behavior).toBe("deny"); + if (result.behavior === "deny") { + expect(result.message).toContain("Settings > MCP Servers"); + expect(result.message).toContain("PostHog Code"); + expect(result.interrupt).toBe(false); + } + }); + + it("routes needs_approval MCP tools to permission dialog with descriptive title", async () => { + setMcpToolApprovalStates({ + mcp__HubSpot__search_crm_objects: "needs_approval", + }); + + const context = createContext("mcp__HubSpot__search_crm_objects"); + const result = await canUseTool(context); + + expect(result.behavior).toBe("allow"); + expect(context.client.requestPermission).toHaveBeenCalledWith( + expect.objectContaining({ + toolCall: expect.objectContaining({ + title: "The agent wants to call search_crm_objects (HubSpot)", + }), + }), + ); + }); + + it("allows approved MCP tools through normal flow", async () => { + setMcpToolApprovalStates({ + mcp__server__approved_tool: "approved", + }); + + const result = await canUseTool( + createContext("mcp__server__approved_tool"), + ); + + // Approved falls through to isToolAllowedForMode; MCP tools without + // readOnly annotation are not auto-allowed, so they go to the default + // permission flow which calls requestPermission + expect(result.behavior).toBe("allow"); + }); + + it("falls through for MCP tools with no approval state", async () => { + const context = createContext("mcp__server__unknown_tool"); + const result = await canUseTool(context); + + // No approval state → falls through to isToolAllowedForMode → not allowed + // in default mode → goes to default permission flow + expect(result.behavior).toBe("allow"); + expect(context.client.requestPermission).toHaveBeenCalled(); + }); + + it("blocks do_not_use even on read-only MCP tools", async () => { + setMcpToolApprovalStates({ + mcp__server__readonly_blocked: "do_not_use", + }); + + const result = await canUseTool( + createContext("mcp__server__readonly_blocked"), + ); + + expect(result.behavior).toBe("deny"); + if (result.behavior === "deny") { + expect(result.message).toContain("blocked"); + } + }); + + it("blocks do_not_use even in bypassPermissions mode", async () => { + setMcpToolApprovalStates({ + mcp__server__blocked_bypass: "do_not_use", + }); + + const result = await canUseTool( + createContext("mcp__server__blocked_bypass", { + session: { permissionMode: "bypassPermissions" }, + }), + ); + + expect(result.behavior).toBe("deny"); + if (result.behavior === "deny") { + expect(result.message).toContain("blocked"); + } + }); + + it("does not affect non-MCP tools", async () => { + const result = await canUseTool(createContext("Read")); + + // Read is in the auto-allowed set for default mode + expect(result.behavior).toBe("allow"); + }); + + it("emits tool denial notification for do_not_use", async () => { + setMcpToolApprovalStates({ + mcp__server__denied_tool: "do_not_use", + }); + + const context = createContext("mcp__server__denied_tool"); + await canUseTool(context); + + expect(context.client.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "test-session", + update: expect.objectContaining({ + sessionUpdate: "tool_call_update", + toolCallId: "test-tool-use-id", + status: "failed", + }), + }), + ); + }); +}); diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts index f6cb79302..8e11f6341 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts @@ -9,6 +9,10 @@ import type { import { text } from "../../../utils/acp-content"; import type { Logger } from "../../../utils/logger"; import { toolInfoFromToolUse } from "../conversion/tool-use-to-acp"; +import { + getMcpToolApprovalState, + getMcpToolMetadata, +} from "../mcp/tool-metadata"; import { getClaudePlansDir, getLatestAssistantText, @@ -408,6 +412,92 @@ async function handleDefaultPermissionFlow( } } +function parseMcpToolName(toolName: string): { + serverName: string; + tool: string; +} { + const parts = toolName.split("__"); + return { + serverName: parts[1] ?? toolName, + tool: parts.slice(2).join("__") || toolName, + }; +} + +async function handleMcpApprovalFlow( + context: ToolHandlerContext, +): Promise { + const { toolName, toolInput, toolUseID, client, sessionId } = context; + + const { serverName, tool: displayTool } = parseMcpToolName(toolName); + const metadata = getMcpToolMetadata(toolName); + const description = metadata?.description + ? `\n\n${metadata.description}` + : ""; + + const response = await client.requestPermission({ + options: [ + { kind: "allow_once", name: "Yes", optionId: "allow" }, + { + kind: "allow_always", + name: "Yes, always allow", + optionId: "allow_always", + }, + { + kind: "reject_once", + name: "Type here to tell the agent what to do differently", + optionId: "reject", + _meta: { customInput: true }, + }, + ], + sessionId, + toolCall: { + toolCallId: toolUseID, + title: `The agent wants to call ${displayTool} (${serverName})`, + kind: "other", + content: description + ? [{ type: "content" as const, content: text(description) }] + : [], + rawInput: { ...(toolInput as Record), toolName }, + }, + }); + + if (context.signal?.aborted || response.outcome?.outcome === "cancelled") { + throw new Error("Tool use aborted"); + } + + if ( + response.outcome?.outcome === "selected" && + (response.outcome.optionId === "allow" || + response.outcome.optionId === "allow_always") + ) { + if (response.outcome.optionId === "allow_always") { + return { + behavior: "allow", + updatedInput: toolInput as Record, + updatedPermissions: [ + { + type: "addRules", + rules: [{ toolName }], + behavior: "allow", + destination: "localSettings", + }, + ], + }; + } + return { + behavior: "allow", + updatedInput: toolInput as Record, + }; + } + + const feedback = (response._meta?.customInput as string | undefined)?.trim(); + const message = feedback + ? `User refused permission to run tool with feedback: ${feedback}` + : "User refused permission to run tool"; + await emitToolDenial(context, message); + return { behavior: "deny", message, interrupt: !feedback }; +} + function handlePlanFileException( context: ToolHandlerContext, ): ToolPermissionResult | null { @@ -510,6 +600,21 @@ export async function canUseTool( } } + if (toolName.startsWith("mcp__")) { + const approvalState = getMcpToolApprovalState(toolName); + + if (approvalState === "do_not_use") { + const message = + "This tool has been blocked. To re-enable it, go to Settings > MCP Servers in PostHog Code."; + await emitToolDenial(context, message); + return { behavior: "deny", message, interrupt: false }; + } + + if (approvalState === "needs_approval") { + return handleMcpApprovalFlow(context); + } + } + if (isToolAllowedForMode(toolName, session.permissionMode)) { return { behavior: "allow", diff --git a/packages/agent/src/adapters/claude/session/instructions.ts b/packages/agent/src/adapters/claude/session/instructions.ts index dbe66f0c4..4ba617306 100644 --- a/packages/agent/src/adapters/claude/session/instructions.ts +++ b/packages/agent/src/adapters/claude/session/instructions.ts @@ -16,4 +16,12 @@ Only enter plan mode (EnterPlanMode) when the user is requesting a significant c When in doubt, continue executing and incorporate the feedback inline. `; -export const APPENDED_INSTRUCTIONS = BRANCH_NAMING + PLAN_MODE; +const MCP_TOOLS = ` +# MCP Tool Access + +If an MCP tool call is explicitly denied with a message, relay that denial message to the user exactly as given. Do NOT suggest checking "Claude Code settings." + +If an MCP tool call returns an error, treat it as a normal tool error — troubleshoot, retry, or inform the user about the specific error. Do NOT assume it is a permissions issue and do NOT direct the user to any settings page. +`; + +export const APPENDED_INSTRUCTIONS = BRANCH_NAMING + PLAN_MODE + MCP_TOOLS; diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index 354b43353..91c106ff5 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -10,6 +10,7 @@ import type { } from "@anthropic-ai/claude-agent-sdk"; import type { Pushable } from "../../utils/streams"; import type { BaseSession } from "../base-acp-agent"; +import type { McpToolApprovals } from "./mcp/tool-metadata"; import type { SettingsManager } from "./session/settings"; import type { CodeExecutionMode } from "./tools"; @@ -117,6 +118,7 @@ export type NewSessionMeta = { /** Model ID to use for this session (e.g. "claude-sonnet-4-6") */ model?: string; jsonSchema?: Record | null; + mcpToolApprovals?: McpToolApprovals; claudeCode?: { options?: Options; emitRawSDKMessages?: boolean | SDKMessageFilter[]; diff --git a/packages/agent/tsup.config.ts b/packages/agent/tsup.config.ts index cba83319e..a3cd3ff75 100644 --- a/packages/agent/tsup.config.ts +++ b/packages/agent/tsup.config.ts @@ -86,6 +86,7 @@ export default defineConfig([ "src/adapters/claude/session/jsonl-hydration.ts", "src/adapters/claude/session/models.ts", "src/adapters/codex/models.ts", + "src/adapters/claude/mcp/tool-metadata.ts", "src/adapters/reasoning-effort.ts", "src/execution-mode.ts", "src/server/schemas.ts",