Your hooks run shell scripts. Every time a hook needs to call an MCP server, it spawns a subprocess, wires up transport, handles auth, parses the response, and formats JSON back to stdout. For a security check that fires on every file write, that overhead adds up fast.
As of Claude Code v2.1.118, there is a cleaner path. Hooks have a new type that calls MCP tools directly. No subprocess. The MCP server is already running. The hook calls straight into its RPC connection.
Add this to .claude/settings.json to run a security scan after every file write:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "mcp_tool",
"server": "semgrep",
"tool": "scan_file",
"input": { "path": "${tool_input.file_path}" }
}
]
}
]
}
}
That is the whole thing. The tool's text output goes through the same JSON decision parser as any command hook. No shell, no PATH problems, no jq dependency.
What type: "mcp_tool" actually is
Before v2.1.118, hooks had four handler types: command, http, prompt, and agent. Now there are five:
-
command— shell subprocess (stdin/stdout) -
http— POST to a URL endpoint -
mcp_tool— direct RPC call to a connected MCP server -
prompt— single-turn LLM evaluation (Haiku default) -
agent— multi-turn subagent with Read/Grep/Glob access
The mcp_tool type works on every hook event, same as command and http. One practical caveat: SessionStart and Setup fire while servers are still connecting. Those hooks may get a "server not connected" error on first run. Subsequent runs are fine.
The full schema
{
"type": "mcp_tool",
"server": "my-mcp-server",
"tool": "tool_name",
"input": {
"arg1": "${tool_input.file_path}",
"arg2": "${session_id}"
},
"timeout": 30,
"statusMessage": "Checking...",
"if": "Edit(*.ts|*.tsx)"
}
Three fields are specific to mcp_tool hooks: server, tool, and input.
server must exactly match the server name in your MCP configuration. One character difference and the hook silently fails with a non-blocking error.
input values support ${field.path} dot-notation into the hook's full event JSON. For a PostToolUse hook on a Write call, the event includes tool_input.file_path, tool_input.content, session_id, cwd, duration_ms, and more. Any field is reachable.
if uses permission-rule syntax. "Edit(*.py|*.ts|*.js)" means the hook only fires when the matched file extension applies. On a docs-heavy project with constant markdown edits, this is a real performance difference.
Why this matters vs. command hooks
Two concrete differences, not just speed.
Stateful servers. A shell subprocess starts fresh every time. An MCP server is a live process with its own state: loaded configs, open connections, caches, accumulated session context. A linting MCP that pre-parsed your tsconfig.json on startup does not re-parse it on every file write. A command hook does.
No shell environment dependency. Command hooks fail silently when PATH is wrong, when jq is not installed, when ~/.zshrc prints something to stdout on non-interactive shells. MCP tool hooks bypass all of that. The call goes from Claude Code to the server over the existing RPC connection.
How the output is processed
The MCP tool's text content is treated exactly like a command hook's stdout. If it parses as valid JSON, Claude Code acts on the decision fields. If not, the text becomes context for Claude.
{
"decision": "block",
"reason": "Security issue found in src/api.ts: SQL injection risk on line 42."
}
Return this from a PostToolUse MCP tool hook and Claude gets the message and fixes the file. For blocking before a tool runs, use PreToolUse and return permissionDecision: "deny".
One field is exclusive to mcp_tool hooks on PostToolUse: updatedMCPToolOutput. It replaces what Claude sees as the tool's output before it enters the conversation. A running MCP server can post-process another tool's result before Claude reads it.
Pattern 1: Security scanning on every write
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "mcp_tool",
"server": "semgrep",
"tool": "scan_file",
"if": "Write(*.ts|*.py|*.js|*.go)",
"input": { "path": "${tool_input.file_path}" },
"statusMessage": "Scanning..."
}
]
}
]
}
}
The scan runs on the server's cached ruleset. Not a fresh subprocess parse on every keystroke. If the tool finds something, return decision: "block" with the finding. Claude reworks the file before continuing.
Pattern 2: Stop hook with external verification
A Stop hook that calls a Linear MCP to check whether the related ticket is actually closed before Claude declares done:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "mcp_tool",
"server": "linear",
"tool": "get_issue_status",
"input": { "issue_id": "${tool_input.issue_id}" }
}
]
}
]
}
}
Always check stop_hook_active in your Stop hook logic. The event JSON includes this field as "true" when Claude is already continuing from a previous Stop hook firing. A server that ignores this creates an infinite loop. Build the guard into the MCP tool: if stop_hook_active is "true" in the input, return empty output and exit cleanly.
Pattern 3: Production error check before stopping
After Claude finishes a feature, check whether anything new broke in staging before marking the session complete:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "mcp_tool",
"server": "sentry",
"tool": "get_new_errors_since",
"input": { "minutes": "5", "skip_if_active": "${stop_hook_active}" }
}
]
}
]
}
}
If new errors appeared in the last five minutes, the MCP tool returns them with decision: "block". Claude reads the error details and fixes the regression before stopping.
Pattern 4: Auto-inject docs before every prompt
A UserPromptSubmit hook with a Context7 MCP fetches live documentation for any library mentioned in the prompt, before Claude processes it:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "mcp_tool",
"server": "context7",
"tool": "get_library_docs",
"input": { "prompt": "${prompt}" },
"timeout": 15
}
]
}
]
}
}
Previously this required Claude to explicitly call the MCP tool. Now it happens on every prompt automatically. Claude starts with current docs instead of training data.
Pattern 5: Policy enforcement for agent teams
When running multi-agent workflows, a shared policy MCP server can enforce which agent writes to which directories:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "mcp_tool",
"server": "policy-server",
"tool": "check_write_permission",
"input": {
"agent": "${agent_name}",
"path": "${tool_input.file_path}"
}
}
]
}
]
}
}
Update the server once and every agent in every project inherits the new rules. No touching individual settings.json files.
Pattern 6: MCP tool hooks in agent frontmatter
Hooks can live in an agent's YAML frontmatter, scoped to that agent's lifecycle:
---
name: backend-developer
description: "Builds API endpoints and database logic"
hooks:
PostToolUse:
- matcher: "Write"
hooks:
- type: mcp_tool
server: semgrep
tool: scan_file
input: { "path": "${tool_input.file_path}" }
Stop:
- hooks:
- type: agent
prompt: "Verify all API endpoints have corresponding tests. Block if any are missing."
---
Each specialist agent in an orchestrated team carries its own validation logic. The backend agent scans for security issues. The frontend agent checks accessibility. Neither needs a global hook that applies to everyone.
MCP servers worth pairing with hooks
| Server | Event | What it does |
|---|---|---|
| Semgrep | PostToolUse: Write | Security scan on every write |
| Sentry | Stop | Check for new staging errors before completing |
| Linear / Jira | Stop | Verify ticket status, update on completion |
| Context7 | UserPromptSubmit | Auto-fetch live docs for mentioned libraries |
| ElevenLabs | Stop | TTS audio on task completion |
| Slack | Notification, Stop | Team alerts without curl boilerplate |
| E2B | Stop | Run generated scripts in a sandbox before marking done |
| claude-mem | PostCompact, SessionStart | Restore session context after compaction |
| n8n | TaskCompleted | Trigger an external workflow on completion |
Known issue: PostToolUse + MCP events + additionalContext
There is an open bug (GitHub issue #24788) where additionalContext from hooks gets silently dropped when the triggering event was an MCP tool call. This affects type: "command" hooks that respond to MCP tool events, not mcp_tool hooks themselves.
The distinction matters: hooks that are MCP invocations work fine. Hooks that respond to MCP tool calls and return additionalContext do not. Workaround is exit 2 plus stderr for critical messages. The blocking pattern works. Advisory injection does not.
The hook system's last missing piece
Before this, hooks were a safety net. Shell commands that could block dangerous things or run formatters. Stateless, process-local, disconnected from everything your MCP servers already know.
After: hooks are a deterministic orchestration layer. Any event, any MCP tool, full decision control, with state that persists across calls and no subprocess overhead.
PreToolUse validates. PostToolUse formats and scans. PostToolBatch runs tests. Stop verifies with real external data. Every step can be an MCP tool invocation. None of them require a shell script.
Full reference with schema, substitution syntax, and all event types: https://buildthisnow.com/blog/tools/hooks/mcp-tool-hooks

Top comments (1)
Great coverage of the
mcp_tooltype — especially the stateful server point, which gets missed in most write-ups.One pattern worth adding: CLAUDE.md + mcp_tool hooks as a two-layer enforcement model.
CLAUDE.md handles soft constraints Claude follows naturally (code style, architectural decisions, tool preferences).
mcp_toolhooks enforce the hard constraints that can't fail silently — your Pattern 1 (security scans) and Pattern 3 (staging error check) are exactly that category.The practical combination: write the rule in CLAUDE.md as intent ("never write to src/generated/"), then back it with a PreToolUse mcp_tool hook that actually blocks the write. Claude honors the CLAUDE.md 95% of the time; the hook catches the 5% where context drift introduces exceptions.
The
iffield also pairs well with CLAUDE.md directory-scoped files — abackend/CLAUDE.mdtargetingsrc/api/can be mirrored byif: "Write(src/api/*)"on your semgrep hook. Intent in the file, enforcement in the hook.Your note on the #24788 bug (additionalContext dropped on MCP-triggered events) will save someone a debugging session.