Skip to content

fix(agent): escalate mcp-store tool approvals to the user#2954

Draft
cvolzer3 wants to merge 2 commits into
mainfrom
codex/sandbox-mcp-tool-permissions
Draft

fix(agent): escalate mcp-store tool approvals to the user#2954
cvolzer3 wants to merge 2 commits into
mainfrom
codex/sandbox-mcp-tool-permissions

Conversation

@cvolzer3

Copy link
Copy Markdown
Contributor

Problem

When an agent in a cloud session calls an MCP Store tool whose per-tool approval state is needs_approval, the MCP Store proxy rejects the call with MCP error -32001: Tool '<name>' requires approval before it can be called. The run just fails — the user is never asked, so there is no way to approve and continue.

The goal: escalate the approval to the user (approve/deny dialog in PostHog Code), and on approval let the agent keep working in the cloud.

Changes

The backend already passed per-tool approval metadata into the sandbox (--mcpToolApprovals / --mcpToolInstallations), and the approval gate already lived in the Claude adapter's canUseTool (handleMcpApprovalFlow → relay dialog → persist approval → proxy then allows). Two defects kept that gate from ever firing:

  1. bypassPermissions skips canUseTool. Cloud Claude runs default to bypassPermissions, which the SDK documents as "bypass all permission checks" — so canUseTool (where the gate lives) is never invoked and the tool runs straight into the proxy → -32001.

    • Fix: add an MCP-store branch to the PreToolUse hook (hooks.ts), which runs in every permission mode. It forces permissionDecision: "ask" for needs_approval tools (routing them back through canUseTool/the approval dialog) and "deny" for do_not_use. This mirrors the existing destructive-PostHog-exec gate.
  2. Approval state was wiped on MCP server reconnect. Approval state was stored on the volatile MCP metadata cache, but clearMcpToolMetadataCache() runs on every MCP server refresh/reconnect (which happens right after startup). After the wipe, getMcpToolApprovalState() returned undefined, the gate was skipped, and since these tools are typically readOnly, isToolAllowedForMode auto-allowed them in default mode → -32001.

    • Fix: store approval state in a dedicated cache (tool-metadata.ts) that survives metadata-cache clears. Approval state is session config, not per-connection metadata.

This PR also includes the in-progress relay/plumbing work this fix builds on: robust MCP tool-key resolution (mcp-store/tool-keys.ts), the cloud relay + persist path (agent-server.ts, posthog-api.ts, bin.ts, schemas.ts), and Codex-adapter approval support.

No backend (posthog/posthog) changes are needed — verified live that the sandbox already receives the correct --mcpToolApprovals map.

How did you test this?

Automated (all run locally, passing):

  • vitest for tool-metadata, hooks, permission-handlers test files — incl. new tests: approval state survives a metadata-cache clear; PreToolUse returns ask/deny/respects-allow-rule for MCP-store tools.
  • tsc --noEmit clean; biome check clean.
  • Rebuilt dist (tsup) and confirmed both fixes are bundled.

Live investigation (not yet a full end-to-end dialog run):

  • Confirmed via a running sandbox that --mcpToolApprovals carries needs_approval for the affected tools, the runtime MCP server name matches the approval keys, the tools are readOnly, and the metadata cache is cleared by a server reconnect at startup.

Still to verify end-to-end (draft): trigger an actual tool call in a fresh sandbox and confirm the dialog renders and approval lets the run continue. If the relay reaches the desktop but no dialog appears, the next suspect is the desktop-side rendering of the relayed _posthog/permission_request for cloud tasks.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

cvolzer3 added 2 commits June 26, 2026 17:22
Cloud sessions surfaced the MCP Store proxy's "requires approval" (-32001)
instead of prompting the user. Two root causes:

- bypassPermissions (the cloud Claude default) skips canUseTool, where the
  MCP approval gate lives, so needs_approval tools ran unchecked. Add a
  PreToolUse hook that forces ask/deny for needs_approval / do_not_use
  MCP Store tools, which runs in every permission mode.
