Skip to content

feat(mcp-store): agent request permission for tool calling#1784

Open
cvolzer3 wants to merge 9 commits intofeat/refactor-mcp-storefrom
worktree-fix+mcp-tool-approval
Open

feat(mcp-store): agent request permission for tool calling#1784
cvolzer3 wants to merge 9 commits intofeat/refactor-mcp-storefrom
worktree-fix+mcp-tool-approval

Conversation

@cvolzer3
Copy link
Copy Markdown
Contributor

Screenshot 2026-04-21 at 2 47 43 PM

Problem

When the agent uses a blocked or approval-required MCP tool, Claude incorrectly tells users to check "Claude Code settings." PostHog Code owns MCP installations, and needs_approval tools should show an interactive approval dialog instead of a static denial.

Changes

  • Intercept MCP tool calls in canUseTool(): do_not_use are denied and the user is directed to "Settings > MCP Servers"; needs_approval tools are shown permission dialog
  • Fetch tool approval states from PostHog API at session start and thread them through _meta into the tool metadata cache
  • When user approves a needs_approval tool, PATCH the backend to set approved before execution, otherwise the server-side proxy still rejects the call
  • Sanitize server names to match the SDK's convention (non-alphanumeric → _)
  • Skip read-only auto-approval for needs_approval tools
  • Fix system prompt to stop Claude from suggesting Settings for generic MCP errors

How did you test this?

  • New unit tests in tool-metadata.test.ts (10 tests) and permission-handlers.test.ts (8 tests) covering all approval states, bypass mode, read-only override, and dialog title format
  • Updated auth-adapter.test.ts and service.test.ts for new return types
  • pnpm typecheck, pnpm --filter agent test (287 pass), pnpm --filter code test (770 pass)
  • Manual: needs_approval shows dialog, do_not_use shows correct denial message

MCP tools with approval_state "do_not_use" are now blocked with a message
directing users to Settings > MCP Servers in PostHog Code. Tools with
"needs_approval" trigger the standard permission dialog. Previously, the
agent had no awareness of PostHog-managed approval states and Claude would
incorrectly tell users to check "Claude Code settings."

- Fetch tool approval states from PostHog API during session creation
- Check approval state in canUseTool() before isToolAllowedForMode
- Preserve approval state across MCP metadata cache updates
- Skip read-only auto-approval for needs_approval tools
- Add system prompt guidance to relay denial messages verbatim
The Claude Agent SDK sanitizes MCP server names (replacing non-alphanumeric
characters with underscores) when building tool names like `mcp__<server>__<tool>`.
Our approval key construction used the raw server name from the PostHog API,
causing key mismatches for servers with spaces or special characters (e.g.
HubSpot). This made `needs_approval` tools fall through to the default
permission flow instead of being intercepted.
The permission dialog for needs_approval MCP tools now shows
"The agent wants to call <tool> (<server>)" instead of just the
raw tool name, giving users clear context about what's being requested.
…oval tool

When a user approves a needs_approval tool in the permission dialog, PATCH
the PostHog API to set approval_state to "approved" before the tool executes.
Without this, the server-side proxy rejects the call even after local approval.

Also fixes the system prompt instruction to stop Claude from directing users
to Settings for every MCP tool error — only relay explicit denial messages.
Vite resolves @posthog/agent subpath imports by mapping to the source
tree. The shorthand "mcp/tool-metadata" resolved to the wrong path.
Use "adapters/claude/mcp/tool-metadata" to match the actual source
location, consistent with other deep imports in the codebase.
@cvolzer3
Copy link
Copy Markdown
Contributor Author

cvolzer3 commented Apr 21, 2026

fyi, this builds on the other mcp store pr: #1747

@cvolzer3 cvolzer3 requested a review from a team April 21, 2026 18:49
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 21, 2026

