Require approval for MCP tool calls: Claude Code, Cursor, then a gateway

Both clients can ask before a tool runs. Here is the configuration, a hook we tested, what each layer can't see, and the point where approvals belong outside the editor.

Every agent client already asks before some tool calls. The work is making it ask before the right ones, in a way a repository can’t quietly undo, without asking so often that people stop reading. This guide covers Claude Code and Cursor as their docs describe them on 18 October 2026, a hook we wrote and tested, and the point where approvals should move out of the editor.

What the spec asks for

The MCP specification (revision 2026-07-28) says there SHOULD always be a human in the loop with the ability to deny tool invocations, and that clients MUST treat tool annotations as untrusted unless they come from trusted servers. So a server marking its tool readOnlyHint: true is a hint, not a reason to skip the prompt. Approval is the client’s job, or something in front of the client.

Every MCP tool call goes through your rules first. Here a delete that doesn’t say which rows is stopped before it reaches the database. These are the rules deedglass init starts with; the call is made up.

Claude Code: rules in settings.json

Claude Code shows MCP tools as mcp__<server>__<tool>, and a project server added with --scope project lands in .mcp.json.3 Permission rules live in .claude/settings.json (shared), .claude/settings.local.json (yours), ~/.claude/settings.json (every project) and managed settings, which nothing else overrides. Rules are evaluated deny, then ask, then allow; the first match wins, and specificity doesn’t change the order.

Code Data in JSON, the format software uses to pass records and settings.

{
  "permissions": {
    "defaultMode": "default",
    "disableBypassPermissionsMode": "disable",
    "allow": ["mcp__postgres__list_tables", "mcp__postgres__describe_table"],
    "ask": ["mcp__postgres__execute_sql", "Bash(psql *)", "Bash(npx prisma migrate *)"],
    "deny": ["Read(./.env)", "Read(./.env.*)", "Bash(npx prisma migrate reset *)"]
  }
}

Three details from the docs matter here:

  • MCP rules stop at the tool name. Claude Code skips any mcp__ rule with parentheses when it loads a settings file, so you can’t ask only for execute_sql calls whose SQL writes. A parameter match for an MCP tool only works as a deny rule passed with --disallowedTools.
  • ask beats allow. A matching ask rule prompts even when a narrower allow rule also matches. Leave bypass mode off (the disableBypassPermissionsMode line), because it skips prompts.
  • Bash rules match text, not programs. The docs say a deny or ask rule covers the form Claude usually writes and is not a security boundary around the program: sh -c '…' or a full path gets past it. Read(./.env) covers the file tools and recognised file commands such as cat, not a script that opens the file itself.

That last point isn’t theoretical. In issue 80868 (opened 24 July 2026, Claude Code 2.1.218), a user in auto mode had ask rules for Bash(npx prisma migrate:*). The model chained cd … && with a prisma migrate diff that read DATABASE_URL out of .env into --shadow-database-url. No prompt appeared, Prisma reset the “shadow” database, and it was production; the reporter restored from a nightly backup. Today’s docs say ask rules apply to every subcommand of a compound command, including command substitutions. Check your version, and don’t rely on a prefix.

Claude Code: a PreToolUse hook

When a rule can’t express the condition, a PreToolUse hook can. It gets the tool name and full input as JSON on stdin and can answer allow, deny, ask or defer. Hook decisions don’t bypass rules: a matching deny still blocks and a matching ask still prompts, whatever the hook returns. This one asks before anything that looks like a write, from the shell or from an MCP tool:

Code Commands typed in a terminal. The text around it says what happens.

#!/usr/bin/env bash
# .claude/hooks/db-write-guard.sh
input=$(cat)
tool=$(jq -r '.tool_name' <<<"$input") || exit 2
case "$tool" in
  Bash)   text=$(jq -r '.tool_input.command // ""' <<<"$input") ;;
  mcp__*) text=$(jq -c '.tool_input' <<<"$input") ;;
  *)      exit 0 ;;
