TL;DR
- I tried to cut Claude Code's token use by having a
PreToolUsehookdenyRead and redirect the model to a local LLM summary tool. - The hook log contained not a single row for the local summary tool. But the tool was being called. Once a
PreToolUsehook returns adeny, hooks stop firing for the rest of that session's MCP tool calls (built-in tools are unaffected). Confirmed on Claude Code 2.1.227 / Windows 11 Home, 2026-08-12. - There's a five-minute repro below. Every claim in this article can be checked by running it, without taking my word for anything.
- The feature itself is a no-go. Local summaries invent facts, so the model either re-reads the original to verify (which cancels the savings) or skips verification and ships a wrong answer. That's structural, not a matter of model size.
Minimal repro
Here first. Any stdio MCP server will do.
Project .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{ "matcher": "*", "hooks": [
{ "type": "command", "command": "python /abs/path/hook.py" }
]}
]
}
}
hook.py — logs every call unconditionally, before any branching, and denies only Read:
import json, sys
p = json.loads(sys.stdin.read())
open("log.jsonl", "a").write(p.get("tool_name", "") + "\n") # unconditional
if p.get("tool_name") == "Read":
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse", "permissionDecision": "deny",
"permissionDecisionReason": "use mcp__<your>__<tool> instead"}}))
In one session, get Read denied, then call the MCP tool:
claude -p "First try reading foo.md with Read. If it's blocked, use mcp__<your>__<tool>" \
--allowedTools "Read,Grep,Glob,Bash,mcp__<your>__<tool>"
log.jsonl will show Read and the built-ins, but no row for the MCP tool. The MCP server's own request log shows the call went through.
What would falsify this: finding a case where the MCP tool is called with no prior deny and the hook row is still missing. Then the cause is something other than the deny.
What I wanted
I wanted to cut Claude Code's token consumption. On a flat-rate plan the bill doesn't change, but the rate limit does, and my nightly cron jobs eat into it.
The idea: hand file reads off to a local model. When Read pulls in a big file, the content stays in context and gets billed on every following turn. Summarize it with Ollama at the entrance and that recurring cost disappears.
I decided to do it with hooks. A hook is an external script that runs right before a tool executes; the PreToolUse kind can return a deny that stops the tool and hands a reason string back to the model. I used that to block Read and put "use mcp__ollama__summarize_file instead" in the reason. That Ollama summary tool is wired in over MCP, so it's an external tool, a separate class from built-ins like Grep. That distinction matters later.
I didn't know whether the model would call the local tool on its own, so the first thing to measure was whether a denied Read actually led to the local summary being used.
The measurement was broken
I wrote five fixtures (deploy notes, a 120-line changelog, a config reference, an incident report, an API spec) and ran trials headless with claude -p.
The hook log had no rows for the local summary tool. Read denials were there. Grep was there. ToolSearch was there. Only the summary tool was missing. It looked like the redirect had failed completely.
Except the model's answers said otherwise:
Processed locally with Ollama.
The Ollama summary misreported the range as "Jan–Sep"; checking the tail showed it actually ends in March.
It says it used the summary. It comments on the content of what it used. The log says it never called the tool. One of them is wrong.
I looked at a third record: the Ollama server log. Throughout the trial window, /api/generate was being hit repeatedly.
[GIN] 2026/08/12 - 03:32:17 | 200 | 22.44s | 127.0.0.1 | POST "/api/generate"
[GIN] 2026/08/12 - 03:33:20 | 200 | 21.20s | 127.0.0.1 | POST "/api/generate"
...
The model called the tool, the server answered, and the hook passed it through without logging it.
An empty hook log has three possible causes: the hook never ran, it ran but threw after the deny branch and failed to write, or it wrote and the write got lost. The logging line sits before any branching, and the same sessions do contain the Read denial and the ToolSearch call — so "ran but failed to write" would still have left a row. That leaves "never ran."
Why the hook was blind
I changed one condition at a time and reproduced each. The conclusion first. Once a PreToolUse hook returns a deny anywhere in the session, hooks stop firing for subsequent MCP tool calls. Built-in tools (Grep, Glob) keep firing after the deny. Only MCP tools are affected, and the trigger is a prior deny.
"matcher" is the hook-config field that says which tools the hook applies to: * means all tools, a literal name means one specific tool. The last column is the observation — whether the hook managed to log that MCP call.
| matcher | deny earlier in session | hook fires on MCP call |
|---|---|---|
* |
no | fires |
* |
yes | does not fire |
* |
yes (tool named explicitly) | does not fire |
* |
no (only emitted allow JSON) | fires |
| literal name | no | fires |
The three no-deny rows fire. Only the two deny rows don't. Everything else is held constant, so the prior deny is what's doing the work.
Two more checks. First, I attached the same logging hook to PostToolUse and re-ran it. After a deny, the MCP call shows up in the PostToolUse log as mcp__ollama__summarize_file — and still not in PreToolUse. The hook system is alive; PreToolUse just isn't seeing MCP.
Second, killing a rival explanation. In this setup MCP tools are lazily loaded: the model calls ToolSearch to pull the tool in before using it. So "hooks don't fire for tools loaded mid-session via ToolSearch" would explain the same observations, and it correlates perfectly with the deny. The table alone can't separate them. So I swapped in a log-only hook that never denies, and ran a control where the model loads the tool through ToolSearch and then calls it.
16:12:47 ToolSearch
16:12:51 mcp__ollama__summarize_file ← logged
A ToolSearch-loaded MCP tool does fire the hook, as long as nothing denied earlier. The load path isn't the cause.
Scope of what I checked
- Claude Code 2.1.227, Windows 11 Home, on 2026-08-12
- No other OS or version tested; I can't say whether this is Windows-specific
- One stdio MCP server, one tool
- n=1 per cell in the table
The docs say "MCP server tools appear as regular tools in tool events," so at minimum this isn't the documented behavior.
Implications
One is observability: a hook-based audit log records nothing for MCP in any session containing a deny. You can no longer distinguish "not used" from "not logged."
The other: since PreToolUse deny is the documented way to control whether a tool may run, every MCP call after the first deny slips past that control.
Don't over-read it, though. What's bypassed is hook-level control and auditing; permission rules and confirmation prompts are a separate layer and still apply. There's no remote attack surface either — exploiting this needs local settings and a registered MCP server on the target machine. The people it hurts are the ones enforcing policy through hooks and the ones counting usage from hook logs.
The local summary itself
With the measurement fixed, here's what the summaries actually looked like. The local model was qwen2.5-coder:7b.
They invented content that wasn't in the file. The detection shows up in the model's own answers:
The Ollama summary output "error-format JSON," "pagination (next token)," and "Authorization header format," but none of these exist in the actual file. The real content is one line per endpoint.
Checking the fixture confirms it: each endpoint is a single line, Returns resource N. Auth: bearer. Rate limit: 100/min., with nothing about error formats or pagination.
In most runs the model caught this, threw the summary out, and re-read the original with Grep. But not every time. One changelog summary came back as "final value 756ms, continuing to around 2026-09." The last line of the file reads 2026-03-12 fix: adjust retry backoff to 833ms. Wrong value, wrong date. The answer carried a hedge ("ask for another method if you need the original verified exactly"), but a hedged wrong answer still went out.
That's where the idea died. Verify the summary and the savings go into verification; skip verification and wrong answers get through.
I only tested 7B against five fixtures, and a 14B or 32B would fabricate less. But the reason this fails isn't accuracy, it's structure. A model that doesn't trust the summary reads the original to check, and that verification cost cancels the savings. A model that trusts it answers from degraded input and takes on the error risk instead. Better accuracy shrinks the fabrication, but it just turns into a judgment call about whether the summary is trustworthy enough to skip checking — which is not the same as the inserted layer paying for itself.
Verdict
I dropped the feature. Three reasons.
I wouldn't use it myself. Trading answer quality against the reason I use a frontier model in the first place is backwards.
There's a substitute inside the product. If you're about to hit the rate limit, switch to Sonnet or Haiku with /model. Quality stays frontier-grade and no extra tooling is needed.
It doesn't save anything. Verify and the cost comes back; skip verifying and you ship errors.
Some questions I didn't chase: whether a fresh session restores the hook, whether slipping one allow in after the deny brings it back, whether any of this holds outside Windows.
A note on how this article was written
I rewrote the numbers three times before publishing.
The first version reported a redirect success rate of 0% — a figure computed from the very log I had just shown to be broken, and I used it without hesitating. The second time I corrected that figure for one model and considered the job done. The third time, I trusted a log file's name and quoted one model's words as another's: I'd launched the trials without --model, so switching the session's model mid-run mixed two models into one file. I noticed the mixing while it was happening, and then trusted the filename anyway when it came time to tally things up.
All three were caught by someone else pointing at them. The common thread is that I stopped checking the moment a coherent explanation was available. The number matched the quotes, the filename matched my expectation, and that was enough to stop.
So I removed my own aggregate numbers from this article. What's left is whether a row exists in a log, plus specific cases checked against the original file. The repro sits at the top for the same reason: run it, and you can verify this article's claim without trusting my arithmetic.
A broken instrument returns zero. Zero reads as "it didn't happen," which draws far less suspicion than a strange value would. The moment the numbers agree with what you expected is probably the most dangerous one.
Top comments (1)
Excellent repro—and the split between “tool executed” and “hook observed it” is the important result.
Until this is fixed, I would treat PreToolUse as advisory telemetry for MCP, not as the authoritative policy boundary. If a tool must be blocked, enforce that at the MCP server, credential scope, filesystem/network sandbox, or an explicit proxy. A client-side hook that can silently disappear cannot be the only control.
For teams that still rely on hooks, a startup canary could make the failure visible: invoke one synthetic allowed call and one synthetic denied call, correlate each MCP server request ID with the expected pre/post-hook receipts, and disable MCP or revoke its session credential if a request executes without the pre-hook receipt. That is much safer than interpreting an empty hook log as “tool not used.”
The next useful test matrix seems to be: fresh vs reused sessions, allow→deny→MCP vs deny→multiple MCP calls, multiple stdio/SSE servers, eagerly exposed vs ToolSearch-loaded tools, and Windows/macOS/Linux across versions. Stable request/trace IDs would also be stronger evidence than timestamps alone.
This is a good example of why policy and observability should be separate layers: the audit path can fail without the capability path failing.