Comments Outside Diff (3)

  1. apps/code/src/main/services/agent/service.ts, line 1437-1466 (link)

    P1 needs_approval dialog re-fires on every call within the same session

    After a user approves a needs_approval tool with "Yes" (allow_once), session.mcpToolApprovals[toolName] is updated to "approved" and the backend is patched. But mcpToolMetadataCache (the module-level cache read by getMcpToolApprovalState in canUseTool) is never updated. So on the very next call to the same tool in the same session, canUseTool still sees "needs_approval" and re-invokes handleMcpApprovalFlow, showing the dialog again.

    The fix is to call setMcpToolApprovalStates({ [toolName]: "approved" }) immediately after the successful backend patch:

    await service.agentAuthAdapter.updateMcpToolApproval(...);
    session.mcpToolApprovals[toolName] = "approved";
    setMcpToolApprovalStates({ [toolName]: "approved" }); // sync the global cache
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/code/src/main/services/agent/service.ts
    Line: 1437-1466
    
    Comment:
    **`needs_approval` dialog re-fires on every call within the same session**
    
    After a user approves a `needs_approval` tool with "Yes" (allow_once), `session.mcpToolApprovals[toolName]` is updated to `"approved"` and the backend is patched. But `mcpToolMetadataCache` (the module-level cache read by `getMcpToolApprovalState` in `canUseTool`) is never updated. So on the very next call to the same tool in the same session, `canUseTool` still sees `"needs_approval"` and re-invokes `handleMcpApprovalFlow`, showing the dialog again.
    
    The fix is to call `setMcpToolApprovalStates({ [toolName]: "approved" })` immediately after the successful backend patch:
    
    ```typescript
    await service.agentAuthAdapter.updateMcpToolApproval(...);
    session.mcpToolApprovals[toolName] = "approved";
    setMcpToolApprovalStates({ [toolName]: "approved" }); // sync the global cache
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. packages/agent/src/adapters/claude/mcp/tool-metadata.test.ts, line 604-620 (link)

    P2 Prefer parameterised tests for sanitizeMcpServerName

    These four cases share identical structure — only the input and expected output differ. Per the team's convention, they should use it.each:

    it.each([
      ["passes through simple alphanumeric names", "HubSpot", "HubSpot"],
      ["replaces spaces with underscores", "My Server", "My_Server"],
      ["replaces special characters with underscores", "server@v2.0!", "server_v2_0_"],
      ["preserves hyphens and underscores", "my-server_v2", "my-server_v2"],
    ])("%s", (_desc, input, expected) => {
      expect(sanitizeMcpServerName(input)).toBe(expected);
    });
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/agent/src/adapters/claude/mcp/tool-metadata.test.ts
    Line: 604-620
    
    Comment:
    **Prefer parameterised tests for `sanitizeMcpServerName`**
    
    These four cases share identical structure — only the input and expected output differ. Per the team's convention, they should use `it.each`:
    
    ```typescript
    it.each([
      ["passes through simple alphanumeric names", "HubSpot", "HubSpot"],
      ["replaces spaces with underscores", "My Server", "My_Server"],
      ["replaces special characters with underscores", "server@v2.0!", "server_v2_0_"],
      ["preserves hyphens and underscores", "my-server_v2", "my-server_v2"],
    ])("%s", (_desc, input, expected) => {
      expect(sanitizeMcpServerName(input)).toBe(expected);
    });
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.

    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!

  3. packages/agent/src/adapters/claude/mcp/tool-metadata.test.ts, line 559-567 (link)

    P2 Test name is misleading — readOnly preservation is not actually verified

    The test is titled "merges with existing entries preserving readOnly", but it starts from an empty cache, so there is no existing entry to preserve. It only asserts that newly-created entries default to readOnly: false. A proper coverage of the invariant would pre-populate the cache with readOnly: true, call setMcpToolApprovalStates, and then assert readOnly is still true. Without this, the claim that readOnly is preserved is untested.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/agent/src/adapters/claude/mcp/tool-metadata.test.ts
    Line: 559-567
    
    Comment:
    **Test name is misleading — `readOnly` preservation is not actually verified**
    
    The test is titled "merges with existing entries preserving readOnly", but it starts from an empty cache, so there is no existing entry to preserve. It only asserts that newly-created entries default to `readOnly: false`. A proper coverage of the invariant would pre-populate the cache with `readOnly: true`, call `setMcpToolApprovalStates`, and then assert `readOnly` is still `true`. Without this, the claim that readOnly is preserved is untested.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/agent/src/adapters/claude/mcp/tool-metadata.ts
Line: 16

Comment:
**Module-level cache shared across all concurrent sessions**

`mcpToolMetadataCache` is a module-level singleton. Each new session calls `setMcpToolApprovalStates(meta.mcpToolApprovals)` in `claude-agent.ts`, which mutates this shared map. If two sessions run concurrently for the same tool key (e.g. `mcp__posthog__search`), the second session's `setMcpToolApprovalStates` call overwrites the state set by the first — and `canUseTool` reads from this same global cache. A session that had `"approved"` could see `"needs_approval"` (or `"do_not_use"`) after another session starts.

