MCP tools are full-citizen tools in the matcher syntax: an MCP server named postgres exposing a query tool shows up as mcp__postgres__query, and a hook can target it just like a built-in. The hard part is not the matcher; it is keeping a slow or flaky MCP server from blocking your session through the hook.
The basic shape
.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__postgres__query",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/log-mcp-query.sh"
}
]
}
]
}
}
mcp__postgres__query matches one specific tool. The matcher field is interpreted in two modes:
- If it contains only letters, digits, underscores, and
|, it is treated as an exact match (or exact list).mcp__postgres__queryandmcp__postgres__query|mcp__postgres__schemaboth fall into this mode. - If it contains any other character (
.,*,(, etc.), it is treated as a JavaScript regex. Somcp__postgres__.*matches every tool from the postgres server,mcp__.*matches every MCP tool, andmcp__(postgres|stripe)__(query|charge)matches two servers and two tools each.
Forgetting the mcp__ prefix is the most common reason a hook never fires.
.claude/hooks/log-mcp-query.sh:
#!/usr/bin/env bash
set -euo pipefail
# Read the event envelope from stdin. PreToolUse includes tool_name
# (e.g. "mcp__postgres__query") and tool_input (the args Claude is passing).
input=$(cat)
tool=$(printf '%s' "$input" | jq -r '.tool_name // empty')
args=$(printf '%s' "$input" | jq -c '.tool_input // {}')
mkdir -p "${CLAUDE_PROJECT_DIR:-$PWD}/.claude/logs"
printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$tool" "$args" \
>> "${CLAUDE_PROJECT_DIR:-$PWD}/.claude/logs/mcp-calls.tsv"
exit 0
chmod +x .claude/hooks/log-mcp-query.sh
Run several hooks at once
Multiple handlers under one matcher group all run for the same event, and Claude Code runs matching hooks in parallel rather than as a sequential chain. Their decisions are merged by precedence: any deny wins over any allow. Treat each hook as independent. Do not write hook A assuming hook B has already redacted something.
{
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__postgres__query",
"hooks": [
{ "type": "command", "command": ".claude/hooks/check-tenant-id.sh" },
{ "type": "command", "command": ".claude/hooks/log-mcp-query.sh", "async": true }
]
}
]
}
}
check-tenant-id.sh is the gate: it blocks the call if tenant_id is missing. log-mcp-query.sh is async: true so it never blocks the tool call regardless of how slow disk I/O gets. Both run on every matching call; the tool runs only if no synchronous hook returns deny.
Exit codes that actually work
For PreToolUse:
| Exit code | Effect |
|---|---|
| 0 | Allow the tool call. Stdout for PreToolUse is written to the debug log; neither Claude nor the transcript sees it unless you emit structured JSON (see below). |
| 2 | Block the tool call. Stderr is shown to Claude as the reason. |
| Other non-zero | Non-blocking error. A hook-error notice appears in the transcript; the tool call still runs. |
For PostToolUse, the tool has already executed; exit 2 cannot retroactively block it, but stderr is fed back to Claude as a system reminder Claude can react to in the next turn.
The cleaner alternative is structured JSON on stdout, exit 0. The shape differs between events. For PreToolUse:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "tenant_id missing from query payload"
}
}
For PostToolUse, decision control lives at the top level (no permissionDecision, since the tool already ran):
{
"decision": "block",
"reason": "query returned 10k rows; refusing to forward to model"
}
PreToolUse JSON gates the call; PostToolUse JSON shapes how Claude sees the result. Pick the right one for the event.
Do not block the session on slow MCP servers
A logging hook that writes to disk is microseconds. A logging hook that POSTs to a remote analytics endpoint is hundreds of milliseconds, every tool call. By default a hook is synchronous: the tool call waits for the hook to finish (PreToolUse) or the next turn waits for it (PostToolUse). Use async: true for fire-and-forget:
{
"type": "command",
"command": ".claude/hooks/post-to-analytics.sh",
"async": true
}
Async hooks run in the background; their exit code does not affect the tool call. Use asyncRewake: true when you want the hook to wake Claude with its stderr if it later exits 2 (useful for “background linter found issues” patterns).
Footguns
The matcher is exact-match by default and only switches to regex when you use a regex character. A matcher containing only letters, digits, underscores, and | stays in exact-match (or exact-list) mode: mcp__postgres__query|mcp__postgres__schema matches those two tools exactly. The moment you add ., *, (, or another regex metacharacter, the whole field is parsed as a JavaScript regex. Mixing the two modes silently loads the wrong rule: mcp__postgres__* is regex (* is a regex metacharacter), and it matches mcp__postgres_ followed by zero or more underscores, which is almost never what you wanted. Use .* when you mean “any tail” and stay aware of which mode your matcher is in.
PostToolUse cannot block, even with exit 2. The tool already ran. Exit 2 surfaces stderr to Claude as a system reminder, but the side effect is done. If you need to refuse a query before it executes, that is PreToolUse with exit 2 or a JSON permissionDecision: deny. The PostToolUseFailure event fires only when the tool call itself returned an error; do not confuse it with “my PostToolUse hook errored”, which is a different concept.
A flaky MCP server makes synchronous hooks the bottleneck. If your hook runs a 200ms curl and the endpoint times out at 30s, every MCP tool call adds up to 30s of latency before Claude continues. Set async: true on any hook whose output Claude does not need before the next step. Reserve synchronous hooks for security checks and validation that genuinely must complete first.
Hooks run in parallel, not as a sequential chain. A common mistake is writing hook B assuming hook A already redacted, normalized, or validated the input. Multiple matching hooks for one event run concurrently; each one sees the original tool input. Their decisions are merged: any deny blocks the call. If you need ordering (redact-then-log), do it inside one hook script, not by listing two handlers in the array.
Hook commands inherit your shell environment. A hook is bash running with your $PATH, $HOME, and every secret in printenv. A buggy hook can cat .env as readily as a Bash tool call can. Treat hook scripts as security-sensitive: commit them to git in .claude/hooks/, code-review changes, and never curl | bash an external script as a hook command.
When NOT to use an MCP hook
- You only need to log read-only calls. The MCP server is the better place: it sees the actual SQL or HTTP request, not just the args Claude passed. Server-side logging is one place; client-side hooks duplicate it without adding signal.
- The check belongs in the MCP server’s tool definition. A
tenant_idprecondition enforced server-side runs before any client (Claude or otherwise) can hit the database. A client-side hook only protects this client. - You are about to register five synchronous handlers. Hooks run in parallel, but the tool still waits for the slowest synchronous one before continuing. Five 200ms hooks set the floor at 200ms, every call. Combine related logic into one handler and move logging-only work to
async: true. - The MCP server is called occasionally. A hook that fires once a day on
mcp__notion__create_pageis not paying for the config maintenance burden. Hooks earn their keep on tool calls that fire dozens of times per session.