PreToolUse hooks do not fire on MCP tool calls from settings.json on Windows (PostToolUse does) — contradicts docs; #74203's Linux control test reports the opposite

Status Open
Reported on v2.1.201
Maintainer reply None cached
Activity 0 comments · opened Aug 10, 2026

Summary

On Windows, a PreToolUse hook defined in the user's settings.json with a matcher naming an MCP tool never fires on that MCP tool call, while PostToolUse hooks on the same call do fire. The documentation states MCP tools are matchable at PreToolUse like any other tool.

This is filed deliberately platform-scoped, because #74203 (Linux, CC 2.1.201, CLOSED/COMPLETED 2026-07-07) reports the opposite result for the settings.json path — see "Relationship to #74203" below. I am not claiming the behaviour is universal.

Environment

  • OS: Windows 11 Pro (10.0.26200)
  • Claude Code: 2.1.216 (original measurement, 2026-07-21) and 2.1.226 (corroborated 2026-08-10)
  • Hook source: the user's own settings.json (user scope) — not a plugin
  • MCP server: a local stdio server (sangha-bus), registered at user scope

What the documentation states

From the hooks documentation (snapshot dated 2026-05-08):

  • The matcher table lists PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied as filtering on tool name, with mcp__.* given as an example matcher value.
  • Under "Match MCP tools": "MCP server tools appear as regular tools in tool events (PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied), so you can match them the same way you match any other tool name."
  • The worked example immediately following is a PreToolUse block with "matcher": "mcp__memory__.*".

What we measure (Windows)

Test 1 — instrumented deny hook (controlled). A PreToolUse deny hook was installed in settings.json with matcher set to a literal MCP tool name (mcp__sangha-bus__bell_list). The hook emitted valid deny JSON (hand-verified by running it directly) and, before any decision logic, wrote an unconditional "fired" marker to a side-file.

Result on 2.1.216: the MCP call was ALLOWED, and the marker file was never written — i.e. the hook process did not run at all. This is a dispatch-layer no-op, not a hook that ran and failed to block.

Test 2 — bracket count. In a live session, the same MCP call produced PostToolUse hook records (two independent hooks, write-order verified) and zero PreToolUse records. Across ~20 MCP calls in that session, none received PreToolUse context injection, while Bash/Read/Grep calls almost always did.

Corroboration on 2.1.226 (weaker, stated as such). A later session's trace carries tool_complete records for mcp__sangha-bus__bus_send and mcp__sangha-bus__pulse — so PostToolUse fires on MCP — while no PreToolUse context injection reached any MCP call although Bash and Read calls in the same session did. This is observational corroboration, not a controlled replication: the two match-all PreToolUse hooks configured on that machine write no unconditional artifact, so their silence alone is not proof. Test 1 was not re-run on 2.1.226.

Expected vs actual

  • Expected: a settings.json-sourced PreToolUse hook matching an MCP tool name fires on that call, and permissionDecision: "deny" denies it.
  • Actual (Windows, 2.1.216): the hook never runs; the call proceeds. PostToolUse on the same call fires normally.

Relationship to #74203

#74203 reports that plugin-sourced PreToolUse hooks never dispatch for mcp__* matchers, and its central argument is a control test showing the settings.json path working: "Placing the identical matcher string in the user's own ~/.claude/settings.json PreToolUse block, pointed at the same underlying script logic, fires reliably and repeatably (confirmed via the script's own side effect — starting a local server — across multiple fresh Claude Code sessions)" (CC 2.1.201, Arch Linux).

That is the configuration we measure as dead. So either:

  1. the settings.json path is platform-specific (works on Linux, not Windows), or
  2. something regressed between 2.1.201 and 2.1.226 — note #74203 was fixed on 2026-07-07 and our first measurement is 2026-07-21, so a plugin-dispatch fix disturbing the settings path is worth ruling out, or
  3. it is specific to our configuration.

We have not discriminated between these. We have no Linux machine to run test 1 on. If #74203's fix touched shared MCP hook-dispatch code, (2) seems the cheapest hypothesis for you to check.

Note also that the two measurements are not equally instrumented: ours used a purpose-built unconditional fired-marker; #74203's control used an observed side effect (starting a server), which can have other causes. That does not make our result universal — one credible contradiction is exactly why this is scoped — but it may matter when reproducing.

Impact

PreToolUse is the documented place to put a deny gate. A hook that silently never runs is worse than one that errors: the guard appears installed, the config validates, nothing warns, and the protection is simply absent. We found two internal surfaces designed around this — one a production-write safety gate — that could not have worked. We have since moved that class of enforcement server-side into the MCP server itself, so this is not blocking us; we are reporting it because a silently-absent security control is a bad failure mode for anyone who trusts the documentation.

What would help

  1. Confirm whether settings.json-sourced PreToolUse dispatch for mcp__* matchers is expected to work on Windows at 2.1.22x.
  2. If it is not supported (on any platform), the "Match MCP tools" section listing PreToolUse and giving a PreToolUse worked example is the thing to correct — that section is what led us to design against it.
  3. If it is supported, a way to tell a non-firing hook from an absent one would have saved most of the investigation — the failure is currently indistinguishable from "no hook configured".

Happy to run further probes on Windows if you name the ones that would discriminate.

View original on GitHub ↗