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 @@ -69,6 +69,12 @@ vi.mock("./mcp/tool-metadata", () => ({
getMcpToolMetadata: vi.fn().mockReturnValue(undefined),
}));

const createLocalToolsMcpServer = vi.hoisted(() => vi.fn(() => undefined));

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 +141,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 +296,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
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 @@ -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,
Comment thread
veria-ai[bot] marked this conversation as resolved.
spokenNarration,
background: meta?.mode === "background",
},
Expand Down
23 changes: 23 additions & 0 deletions packages/agent/src/adapters/claude/mcp/local-tools.test.ts
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();
});
});
18 changes: 12 additions & 6 deletions packages/agent/src/adapters/claude/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,18 @@ 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",
Expand Down
12 changes: 11 additions & 1 deletion 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",
"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,54 @@ 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();
});

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