esac
pattern='(^|[^a-z_])(insert|update|delete|truncate|drop|alter|merge|grant|revoke)([^a-z_]|$)|migrate (reset|dev)|db push|force-reset'
if grep -Eiq "$pattern" <<<"$text"; then
  jq -n --arg r "Database write via $tool. A person approves this one." \
    '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: $r}}'
fi
exit 0

Registered in the same settings file:

Code Data in JSON, the format software uses to pass records and settings.

"hooks": {
  "PreToolUse": [
    { "matcher": "Bash|mcp__.*",
      "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/db-write-guard.sh", "timeout": 10 }] }
  ]
}

We fed it hook input by hand. A psql -c "DELETE …", an mcp__postgres__execute_sql call with an UPDATE, and npx prisma migrate reset --force each returned "permissionDecision": "ask". A SELECT returned nothing, so the normal rules applied, and a column named updated_at didn’t trip it.

Know how it fails. Exit code 2 blocks; any other non-zero exit is a non-blocking error and the call goes ahead. A PreToolUse hook that times out doesn’t block either, unless you set "onFailure": "block" (Claude Code 2.1.295 or later). If your hook is a control rather than a convenience, make its failure path exit 2.

For a team, put the parts that must hold in managed settings. A deny or ask rule there can’t be overridden by project settings or the command line, disableBypassPermissionsMode there can’t be switched back, and allowManagedHooksOnly stops user and project hooks from running beside yours. Two more defaults are worth knowing before an agent runs unattended:

  • Claude Code asks before it uses a project-scoped server from a repository’s .mcp.json in an interactive session, and a cloned repository can’t pre-approve its own servers in a folder you haven’t trusted.
  • In claude -p runs, Agent SDK sessions and cloud sessions there is no one to show that prompt to, so project servers load without asking. Use --strict-mcp-config or disabledMcpjsonServers to keep a server out of CI.

If you write the MCP server, there is a stronger switch: setting _meta["anthropic/requiresUserInteraction"] to true on a tool makes Claude Code prompt on every call, even in bypassPermissions mode, with no “don’t ask again” option, and allow rules don’t skip it.

Cursor

Cursor reads MCP servers from .cursor/mcp.json and ~/.cursor/mcp.json.10 Its security page says each MCP connection needs approval and each tool call is then approved individually, unless the tool is on the MCP allowlist. The allowlist lives in ~/.cursor/permissions.json or .cursor/permissions.json, as server:tool entries with globs:

Code Data in JSON, the format software uses to pass records and settings.

{ "mcpAllowlist": ["postgres:list_tables", "postgres:describe_*"] }

Two caveats from the reference: if the file defines mcpAllowlist, it replaces the in-app list for MCP entirely, and the docs describe the allowlists as a best-effort convenience, not a security boundary. There is no documented denylist.

For a decision on arguments, Cursor has a beforeMCPExecution hook in .cursor/hooks.json that receives mcp_server_name, tool_name and tool_input and answers allow, deny or ask:

Code Data in JSON, the format software uses to pass records and settings.

{ "version": 1,
  "hooks": { "beforeMCPExecution": [
    { "command": ".cursor/hooks/mcp-db-guard.sh", "timeout": 10, "failClosed": true } ] } }

Our script used the same pattern as above. Piped test input gave {"permission":"ask",…} for a DELETE, {"permission":"allow"} for a SELECT, and a deny when mcp_server_name was missing, which the docs advise. Hooks fail open by default; failClosed: true is what makes a crash or timeout block. We tested the scripts by piping input, not inside a running Cursor.

What text checks can’t see

Every layer so far reads the call: a tool name, a command string, some SQL. That misses writes whose text looks harmless. On a local Postgres 18.6:

Code A database command in SQL. The text around it says what it does.

