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
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,14 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({

const fetchMcpToolMetadataMock = vi.fn().mockResolvedValue(undefined);
const clearMcpToolMetadataCacheMock = vi.fn();
const clearMcpToolApprovalCacheMock = vi.fn();
vi.mock("./mcp/tool-metadata", () => ({
fetchMcpToolMetadata: fetchMcpToolMetadataMock,
getConnectedMcpServerNames: vi.fn().mockReturnValue([]),
getCachedMcpTools: vi.fn().mockReturnValue([]),
clearMcpToolMetadataCache: clearMcpToolMetadataCacheMock,
clearMcpToolApprovalCache: clearMcpToolApprovalCacheMock,
setMcpToolApprovalStates: vi.fn(),
}));

// Import after the mocks so ClaudeAcpAgent resolves the mocked SDK
Expand Down Expand Up @@ -169,6 +172,7 @@ describe("ClaudeAcpAgent.extMethod refresh_session", () => {
});
fetchMcpToolMetadataMock.mockClear();
clearMcpToolMetadataCacheMock.mockClear();
clearMcpToolApprovalCacheMock.mockClear();
});

it("returns methodNotFound for unknown extension methods", async () => {
Expand Down Expand Up @@ -428,6 +432,7 @@ describe("ClaudeAcpAgent.extMethod refresh_session", () => {
});

expect(clearMcpToolMetadataCacheMock).toHaveBeenCalledTimes(1);
expect(clearMcpToolApprovalCacheMock).not.toHaveBeenCalled();
});
});

