Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ vi.mock("./mcp/tool-metadata", () => ({
getMcpToolMetadata: vi.fn().mockReturnValue(undefined),
}));

// Returns a truthy stub so the rest of the suite doesn't trip the "cloud run
// registered no local tools" warning.
const createLocalToolsMcpServer = vi.hoisted(() =>
vi.fn(() => ({ instance: {} })),
);

vi.mock("./mcp/local-tools", () => ({
createLocalToolsMcpServer,
}));

// Import after the mocks so ClaudeAcpAgent resolves the mocked SDK
const { ClaudeAcpAgent } = await import("./claude-agent");
type Agent = InstanceType<typeof ClaudeAcpAgent>;
Expand Down Expand Up @@ -135,6 +145,7 @@ describe("ClaudeAcpAgent session creation", () => {
commands: [],
models: [],
});
createLocalToolsMcpServer.mockClear();
// No gateway: fetchGatewayModels returns [] and the requested model is
// kept as a custom option — mirrors the gateway-outage failure mode.
delete process.env.ANTHROPIC_BASE_URL;
Expand Down Expand Up @@ -289,6 +300,28 @@ describe("ClaudeAcpAgent session creation", () => {
},
);

it("passes repository-less mode to the local-tools server", async () => {
const agent = makeAgent();

await agent.newSession({
cwd,
mcpServers: [],
_meta: {
environment: "cloud",
channelMode: true,
taskRunId: "run-channel",
},
});

expect(createLocalToolsMcpServer).toHaveBeenCalledWith(
expect.objectContaining({ cwd }),
expect.objectContaining({
environment: "cloud",
channelMode: true,
}),
);
});

// The SDK does not carry the model across resume — without an explicit
// setModel the resumed session silently runs the SDK default (opus).
it.each([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1979,6 +1979,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
// needs so the session doesn't pin the whole meta object.
const baseBranch = meta?.baseBranch;
const environment = meta?.environment;
const channelMode = meta?.channelMode;
const spokenNarration = resolveSpokenNarration(meta);
const requestFinish = this.buildRequestFinish(taskId, meta?.taskRunId);
const buildInProcessMcpServers = (): Record<
Expand All @@ -1996,6 +1997,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
},
{
environment,
channelMode,
spokenNarration,
background: meta?.mode === "background",
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,27 @@ describe("createLocalToolsMcpServer", () => {

await client.close();
});

it("exposes lazy repository tools in a repository-less cloud run", async () => {
const server = createLocalToolsMcpServer(
{ cwd: "/tmp/workspace", token: "ghs_x" },
{ environment: "cloud", channelMode: true },
);
if (!server) {
throw new Error("expected the local-tools server to be registered");
}

const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.instance.connect(serverTransport);
const client = new Client({ name: "test", version: "1.0.0" });
await client.connect(clientTransport);

const { tools } = await client.listTools();
const names = tools.map((tool) => tool.name);
expect(names).toContain("list_repos");
expect(names).toContain("clone_repo");

await client.close();
});
});
33 changes: 21 additions & 12 deletions products/desktop/packages/agent/src/adapters/claude/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,26 @@ describe("toSdkPermissionMode", () => {
});

describe("isToolAllowedForMode stays authoritative for auto", () => {
it.each(["Bash", "Edit", "Write", "NotebookEdit", "BashOutput", "KillShell"])(
"auto-allows %s in auto mode",
(tool) => {
expect(isToolAllowedForMode(tool, "auto")).toBe(true);
},
);
it.each([
"Bash",
"Edit",
"Write",
"NotebookEdit",
"BashOutput",
"KillShell",
"mcp__posthog-code-tools__list_repos",
"mcp__posthog-code-tools__clone_repo",
])("auto-allows %s in auto mode", (tool) => {
expect(isToolAllowedForMode(tool, "auto")).toBe(true);
});

it.each(["Bash", "Edit", "Write"])(
"still gates %s in default mode",
(tool) => {
expect(isToolAllowedForMode(tool, "default")).toBe(false);
},
);
it.each([
"Bash",
"Edit",
"Write",
"mcp__posthog-code-tools__list_repos",
"mcp__posthog-code-tools__clone_repo",
])("still gates %s in default mode", (tool) => {
expect(isToolAllowedForMode(tool, "default")).toBe(false);
});
});
12 changes: 11 additions & 1 deletion products/desktop/packages/agent/src/adapters/claude/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,24 @@ const BASE_ALLOWED_TOOLS = [
...AGENT_TOOLS,
];

const AUTO_ALLOWED_LOCAL_TOOLS = [
"mcp__posthog-code-tools__list_repos",
Comment thread
charlesvien marked this conversation as resolved.
"mcp__posthog-code-tools__clone_repo",
];

