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.
Deedglass
A tool call meets your rules before it runs
Asks to run
DELETE FROM visits;
Deletes every row in visits
Deedglass checks your rules
- Reading dataAllowed
- Deleting a whole table (DROP, TRUNCATE)Never
- Changing or deleting rows without saying which (WHERE)This one
- Other changesWait for a person
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 forexecute_sqlcalls whose SQL writes. A parameter match for an MCP tool only works as a deny rule passed with--disallowedTools. askbeatsallow. A matching ask rule prompts even when a narrower allow rule also matches. Leave bypass mode off (thedisableBypassPermissionsModeline), 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 ascat, 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.jsonin an interactive session, and a cloned repository can’t pre-approve its own servers in a folder you haven’t trusted. - In
claude -pruns, Agent SDK sessions and cloud sessions there is no one to show that prompt to, so project servers load without asking. Use--strict-mcp-configordisabledMcpjsonServersto 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.