CREATE FUNCTION legacy.tidy_orders() RETURNS text LANGUAGE plpgsql AS $fn$
DECLARE n int;
BEGIN
  DELETE FROM public.orders WHERE status = 'cancelled';
  GET DIAGNOSTICS n = ROW_COUNT;
  RETURN 'tidied';
END $fn$;

Code What the screen or file shows, copied as is.

 orders_before
---------------
           500

 tidy_orders
-------------
 tidied

 orders_after
--------------
          450

psql -c "SELECT legacy.tidy_orders()" passes our hook untouched: no write keyword in the text. Fifty rows went. Triggers do the same thing behind an innocent UPDATE, as the pgAudit, triggers and logical decoding post shows. Approval decides whether a call may run; it can’t tell you what the call did.

The other limit is people. A prompt on every execute_sql gets approved without reading by the second day. Keep reads on the allowlist and save the prompt for writes.

Then a gateway

Client-side approval has a ceiling. Each developer’s machine holds its own rules, a repository’s settings can loosen them for anyone who trusts the folder, and the person approving is the person who asked the agent to do the work. A gateway moves the decision onto the path between every client and the MCP servers: one policy for Claude Code, Cursor and the CI agent, and an approver who isn’t the requester.

The shape is similar across products. TrueFoundry’s docs, for example, describe a gated call that creates a pending request, notifies approvers, and returns a result saying it is waiting for approval, without running the tool; the client retries later. The MCP gateway comparison covers what several of them check. Most gateways still see only the call and its reply, so the function-call problem above follows you there.

How Deedglass does it

Deedglass is one of those gateways. Its rules decide each tool call before it runs, and a person can approve in Slack, email or the Console.9 An existing server goes behind it with deedglass mcp wrap --connector <name> -- <server command>, and a rule pauses writes until someone countersigns:

Code A settings file: the rules a tool reads before it acts.

- id: orders-writes-need-approval
  match: { tool: [postgres.execute] }
  when: sql.writes && "public.orders" in sql.tables
  decision: require_approval
  approval: { approvers: [role:admin], required: 1, expires_in: 30m }

Nothing runs while it waits, and an approval nobody decides expires. A server’s own annotations are never used for decisions. For Postgres, its check then reads the change log and reports Matches or Doesn’t match, so what it compares is what committed, not what the SQL text looked like.9

Try an approval in your browser at /play. To see what your agent can reach today, run npx deedglass scan; the score page explains the grade.

Written with AI assistance and checked against our claims register.

The takeaway

Claude Code and Cursor can ask before a tool runs, but they can’t see what committed; approvals that must expire and be recorded belong outside the editor.

Read nextOpenAI Agents SDK tool guardrails: how to use them on MCP tools, and what they skip Try it in your browser

Related on this site

More posts

Sources

  1. 1Claude Code documentation: Configure permissions · code.claude.com · checked 18 October 2026
  2. 2Claude Code documentation: Hooks reference · code.claude.com · checked 18 October 2026
  3. 3Claude Code documentation: Connect Claude Code to tools via MCP · code.claude.com · checked 18 October 2026
  4. 4Cursor documentation: Agent security · cursor.com · checked 18 October 2026
  5. 5Cursor documentation: Permissions reference · cursor.com · checked 18 October 2026
  6. 6Cursor documentation: Hooks · cursor.com · checked 18 October 2026
  7. 7MCP specification 2026-07-28: Tools · modelcontextprotocol.io · checked 18 October 2026
  8. 8Claude Code issue 80868: ask rules failed to gate a destructive command · github.com · checked 18 October 2026
  9. 9Deedglass docs: Approvals · docs.deedglass.com · checked 18 October 2026
  10. 10Cursor documentation: Model Context Protocol · cursor.com · checked 13 October 2026

Published 18 October 2026, sources last checked 18 October 2026. If something here is out of date, write to hello@deedglass.com.

Try it on your own setup

npx deedglass scan

Grades only your own setup. Nothing is uploaded.

Pricing