The per-session `mcpToolApprovals` field already exists on `ManagedSession` in `service.ts`, but the permission enforcement path in `permission-handlers.ts` bypasses it entirely. Approval states should either be keyed by session, or the cache should be keyed by `(sessionId, toolKey)` to prevent cross-session contamination.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/code/src/main/services/agent/service.ts
Line: 1437-1466

Comment:
**`needs_approval` dialog re-fires on every call within the same session**

After a user approves a `needs_approval` tool with "Yes" (allow_once), `session.mcpToolApprovals[toolName]` is updated to `"approved"` and the backend is patched. But `mcpToolMetadataCache` (the module-level cache read by `getMcpToolApprovalState` in `canUseTool`) is never updated. So on the very next call to the same tool in the same session, `canUseTool` still sees `"needs_approval"` and re-invokes `handleMcpApprovalFlow`, showing the dialog again.

The fix is to call `setMcpToolApprovalStates({ [toolName]: "approved" })` immediately after the successful backend patch:

```typescript
await service.agentAuthAdapter.updateMcpToolApproval(...);
session.mcpToolApprovals[toolName] = "approved";
setMcpToolApprovalStates({ [toolName]: "approved" }); // sync the global cache
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/agent/src/adapters/claude/mcp/tool-metadata.test.ts
Line: 604-620

Comment:
**Prefer parameterised tests for `sanitizeMcpServerName`**

These four cases share identical structure — only the input and expected output differ. Per the team's convention, they should use `it.each`:

```typescript
it.each([
  ["passes through simple alphanumeric names", "HubSpot", "HubSpot"],
  ["replaces spaces with underscores", "My Server", "My_Server"],
  ["replaces special characters with underscores", "server@v2.0!", "server_v2_0_"],
  ["preserves hyphens and underscores", "my-server_v2", "my-server_v2"],
])("%s", (_desc, input, expected) => {
  expect(sanitizeMcpServerName(input)).toBe(expected);
});
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/agent/src/adapters/claude/mcp/tool-metadata.test.ts
Line: 559-567

Comment:
**Test name is misleading — `readOnly` preservation is not actually verified**

The test is titled "merges with existing entries preserving readOnly", but it starts from an empty cache, so there is no existing entry to preserve. It only asserts that newly-created entries default to `readOnly: false`. A proper coverage of the invariant would pre-populate the cache with `readOnly: true`, call `setMcpToolApprovalStates`, and then assert `readOnly` is still `true`. Without this, the claim that readOnly is preserved is untested.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix: types" | Re-trigger Greptile

approvalState?: McpToolApprovalState;
}

const mcpToolMetadataCache: Map<string, McpToolMetadata> = new Map();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Module-level cache shared across all concurrent sessions

mcpToolMetadataCache is a module-level singleton. Each new session calls setMcpToolApprovalStates(meta.mcpToolApprovals) in claude-agent.ts, which mutates this shared map. If two sessions run concurrently for the same tool key (e.g. mcp__posthog__search), the second session's setMcpToolApprovalStates call overwrites the state set by the first — and canUseTool reads from this same global cache. A session that had "approved" could see "needs_approval" (or "do_not_use") after another session starts.

The per-session mcpToolApprovals field already exists on ManagedSession in service.ts, but the permission enforcement path in permission-handlers.ts bypasses it entirely. Approval states should either be keyed by session, or the cache should be keyed by (sessionId, toolKey) to prevent cross-session contamination.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/agent/src/adapters/claude/mcp/tool-metadata.ts
Line: 16

Comment:
**Module-level cache shared across all concurrent sessions**

`mcpToolMetadataCache` is a module-level singleton. Each new session calls `setMcpToolApprovalStates(meta.mcpToolApprovals)` in `claude-agent.ts`, which mutates this shared map. If two sessions run concurrently for the same tool key (e.g. `mcp__posthog__search`), the second session's `setMcpToolApprovalStates` call overwrites the state set by the first — and `canUseTool` reads from this same global cache. A session that had `"approved"` could see `"needs_approval"` (or `"do_not_use"`) after another session starts.

The per-session `mcpToolApprovals` field already exists on `ManagedSession` in `service.ts`, but the permission enforcement path in `permission-handlers.ts` bypasses it entirely. Approval states should either be keyed by session, or the cache should be keyed by `(sessionId, toolKey)` to prevent cross-session contamination.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread apps/code/src/main/services/agent/service.ts
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.

1 participant