Expand All @@ -437,6 +442,7 @@ describe("ClaudeAcpAgent self-heal: ensureLocalToolsConnected", () => {
beforeEach(() => {
clearMcpToolMetadataCacheMock.mockClear();
fetchMcpToolMetadataMock.mockClear();
clearMcpToolApprovalCacheMock.mockClear();
});

function callHeal(agent: Agent, trigger = "test"): Promise<boolean> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ function makeQueryHandle(): SdkQueryHandle {
}

const createdQueries: SdkQueryHandle[] = [];
const clearMcpToolApprovalCacheMock = vi.fn();
const setMcpToolApprovalStatesMock = vi.fn();

vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: vi.fn(() => {
Expand All @@ -58,7 +60,10 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
vi.mock("./mcp/tool-metadata", () => ({
fetchMcpToolMetadata: vi.fn().mockResolvedValue(undefined),
getConnectedMcpServerNames: vi.fn().mockReturnValue([]),
setMcpToolApprovalStates: vi.fn(),
getCachedMcpTools: vi.fn().mockReturnValue([]),
clearMcpToolMetadataCache: vi.fn(),
clearMcpToolApprovalCache: clearMcpToolApprovalCacheMock,
setMcpToolApprovalStates: setMcpToolApprovalStatesMock,
getMcpToolApprovalState: vi.fn().mockReturnValue("approved"),
getMcpToolMetadata: vi.fn().mockReturnValue(undefined),
}));
Expand Down Expand Up @@ -117,6 +122,8 @@ describe("ClaudeAcpAgent session model on resume", () => {
// kept as a custom option — mirrors the gateway-outage failure mode.
delete process.env.ANTHROPIC_BASE_URL;
process.env.CLAUDE_CONFIG_DIR = configDir;
clearMcpToolApprovalCacheMock.mockClear();
setMcpToolApprovalStatesMock.mockClear();
});

// The SDK does not carry the model across resume — without an explicit
Expand Down Expand Up @@ -195,6 +202,23 @@ describe("ClaudeAcpAgent session model on resume", () => {
}
});

it("clears stale MCP tool approval cache before applying the session snapshot", async () => {
const agent = makeAgent();
const approvals = { mcp__Linear__search: "needs_approval" as const };

await agent.newSession({
cwd,
mcpServers: [],
_meta: { taskRunId: "run-approvals", mcpToolApprovals: approvals },
});

expect(clearMcpToolApprovalCacheMock).toHaveBeenCalledTimes(1);
expect(setMcpToolApprovalStatesMock).toHaveBeenCalledWith(approvals);
expect(
clearMcpToolApprovalCacheMock.mock.invocationCallOrder[0],
).toBeLessThan(setMcpToolApprovalStatesMock.mock.invocationCallOrder[0]);
});

// The timeout *message* (RequestError "... timed out after ...") is covered
// by claude-agent.refresh.test.ts. Here we cover the leak fix on the
// new-session and resume paths: any init failure must close the query so the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
vi.mock("./mcp/tool-metadata", () => ({
fetchMcpToolMetadata: vi.fn().mockResolvedValue(undefined),
getConnectedMcpServerNames: vi.fn().mockReturnValue([]),
getCachedMcpTools: vi.fn().mockReturnValue([]),
clearMcpToolMetadataCache: vi.fn(),
clearMcpToolApprovalCache: vi.fn(),
setMcpToolApprovalStates: vi.fn(),
isMcpToolReadOnly: vi.fn().mockReturnValue(false),
getMcpToolMetadata: vi.fn().mockReturnValue(undefined),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ vi.mock("./mcp/tool-metadata", () => ({
getConnectedMcpServerNames: vi.fn().mockReturnValue([]),
getCachedMcpTools: vi.fn().mockReturnValue([]),
clearMcpToolMetadataCache: vi.fn(),
clearMcpToolApprovalCache: vi.fn(),
setMcpToolApprovalStates: vi.fn(),
isMcpToolReadOnly: vi.fn().mockReturnValue(false),
getMcpToolMetadata: vi.fn().mockReturnValue(undefined),
Expand Down
2 changes: 2 additions & 0 deletions packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import {
import type { EnrichedReadCache } from "./hooks";
import { createLocalToolsMcpServer } from "./mcp/local-tools";
import {
clearMcpToolApprovalCache,
clearMcpToolMetadataCache,
fetchMcpToolMetadata,
getCachedMcpTools,
Expand Down Expand Up @@ -1687,6 +1688,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {

const systemPrompt = buildSystemPrompt(meta?.systemPrompt);

clearMcpToolApprovalCache();
if (meta?.mcpToolApprovals) {
setMcpToolApprovalStates(meta.mcpToolApprovals);
}
Expand Down
80 changes: 80 additions & 0 deletions packages/agent/src/adapters/claude/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import {
createTaskHook,
type EnrichedReadCache,
} from "./hooks";
import {
clearMcpToolApprovalCache,
clearMcpToolMetadataCache,
setMcpToolApprovalStates,
} from "./mcp/tool-metadata";
import type {
PermissionCheckResult,
SettingsManager,
Expand Down Expand Up @@ -313,6 +318,81 @@ describe("createPreToolUseHook", () => {
hookSpecificOutput: { permissionDecision: "ask" },
});
});

test("routes needs_approval MCP Store tool to canUseTool via ask", async () => {
clearMcpToolMetadataCache();
clearMcpToolApprovalCache();
setMcpToolApprovalStates({
mcp__granola__query_granola_meetings: "needs_approval",
});
const settingsManager = buildSettingsManagerStub({ decision: "ask" });
const hook = createPreToolUseHook(settingsManager, logger);
const result = await hook(
buildPreToolUseHookInput("mcp__granola__query_granola_meetings", {}),
undefined,
{ signal: new AbortController().signal },
);

expect(result).toMatchObject({
continue: true,
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
},
});
clearMcpToolMetadataCache();
clearMcpToolApprovalCache();
});

test("denies do_not_use MCP Store tool", async () => {
clearMcpToolMetadataCache();
clearMcpToolApprovalCache();
setMcpToolApprovalStates({
mcp__granola__query_granola_meetings: "do_not_use",
});
const settingsManager = buildSettingsManagerStub({ decision: "ask" });
const hook = createPreToolUseHook(settingsManager, logger);
const result = await hook(
buildPreToolUseHookInput("mcp__granola__query_granola_meetings", {}),
undefined,
{ signal: new AbortController().signal },
);

expect(result).toMatchObject({
continue: true,
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
},
});
clearMcpToolMetadataCache();
clearMcpToolApprovalCache();
});

test("respects an explicit allow rule for a needs_approval MCP Store tool", async () => {
clearMcpToolMetadataCache();
clearMcpToolApprovalCache();
setMcpToolApprovalStates({
mcp__granola__query_granola_meetings: "needs_approval",
});
const settingsManager = buildSettingsManagerStub({
decision: "allow",
rule: "mcp__granola__query_granola_meetings",
source: "allow",
});
const hook = createPreToolUseHook(settingsManager, logger);
const result = await hook(
buildPreToolUseHookInput("mcp__granola__query_granola_meetings", {}),
undefined,
{ signal: new AbortController().signal },
);

expect(result).toMatchObject({
hookSpecificOutput: { permissionDecision: "allow" },
});
clearMcpToolMetadataCache();
clearMcpToolApprovalCache();
});
});

describe("createSignedCommitGuardHook", () => {
Expand Down
40 changes: 40 additions & 0 deletions packages/agent/src/adapters/claude/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Logger } from "../../utils/logger";
import { SIGNED_COMMIT_QUALIFIED_TOOL_NAME } from "../signed-commit-shared";
import { stripCatLineNumbers } from "./conversion/sdk-to-acp";
import type { TaskState } from "./conversion/task-state";
import { getMcpToolApprovalState } from "./mcp/tool-metadata";
import {
extractPostHogSubTool,
isPostHogDestructiveSubTool,
Expand Down Expand Up @@ -410,6 +411,45 @@ export const createPreToolUseHook =
);
}

// MCP Store per-tool approval. Cloud sessions run in bypassPermissions,
// which skips canUseTool entirely — so this PreToolUse hook is the only
// gate that runs for these tools. Force a decision here: deny blocked
// tools outright, and route needs_approval tools back through canUseTool
// (via "ask") so handleMcpApprovalFlow relays the approval dialog to the
// desktop and persists the result to the backend. An explicit allow rule
// (e.g. a prior "always allow") takes precedence so the prompt sticks.
const mcpApprovalState = getMcpToolApprovalState(toolName);
if (mcpApprovalState === "do_not_use") {
logger.info(`[PreToolUseHook] Blocking do_not_use MCP tool: ${toolName}`);
return {
continue: true,
hookSpecificOutput: {
hookEventName: "PreToolUse" as const,
permissionDecision: "deny" as const,
permissionDecisionReason:
"This tool has been blocked. To re-enable it, go to Settings > MCP Servers in PostHog Code.",
},
};
}
if (
mcpApprovalState === "needs_approval" &&
permissionCheck.decision !== "allow" &&
permissionCheck.decision !== "deny"
) {
logger.info(
`[PreToolUseHook] Routing needs_approval MCP tool to canUseTool: ${toolName}`,
);
return {
continue: true,
hookSpecificOutput: {
hookEventName: "PreToolUse" as const,
permissionDecision: "ask" as const,
permissionDecisionReason:
"This MCP tool requires approval before it can be used.",
},
};
}

// Defer destructive PostHog exec sub-tools to canUseTool so the
// sub-tool gate can re-prompt. Returning `{ continue: true }` is
// not enough — the SDK then falls back to its default permission
Expand Down
41 changes: 41 additions & 0 deletions packages/agent/src/adapters/claude/mcp/tool-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
clearMcpToolApprovalCache,
clearMcpToolMetadataCache,
getMcpToolApprovalState,
getMcpToolMetadata,
getMcpToolMetadataKey,
isMcpToolReadOnly,
sanitizeMcpServerName,
setMcpToolApprovalStates,
Expand All @@ -11,6 +13,7 @@ import {
describe("tool-metadata approval states", () => {
beforeEach(() => {
clearMcpToolMetadataCache();
clearMcpToolApprovalCache();
});

describe("setMcpToolApprovalStates", () => {
Expand Down Expand Up @@ -56,12 +59,50 @@ describe("tool-metadata approval states", () => {
expect(getMcpToolApprovalState("mcp__server__unknown")).toBeUndefined();
});

it("normalizes MCP server names before looking up approval state", () => {
setMcpToolApprovalStates({
mcp__Granola_Meetings__query_granola_meetings: "needs_approval",
});

expect(
getMcpToolApprovalState(
"mcp__Granola Meetings__query_granola_meetings",
),
).toBe("needs_approval");
});

it("resolves a bare upstream tool name when it uniquely maps to an MCP Store tool", () => {
setMcpToolApprovalStates({
mcp__Granola_Meetings__query_granola_meetings: "needs_approval",
});

expect(getMcpToolApprovalState("query_granola_meetings")).toBe(
"needs_approval",
);
expect(getMcpToolMetadataKey("query_granola_meetings")).toBe(
"mcp__Granola_Meetings__query_granola_meetings",
);
});

it("returns the correct state", () => {
setMcpToolApprovalStates({
mcp__s__t: "needs_approval",
});
expect(getMcpToolApprovalState("mcp__s__t")).toBe("needs_approval");
});

it("survives a metadata cache clear (MCP server refresh/reconnect)", () => {
setMcpToolApprovalStates({
mcp__granola__query_granola_meetings: "needs_approval",
});

// A server reconnect wipes the metadata cache mid-session.
clearMcpToolMetadataCache();

expect(
getMcpToolApprovalState("mcp__granola__query_granola_meetings"),
).toBe("needs_approval");
});
});

describe("isMcpToolReadOnly with approval states", () => {
Expand Down
Loading
Loading