Skip to content

Commit bf2ef9c

Browse files
authored
feat: autoapprove readonly mcp tools (#881)
1 parent 41338ec commit bf2ef9c

5 files changed

Lines changed: 137 additions & 4 deletions

File tree

apps/twig/src/main/services/agent/service.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type RequestPermissionResponse,
1212
type SessionConfigOption,
1313
} from "@agentclientprotocol/sdk";
14+
import { isMcpToolReadOnly } from "@posthog/agent";
1415
import { Agent } from "@posthog/agent/agent";
1516
import {
1617
fetchGatewayModels,
@@ -1055,6 +1056,22 @@ For git operations while detached:
10551056
optionCount: params.options.length,
10561057
});
10571058

1059+
if (toolName && isMcpToolReadOnly(toolName)) {
1060+
log.info("Auto-approving read-only MCP tool", {
1061+
taskRunId,
1062+
toolName,
1063+
});
1064+
const allowOption = params.options.find(
1065+
(o) => o.kind === "allow_once" || o.kind === "allow_always",
1066+
);
1067+
return {
1068+
outcome: {
1069+
outcome: "selected",
1070+
optionId: allowOption?.optionId ?? params.options[0].optionId,
1071+
},
1072+
};
1073+
}
1074+
10581075
// If we have a toolCallId, always prompt the user for permission.
10591076
// The claude.ts adapter only calls requestPermission when user input is needed.
10601077
// (It handles auto-approve internally for acceptEdits/bypassPermissions modes)

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
handleSystemMessage,
4545
handleUserAssistantMessage,
4646
} from "./conversion/sdk-to-acp.js";
47+
import { fetchMcpToolMetadata } from "./mcp/tool-metadata.js";
4748
import { canUseTool } from "./permissions/permission-handlers.js";
4849
import { getAvailableSlashCommands } from "./session/commands.js";
4950
import { parseMcpServers } from "./session/mcp-config.js";
@@ -140,6 +141,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
140141
const permissionMode: TwigExecutionMode = "default";
141142

142143
const mcpServers = parseMcpServers(params);
144+
await fetchMcpToolMetadata(mcpServers, this.logger);
143145

144146
const options = buildSessionOptions({
145147
cwd: params.cwd,
@@ -199,6 +201,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
199201

200202
const meta = params._meta as NewSessionMeta | undefined;
201203
const mcpServers = parseMcpServers(params);
204+
await fetchMcpToolMetadata(mcpServers, this.logger);
202205

203206
const { query: q, session } = await this.initializeQuery({
204207
internalSessionId,
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
2+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
5+
import { Logger } from "../../../utils/logger.js";
6+
7+
export interface McpToolMetadata {
8+
readOnly: boolean;
9+
}
10+
11+
const mcpToolMetadataCache: Map<string, McpToolMetadata> = new Map();
12+
13+
function buildToolKey(serverName: string, toolName: string): string {
14+
return `mcp__${serverName}__${toolName}`;
15+
}
16+
17+
function isHttpMcpServer(
18+
config: McpServerConfig,
19+
): config is McpServerConfig & { type: "http"; url: string } {
20+
return config.type === "http" && typeof (config as any).url === "string";
21+
}
22+
23+
async function fetchToolsFromHttpServer(
24+
_serverName: string,
25+
config: McpServerConfig & { type: "http"; url: string },
26+
): Promise<Tool[]> {
27+
const transport = new StreamableHTTPClientTransport(new URL(config.url), {
28+
requestInit: {
29+
headers: (config as any).headers || {},
30+
},
31+
});
32+
33+
const client = new Client({
34+
name: "twig-metadata-fetcher",
35+
version: "1.0.0",
36+
});
37+
38+
try {
39+
await client.connect(transport);
40+
const result = await client.listTools();
41+
return result.tools;
42+
} finally {
43+
await client.close().catch(() => {});
44+
}
45+
}
46+
47+
function extractToolMetadata(tool: Tool): McpToolMetadata {
48+
return {
49+
readOnly: tool.annotations?.readOnlyHint === true,
50+
};
51+
}
52+
53+
export async function fetchMcpToolMetadata(
54+
mcpServers: Record<string, McpServerConfig>,
55+
logger: Logger = new Logger({ debug: false, prefix: "[McpToolMetadata]" }),
56+
): Promise<void> {
57+
const fetchPromises: Promise<void>[] = [];
58+
59+
for (const [serverName, config] of Object.entries(mcpServers)) {
60+
if (!isHttpMcpServer(config)) {
61+
continue;
62+
}
63+
64+
const fetchPromise = fetchToolsFromHttpServer(serverName, config)
65+
.then((tools) => {
66+
const toolCount = tools.length;
67+
const readOnlyCount = tools.filter(
68+
(t) => t.annotations?.readOnlyHint === true,
69+
).length;
70+
71+
for (const tool of tools) {
72+
const toolKey = buildToolKey(serverName, tool.name);
73+
mcpToolMetadataCache.set(toolKey, extractToolMetadata(tool));
74+
}
75+
76+
logger.info("Fetched MCP tool metadata", {
77+
serverName,
78+
toolCount,
79+
readOnlyCount,
80+
});
81+
})
82+
.catch((error) => {
83+
logger.error("Failed to fetch MCP tool metadata", {
84+
serverName,
85+
error: error instanceof Error ? error.message : String(error),
86+
});
87+
});
88+
89+
fetchPromises.push(fetchPromise);
90+
}
91+
92+
await Promise.all(fetchPromises);
93+
}
94+
95+
export function isMcpToolReadOnly(toolName: string): boolean {
96+
const metadata = mcpToolMetadataCache.get(toolName);
97+
return metadata?.readOnly === true;
98+
}
99+
100+
export function clearMcpToolMetadataCache(): void {
101+
mcpToolMetadataCache.clear();
102+
}

packages/agent/src/adapters/claude/tools.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export {
66
} from "../../execution-mode.js";
77

88
import type { TwigExecutionMode } from "../../execution-mode.js";
9+
import { isMcpToolReadOnly } from "./mcp/tool-metadata.js";
910

1011
export const READ_TOOLS: Set<string> = new Set(["Read", "NotebookRead"]);
1112

@@ -44,8 +45,14 @@ export function isToolAllowedForMode(
4445
toolName: string,
4546
mode: TwigExecutionMode,
4647
): boolean {
47-
return (
48-
mode === "bypassPermissions" ||
49-
AUTO_ALLOWED_TOOLS[mode]?.has(toolName) === true
50-
);
48+
if (mode === "bypassPermissions") {
49+
return true;
50+
}
51+
if (AUTO_ALLOWED_TOOLS[mode]?.has(toolName) === true) {
52+
return true;
53+
}
54+
if (isMcpToolReadOnly(toolName)) {
55+
return true;
56+
}
57+
return false;
5158
}

packages/agent/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export type {
2323
InProcessAcpConnection,
2424
} from "./adapters/acp-connection.js";
2525
export { createAcpConnection } from "./adapters/acp-connection.js";
26+
export {
27+
fetchMcpToolMetadata,
28+
isMcpToolReadOnly,
29+
} from "./adapters/claude/mcp/tool-metadata.js";
2630
export type { CodexProcessOptions } from "./adapters/codex/spawn.js";
2731
export { Agent } from "./agent.js";
2832
export {

0 commit comments

Comments
 (0)