const AUTO_ALLOWED_TOOLS: Record<string, Set<string>> = {
// Auto mode is hands-off: it auto-approves file edits and shell commands on
// top of the base read/search/web/agent tools. Without WRITE_TOOLS and
// BASH_TOOLS here, Edit/Write/Bash fall through to a manual prompt on every
// call, which contradicts what the mode advertises. MCP tools are still gated
// separately (do_not_use is denied, needs_approval still prompts) in
// canUseTool, so auto stays narrower than bypassPermissions.
auto: new Set([...BASE_ALLOWED_TOOLS, ...WRITE_TOOLS, ...BASH_TOOLS]),
auto: new Set([
...BASE_ALLOWED_TOOLS,
...WRITE_TOOLS,
...BASH_TOOLS,
...AUTO_ALLOWED_LOCAL_TOOLS,
]),
default: new Set(BASE_ALLOWED_TOOLS),
acceptEdits: new Set([...BASE_ALLOWED_TOOLS, ...WRITE_TOOLS]),
plan: new Set(BASE_ALLOWED_TOOLS),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,106 @@ describe("CodexAppServerAgent", () => {
expect(requestPermission).not.toHaveBeenCalled();
});

it("auto-accepts repository tools from the built-in local MCP server in auto mode", async () => {
const stub = makeStubRpc({
initialize: {},
"thread/start": { thread: { id: "thr_1" } },
});
const requestPermission = vi.fn();
const client = {
sessionUpdate: async () => {},
requestPermission,
extNotification: async () => {},
} as unknown as AgentSideConnection;
const agent = new CodexAppServerAgent(client, {
processOptions: { binaryPath: "/bundle/codex" },
model: "gpt-5.5",
rpcFactory: stub.factory,
});
await agent.initialize(init);
await agent.newSession({
cwd: "/repo",
_meta: {
environment: "cloud",
channelMode: true,
permissionMode: "auto",
},
} as unknown as NewSessionRequest);

stub.emit("item/started", {
item: {
type: "mcpToolCall",
id: "m1",
server: "posthog-code-tools",
tool: "clone_repo",
arguments: { repo: "PostHog/posthog" },
},
});
const decision = await stub.invokeRequest("mcpServer/elicitation/request", {
threadId: "thr_1",
turnId: "turn_1",
serverName: "posthog-code-tools",
mode: "form",
message:
'Allow the posthog-code-tools MCP server to run tool "clone_repo"?',
});

expect(decision).toMatchObject({ action: "accept" });
expect(requestPermission).not.toHaveBeenCalled();
});

// The inverse of the auto-accept above: outside the hands-off modes a
// repository tool must still go through the permission prompt.
it("prompts for repository tools outside hands-off modes", async () => {
const stub = makeStubRpc({
initialize: {},
"thread/start": { thread: { id: "thr_1" } },
});
const requestPermission = vi.fn(async () => ({
outcome: { outcome: "selected", optionId: "decline" },
}));
const client = {
sessionUpdate: async () => {},
requestPermission,
extNotification: async () => {},
} as unknown as AgentSideConnection;
const agent = new CodexAppServerAgent(client, {
processOptions: { binaryPath: "/bundle/codex" },
model: "gpt-5.5",
rpcFactory: stub.factory,
});
await agent.initialize(init);
await agent.newSession({
cwd: "/repo",
_meta: {
environment: "cloud",
channelMode: true,
permissionMode: "read-only",
},
} as unknown as NewSessionRequest);

stub.emit("item/started", {
item: {
type: "mcpToolCall",
id: "m1",
server: "posthog-code-tools",
tool: "clone_repo",
arguments: { repo: "PostHog/posthog" },
},
});
const decision = await stub.invokeRequest("mcpServer/elicitation/request", {
threadId: "thr_1",
turnId: "turn_1",
serverName: "posthog-code-tools",
mode: "form",
message:
'Allow the posthog-code-tools MCP server to run tool "clone_repo"?',
});

expect(requestPermission).toHaveBeenCalledOnce();
expect(decision).toMatchObject({ action: "decline" });
});

it("auto-accepts a gated PostHog exec sub-tool in local hands-off modes", async () => {
const stub = makeStubRpc({
initialize: {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
estimateTokens,
} from "../claude/context-breakdown";
import { isLocalSkillCommandChunk } from "../local-skill";
import { LOCAL_TOOLS_MCP_NAME } from "../local-tools";
import { resolveSpokenNarration } from "../session-meta";
import {
AppServerClient,
Expand Down Expand Up @@ -1939,6 +1940,22 @@ export class CodexAppServerAgent extends BaseAcpAgent {
);
}

private shouldAutoAcceptMcpToolCall(mcp: {
server: string;
tool: string;
args: unknown;
}): boolean {
const isHandsOffMode =
this.config.mode === "auto" || this.config.mode === "full-access";
const isRepositoryTool =
mcp.server === LOCAL_TOOLS_MCP_NAME &&
(mcp.tool === "list_repos" || mcp.tool === "clone_repo");
return (
(isHandsOffMode && isRepositoryTool) ||
this.shouldAutoAcceptPostHogExec(mcp)
);
}

/**
* Server-initiated requests. Simple approvals resolve to a `{ decision }` envelope (a bare
* string is rejected); richer ones (AskUserQuestion / permission profile / elicitation) go
Expand All @@ -1953,7 +1970,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
logger: this.logger,
resolveMcpToolCall: (serverName) => this.mcp.byServer(serverName),
shouldAutoAcceptMcpToolCall: (mcp) =>
this.shouldAutoAcceptPostHogExec(mcp),
this.shouldAutoAcceptMcpToolCall(mcp),
});
if (richer.handled) {
return richer.response;
Expand Down Expand Up @@ -2001,7 +2018,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
// Codex has no MCP-specific approval; a known MCP call surfaces the real server/tool/args
// so the host renders the proper MCP permission (incl. PostHog `exec` unwrapping).
const mcp = this.mcp.byItemId(detail.itemId);
if (mcp && this.shouldAutoAcceptPostHogExec(mcp)) {
if (mcp && this.shouldAutoAcceptMcpToolCall(mcp)) {
return { decision: "accept" };
}
// kind + content route plain command/file approvals to Execute/EditPermission (not the fallback).
Expand Down
Loading
Loading