DEV Community

speedy_devv
speedy_devv

Posted on

MCP Tool Hooks in Claude Code

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}" }
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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)"
}
Enter fullscreen mode Exit fullscreen mode

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."
}
Enter fullscreen mode Exit fullscreen mode

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..."
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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}" }
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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}" }
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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}"
            }
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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."
---
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
a3e_ecosystem profile image
A3E Ecosystem

Great coverage of the mcp_tool type — 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_tool hooks 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 if field also pairs well with CLAUDE.md directory-scoped files — a backend/CLAUDE.md targeting src/api/ can be mirrored by if: "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.