- approval state was stored on the volatile MCP metadata cache, which
  clearMcpToolMetadataCache() wipes on every server refresh/reconnect.
  Move it to a dedicated cache that survives refreshes so the gate keeps
  seeing the right state mid-session.

Also hardens mcp-store tool-key resolution and wires codex approval support.
@github-actions

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit 75f5bdc.

Comment on lines +19 to +21
return sanitizeMcpServerName(name)
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "")
serverName: string;
toolName: string;
} | null {
const match = title.match(/^The agent wants to call\s+(.+?)\s+\((.+)\)$/);
@greptile-apps

greptile-apps Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "fix(agent): escalate mcp-store tool appr..." | Re-trigger Greptile

Comment on lines +1312 to +1314
...(this.config.mcpToolApprovals && {
mcpToolApprovals: this.config.mcpToolApprovals,
}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 mcpToolInstallations is omitted from sessionMeta even though mcpToolApprovals is included. When the Codex adapter runs in cloud mode via agent-server.ts, sessionState.mcpToolInstallations will be undefined. In codex-client.ts, getMcpToolNameFromPermissionRequest uses sessionState.mcpToolInstallations as a disambiguation fallback — without it, bare tool names that collide across multiple MCP servers will resolve to null, bypassing the in-client approval short-circuit and falling through to the upstream (agent-server) unchanged. The server-side approveMcpStoreToolIfNeeded still has this.config.mcpToolInstallations so the relay and persist path remains correct, but the Codex client's in-memory sessionState.mcpToolApprovals update will be skipped for those cases.

Suggested change
...(this.config.mcpToolApprovals && {
mcpToolApprovals: this.config.mcpToolApprovals,
}),
...(this.config.mcpToolApprovals && {
mcpToolApprovals: this.config.mcpToolApprovals,
}),
...(this.config.mcpToolInstallations && {
mcpToolInstallations: this.config.mcpToolInstallations,
}),

Comment on lines +118 to 128
export function getMcpToolMetadataKey(toolName: string): string | undefined {
const resolvedKey = resolveMcpStoreToolKey(toolName, {
approvals: Object.fromEntries(
[...mcpToolMetadataCache.keys()].map((key) => [key, true]),
),
});
const normalizedToolName = normalizeMcpToolKey(toolName);
if (mcpToolMetadataCache.has(toolName)) return toolName;
if (mcpToolMetadataCache.has(normalizedToolName)) return normalizedToolName;
return resolvedKey ?? undefined;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 resolvedKey is computed eagerly — iterating all metadata cache keys and calling resolveMcpStoreToolKey — before the cheaper direct and normalized-key checks at lines 125–126. For the common case where the tool name is already in the cache verbatim, this work is wasted. Moving the direct/normalized checks before the resolveMcpStoreToolKey call avoids the overhead without changing behavior.

Suggested change
export function getMcpToolMetadataKey(toolName: string): string | undefined {
const resolvedKey = resolveMcpStoreToolKey(toolName, {
approvals: Object.fromEntries(
[...mcpToolMetadataCache.keys()].map((key) => [key, true]),
),
});
const normalizedToolName = normalizeMcpToolKey(toolName);
if (mcpToolMetadataCache.has(toolName)) return toolName;
if (mcpToolMetadataCache.has(normalizedToolName)) return normalizedToolName;
return resolvedKey ?? undefined;
}
export function getMcpToolMetadataKey(toolName: string): string | undefined {
if (mcpToolMetadataCache.has(toolName)) return toolName;
const normalizedToolName = normalizeMcpToolKey(toolName);
if (mcpToolMetadataCache.has(normalizedToolName)) return normalizedToolName;
const resolvedKey = resolveMcpStoreToolKey(toolName, {
approvals: Object.fromEntries(
[...mcpToolMetadataCache.keys()].map((key) => [key, true]),
),
});
return resolvedKey ?? undefined;
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants