MCP Tool Hooks in Claude Code
How to call MCP server tools directly from Claude Code hooks using type: mcp_tool — schema, substitution syntax, use cases, and production patterns.
Stop configuring. Start building.
SaaS builder templates with AI orchestration.
Problem: 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 output back to stdout. For a formatter or a security check that fires on every file write, that overhead adds up.
Quick Win: As of v2.1.118, hooks have a new type that calls MCP tools directly. Add this to .claude/settings.json to run a security scan after every file write, no subprocess required:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "mcp_tool",
"server": "semgrep",
"tool": "scan_file",
"input": { "path": "${tool_input.file_path}" }
}
]
}
]
}
}The MCP server is already running. The hook skips the shell entirely and calls into the server's RPC connection. The tool's text output goes through the same JSON decision parser as any command hook.
What type: "mcp_tool" Actually Is
Before v2.1.118, hooks had four handler types: command, http, prompt, and agent. Now there are five:
| Type | What runs |
|---|---|
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 covers every hook event, the same as command and http. The only practical caveat: SessionStart and Setup fire while servers are still connecting, so those hooks may get a "server not connected" error on first run. Subsequent runs are fine.
The Full Schema
Three fields are specific to mcp_tool hooks. The rest are shared across all hook types:
{
"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)"
}| Field | Required | Description |
|---|---|---|
server | YES | Exact name of the MCP server as configured in settings |
tool | YES | Tool name on that server |
input | no | Arguments passed to the tool. Supports ${path} substitution |
timeout | no | Seconds before the hook is canceled |
statusMessage | no | Spinner text shown while the hook runs |
if | no | Permission-rule syntax filter. Hook only fires when the full call matches |
Critical: server must exactly match the server name in your MCP configuration. A single character difference means the hook silently fails with a non-blocking error.
Input Substitution
String values in input support ${field.path} dot-notation into the hook's full event JSON. For a PostToolUse hook on a Write call, the event JSON looks like this:
{
"session_id": "abc123",
"cwd": "/your/project",
"hook_event_name": "PostToolUse",
"tool_name": "Write",
"tool_use_id": "toolu_01...",
"tool_input": {
"file_path": "/your/project/src/api.ts",
"content": "..."
},
"tool_response": { "filePath": "/your/project/src/api.ts", "success": true },
"duration_ms": 142
}So "${tool_input.file_path}" resolves to /your/project/src/api.ts. Any field in that object is reachable. The duration_ms field was added in v2.1.119, one release after mcp_tool shipped.
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.
The decision fields work the same as any hook:
{
"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. The tool already ran, so this is advisory, not preventative. 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.
Why This Matters vs. Shell Command Hooks
There are 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 doesn't 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 isn't installed, when ~/.zshrc prints something to stdout on non-interactive shells. MCP tool hooks bypass all of that. The call goes straight from Claude Code to the server over the existing RPC connection.
The if Field: Scope Your Hooks
Without if, a hook fires on every event that matches the matcher. With if, the hook process only spawns when the full tool call (name and arguments) matches the permission rule syntax:
{
"type": "mcp_tool",
"server": "semgrep",
"tool": "scan_file",
"if": "Edit(*.py|*.ts|*.js)",
"input": { "path": "${tool_input.file_path}" }
}This hook never runs on .md or .json files. On a project with heavy documentation edits, that's a real performance difference.
Pattern 1: Security Scanning on Every Write
A security MCP server that accepts a file path and returns findings. Block Claude if it finds something:
{
"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..."
}
]
}
]
}
}If the MCP tool returns a finding, structure the response as:
{
"decision": "block",
"reason": "Semgrep finding: [description of issue at line N]"
}Claude gets the block message and reworks the file. The scan runs on the server's cached ruleset, not a fresh subprocess parse.
Pattern 2: Stop Hook with External Verification
A Stop hook that calls a Linear or Jira MCP to check whether the related ticket is actually closed before allowing Claude to declare done:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "mcp_tool",
"server": "linear",
"tool": "get_issue_status",
"input": { "issue_id": "${tool_input.issue_id}" }
}
]
}
]
}
}The MCP tool returns the ticket status. If it comes back as In Progress, the response JSON should carry decision: "block" and a reason. Claude keeps working.
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 doesn't check 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. A Sentry MCP handles the lookup:
{
"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 along 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. The CLAUDE_AGENT_NAME environment variable identifies the current agent. A PreToolUse hook calls the policy server:
{
"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}"
}
}
]
}
]
}
}The policy server holds the full authorization map. Update the server once and every agent in every project inherits the new rules, without touching a single settings.json.
Pattern 6: MCP Tool Hooks in Agent Frontmatter
Hooks don't have to live in settings.json. They can sit 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.
Elicitation Control
The Elicitation event fires when an MCP server requests user input mid-task. An mcp_tool hook can auto-answer known prompts by calling a secrets manager:
{
"hooks": {
"Elicitation": [
{
"matcher": "my-db-server",
"hooks": [
{
"type": "mcp_tool",
"server": "secrets-manager",
"tool": "get_credential",
"input": { "key": "${elicitation.field_name}" }
}
]
}
]
}
}Predictable credential prompts resolve automatically. The task runs without interruption.
MCP Servers Worth Pairing With Hooks
Not every MCP server makes sense as a hook target. The ones with the best fit are tools that need to fire on specific events without user intervention:
| 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, TaskCompleted | Verify ticket status, update on completion |
| Context7 | UserPromptSubmit | Auto-fetch live docs for mentioned libraries |
| ElevenLabs | Stop, Notification | 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. The workaround is using exit 2 plus stderr for critical messages from PostToolUse hooks targeting MCP tool calls. The blocking pattern works; advisory injection does not.
MCP Tool Hooks Are 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.
The pipeline is now complete. PreToolUse validates. PostToolUse formats and scans. PostToolBatch runs tests. Stop verifies with real external data. Every step can be an MCP tool invocation, and none of them require a shell script.
Stop configuring. Start building.
SaaS builder templates with AI orchestration.