Skip to content

Commit 0bc963e

Browse files
authored
feat(mcp-store): agent request permission for tool calling (#1784)
1 parent 5acaa7f commit 0bc963e

15 files changed

Lines changed: 701 additions & 31 deletions

File tree

apps/code/src/main/services/agent/auth-adapter.test.ts

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ describe("AgentAuthAdapter", () => {
8585
});
8686

8787
it("builds the default PostHog MCP server routed through the local proxy", async () => {
88-
const servers = await adapter.buildMcpServers(baseCredentials);
88+
const { servers } = await adapter.buildMcpServers(baseCredentials);
8989

9090
expect(deps.mcpProxy.register).toHaveBeenCalledWith(
9191
"posthog",
@@ -126,7 +126,7 @@ describe("AgentAuthAdapter", () => {
126126
}),
127127
});
128128

129-
const servers = await adapter.buildMcpServers(baseCredentials);
129+
const { servers } = await adapter.buildMcpServers(baseCredentials);
130130

131131
expect(deps.mcpProxy.register).toHaveBeenCalledWith(
132132
"installation-inst-2",
@@ -143,6 +143,81 @@ describe("AgentAuthAdapter", () => {
143143
);
144144
});
145145

146+
it("fetches tool approval states for installations", async () => {
147+
mockFetch
148+
.mockResolvedValueOnce({
149+
ok: true,
150+
json: () =>
151+
Promise.resolve({
152+
results: [
153+
{
154+
id: "inst-3",
155+
url: "https://tools.example.com",
156+
proxy_url: "https://proxy.posthog.com/inst-3/",
157+
name: "tool-server",
158+
display_name: "Tool Server",
159+
auth_type: "oauth",
160+
is_enabled: true,
161+
pending_oauth: false,
162+
needs_reauth: false,
163+
},
164+
],
165+
}),
166+
})
167+
.mockResolvedValueOnce({
168+
ok: true,
169+
json: () =>
170+
Promise.resolve({
171+
results: [
172+
{ tool_name: "read_data", approval_state: "approved" },
173+
{ tool_name: "write_data", approval_state: "do_not_use" },
174+
{ tool_name: "query", approval_state: "needs_approval" },
175+
],
176+
}),
177+
});
178+
179+
const { toolApprovals, toolInstallations } =
180+
await adapter.buildMcpServers(baseCredentials);
181+
182+
expect(toolApprovals).toEqual({
183+
"mcp__tool-server__read_data": "approved",
184+
"mcp__tool-server__write_data": "do_not_use",
185+
"mcp__tool-server__query": "needs_approval",
186+
});
187+
expect(toolInstallations["mcp__tool-server__read_data"]).toEqual({
188+
installationId: "inst-3",
189+
toolName: "read_data",
190+
});
191+
});
192+
193+
it("returns empty approvals when tool fetch fails", async () => {
194+
mockFetch
195+
.mockResolvedValueOnce({
196+
ok: true,
197+
json: () =>
198+
Promise.resolve({
199+
results: [
200+
{
201+
id: "inst-4",
202+
url: "https://broken.example.com",
203+
proxy_url: "https://proxy.posthog.com/inst-4/",
204+
name: "broken-server",
205+
display_name: "Broken Server",
206+
auth_type: "oauth",
207+
is_enabled: true,
208+
pending_oauth: false,
209+
needs_reauth: false,
210+
},
211+
],
212+
}),
213+
})
214+
.mockResolvedValueOnce({ ok: false, status: 500 });
215+
216+
const { toolApprovals } = await adapter.buildMcpServers(baseCredentials);
217+
218+
expect(toolApprovals).toEqual({});
219+
});
220+
146221
it("configures environment using the gateway proxy and current token", async () => {
147222
await adapter.configureProcessEnv({
148223
credentials: baseCredentials,

apps/code/src/main/services/agent/auth-adapter.ts

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import { delimiter } from "node:path";
2+
import {
3+
type McpToolApprovalState,
4+
type McpToolApprovals,
5+
sanitizeMcpServerName,
6+
} from "@posthog/agent/adapters/claude/mcp/tool-metadata";
27
import { getLlmGatewayUrl } from "@posthog/agent/posthog-api";
38
import { inject, injectable } from "inversify";
49
import { MAIN_TOKENS } from "../../di/tokens";
@@ -10,6 +15,15 @@ import type { Credentials } from "./schemas";
1015

1116
const log = logger.scope("agent-auth-adapter");
1217

18+
const VALID_APPROVAL_STATES = new Set([
19+
"approved",
20+
"needs_approval",
21+
"do_not_use",
22+
]);
23+
function isValidApprovalState(value: string): value is McpToolApprovalState {
24+
return VALID_APPROVAL_STATES.has(value);
25+
}
26+
1327
export interface AcpMcpServer {
1428
name: string;
1529
type: "http";
@@ -24,6 +38,15 @@ export interface AgentPosthogConfig {
2438
projectId: number;
2539
}
2640

41+
/** Reference linking an MCP tool key back to its server installation for backend updates. */
42+
export interface McpToolInstallationRef {
43+
installationId: string;
44+
toolName: string;
45+
}
46+
47+
/** Maps MCP tool keys (e.g. `mcp__server__tool`) to their installation reference. */
48+
export type McpToolInstallations = Record<string, McpToolInstallationRef>;
49+
2750
interface ConfigureProcessEnvInput {
2851
credentials: Credentials;
2952
mockNodeDir: string;
@@ -51,7 +74,11 @@ export class AgentAuthAdapter {
5174
};
5275
}
5376

54-
async buildMcpServers(credentials: Credentials): Promise<AcpMcpServer[]> {
77+
async buildMcpServers(credentials: Credentials): Promise<{
78+
servers: AcpMcpServer[];
79+
toolApprovals: McpToolApprovals;
80+
toolInstallations: McpToolInstallations;
81+
}> {
5582
const servers: AcpMcpServer[] = [];
5683
const mcpUrl = this.getPostHogMcpUrl(credentials.apiHost);
5784
// Warm the token so authenticatedFetch() has something cached, but do not
@@ -95,7 +122,10 @@ export class AgentAuthAdapter {
95122
});
96123
}
97124

98-
return servers;
125+
const { approvals: toolApprovals, toolInstallations } =
126+
await this.fetchMcpToolApprovals(credentials, installations);
127+
128+
return { servers, toolApprovals, toolInstallations };
99129
}
100130

101131
async ensureGatewayProxy(apiHost: string): Promise<string> {
@@ -154,6 +184,96 @@ export class AgentAuthAdapter {
154184
return host.endsWith("/") ? host.slice(0, -1) : host;
155185
}
156186

187+
async updateMcpToolApproval(
188+
credentials: Credentials,
189+
installationId: string,
190+
toolName: string,
191+
approvalState: McpToolApprovalState,
192+
): Promise<void> {
193+
const baseUrl = this.getPostHogApiBaseUrl(credentials.apiHost);
194+
const url = `${baseUrl}/api/environments/${credentials.projectId}/mcp_server_installations/${installationId}/tools/${encodeURIComponent(toolName)}/`;
195+
const response = await this.authService.authenticatedFetch(fetch, url, {
196+
method: "PATCH",
197+
headers: { "Content-Type": "application/json" },
198+
body: JSON.stringify({ approval_state: approvalState }),
199+
});
200+
if (!response.ok) {
201+
throw new Error(
202+
`Failed to update MCP tool approval (${response.status}) for ${toolName} on installation ${installationId}`,
203+
);
204+
}
205+
}
206+
207+
private async fetchMcpToolApprovals(
208+
credentials: Credentials,
209+
installations: Array<{
210+
id: string;
211+
url: string;
212+
name: string;
213+
display_name: string;
214+
}>,
215+
): Promise<{
216+
approvals: McpToolApprovals;
217+
toolInstallations: McpToolInstallations;
218+
}> {
219+
const baseUrl = this.getPostHogApiBaseUrl(credentials.apiHost);
220+
const approvals: McpToolApprovals = {};
221+
const toolInstallations: McpToolInstallations = {};
222+
223+
const results = await Promise.allSettled(
224+
installations.map(async (installation) => {
225+
const serverName = sanitizeMcpServerName(
226+
installation.name || installation.display_name || installation.url,
227+
);
228+
const toolsUrl = `${baseUrl}/api/environments/${credentials.projectId}/mcp_server_installations/${installation.id}/tools/`;
229+
230+
const response = await this.authService.authenticatedFetch(
231+
fetch,
232+
toolsUrl,
233+
{ headers: { "Content-Type": "application/json" } },
234+
);
235+
if (!response.ok) return [];
236+
237+
const data = (await response.json()) as {
238+
results?: Array<{
239+
tool_name: string;
240+
approval_state?: string;
241+
}>;
242+
};
243+
return (data.results ?? []).map((tool) => ({
244+
serverName,
245+
installationId: installation.id,
246+
toolName: tool.tool_name,
247+
approvalState: tool.approval_state,
248+
}));
249+
}),
250+
);
251+
252+
for (const result of results) {
253+
if (result.status !== "fulfilled") {
254+
log.warn("Failed to fetch tool approvals for an installation", {
255+
error:
256+
result.reason instanceof Error
257+
? result.reason.message
258+
: String(result.reason),
259+
});
260+
continue;
261+
}
262+
for (const tool of result.value) {
263+
const key = `mcp__${tool.serverName}__${tool.toolName}`;
264+
if (tool.approvalState && isValidApprovalState(tool.approvalState)) {
265+
approvals[key] = tool.approvalState;
266+
}
267+
toolInstallations[key] = {
268+
installationId: tool.installationId,
269+
toolName: tool.toolName,
270+
};
271+
}
272+
}
273+
274+
return { approvals, toolInstallations };
275+
}
276+
157277
private async fetchMcpInstallations(credentials: Credentials): Promise<
158278
Array<{
159279
id: string;

apps/code/src/main/services/agent/service.test.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -154,14 +154,18 @@ function createMockDependencies() {
154154
refreshApiKey: vi.fn().mockResolvedValue("fresh-access-token"),
155155
projectId: credentials.projectId,
156156
})),
157-
buildMcpServers: vi.fn().mockResolvedValue([
158-
{
159-
name: "posthog",
160-
type: "http",
161-
url: "https://mcp.posthog.com/mcp",
162-
headers: [],
163-
},
164-
]),
157+
buildMcpServers: vi.fn().mockResolvedValue({
158+
servers: [
159+
{
160+
name: "posthog",
161+
type: "http",
162+
url: "https://mcp.posthog.com/mcp",
163+
headers: [],
164+
},
165+
],
166+
toolApprovals: {},
167+
toolInstallations: {},
168+
}),
165169
},
166170
mcpAppsService: {
167171
setServerConfigs: vi.fn(),
@@ -301,6 +305,8 @@ describe("AgentService", () => {
301305
config: {},
302306
promptPending: false,
303307
inFlightMcpToolCalls: new Map(),
308+
mcpToolApprovals: {},
309+
toolInstallations: {},
304310
...overrides,
305311
});
306312
}

0 commit comments

Comments
 (0)