Correction (2026-08-14): the main claim in this post is wrong.
I wrote that PreToolUse hooks stop firing for MCP tool calls after the hook returns a deny.
They don't. The hook fired every time. What failed was my own hook script, which crashed
on MCP payloads. This is not a Claude Code bug.
I re-ran the same setup (Claude Code 2.1.227, same machine) with a hook that writes one
unconditional line before it parses stdin:
| stage | count |
|---|---|
| hook invoked | 10 (including all 3 MCP calls) |
| payload parsed | 10 |
| row written | 7 (non-MCP only) |
| exception | 3 (all mcp__ollama__summarize_file) |
The exception:
UnicodeEncodeError: 'utf-8' codec can't encode character '\udc86'
in position 353: surrogates not allowed
The cause has nothing to do with Claude Code or MCP. Python on Japanese Windows decodes
piped stdin as cp932, which mangles UTF-8 Japanese text into lone surrogates. Here is the
whole thing without Claude Code involved:
$ echo '{"x":"順"}' | python -c "import sys; print(repr(sys.stdin.read()))"
'{"x":"�\udc86"}\n'
My hook logged tool_input. For Read that is just an ASCII file path. For the MCP call the
model passed a Japanese instruction as an argument, so only MCP payloads contained non-ASCII.
This is not MCP-specific at all — any tool whose arguments contain non-ASCII would do it.
Changing sys.stdin.read() to sys.stdin.buffer.read().decode("utf-8") — one line — makes
the MCP rows appear, 3 sessions out of 3.
Why it looked deny-dependent: without a deny the model just reads the file and stops, so it
never calls the MCP tool at all. MCP calls only happened in sessions that had a deny, so those
were the only sessions missing rows. The deny wasn't the cause; it was what made the model
reach for the MCP tool.
I checked whether Claude Code was sending malformed bytes. It wasn't — I captured the raw
stdin with sys.stdin.buffer and all six payloads are strictly valid UTF-8. The 0x86 byte
is the third byte of \xe9\xa0\x86, U+9806 「順」.
No issue was filed against anthropics/claude-code, and none should be. The body below is
preserved as published, with each wrong claim struck through and corrected inline.
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 aCorrection: the hook fired every time. The missing rows were my own hook crashing on cp932-mangled stdin (see the correction above).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.Correction: what the repro reproduces is not a hook failure — it's thesys.stdin.read()bug inside the repro's ownhook.py. - 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.
Correction: the missing row is caused by line 2 of this very hook.py. On Japanese Windows, Python decodes piped stdin as cp932, so an MCP argument containing Japanese arrives as lone surrogates and the write dies with UnicodeEncodeError. Replace sys.stdin.read() with sys.stdin.buffer.read().decode("utf-8") and every row appears.
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.
Correction: this falsification condition was in fact met. Any non-ASCII argument drops the row, deny or no deny. My no-deny trials all logged fine only because their arguments happened to be pure ASCII.
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
Correction: this section's conclusion and the control matrix below are retracted. The deny was a confounder, not the cause. Without a deny the model just reads the file and never calls the MCP tool, so MCP calls (= Japanese arguments = hook crash) only existed in deny sessions. The correct observation is the table in the correction above: 10/10 hook invocations, 3 exceptions, all of them MCP calls.
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. Correction: the behavior matched the docs. Hooks fired on every MCP call.
Implications
Correction: this section's premise (hooks not firing) is wrong, so both implications below are retracted. What survives, for a different reason: when a hook script crashes, auditing and enforcement both silently disappear — exit status ignored, no row, no warning.
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. Correction: these questions dissolved along with their premise.
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 (4)
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.
Update: I owe you a correction. The split I reported between "tool executed" and "hook
observed it" was real, but the cause wasn't Claude Code — it was my hook crashing on the MCP
payload. Python on Japanese Windows read piped stdin as cp932, turned the model's Japanese
argument into lone surrogates, and the write threw UnicodeEncodeError. The hook fired every
time; it just died before logging. Details and the one-line fix are in the correction at the
top of the post.
Your point about layering survives it, though, and I think it survives stronger. My audit path
failed without the capability path failing — which is exactly what you said should be assumed
possible. It just failed because of my own code rather than the client's. The canary you
described (one synthetic allowed call, one synthetic denied call, correlate receipts) would
have caught this immediately, because it checks that the instrument produces output at all
instead of reading an empty log as "nothing happened."
The falsification section makes this worth more than most bug reports, you told readers exactly which observation would kill the claim. If "Once a PreToolUse hook returns a deny anywhere in the session, hooks stop firing for subsequent MCP tool calls" holds beyond Windows, that is a real policy-bypass class for anyone enforcing controls through hooks alone. You list "whether slipping one allow in after the deny brings it back" among the questions you didn't chase - that is the follow-up I'd run first, since it decides whether this is a sticky-state bug or a full policy bypass. And did you file it against anthropics/claude-code? If there is an issue number it is worth adding to the post so people can track a fix, the five-minute repro is already 90% of a perfect report. Until then the practical mitigation your own data supports: keep enforcement in permissions.deny, which is a separate layer from hooks, and treat PostToolUse as the audit source of record, since it kept firing in your runs when PreToolUse went blind.
You asked which follow-up I'd run first, and whether I'd filed an issue. Both answers changed
the post.
I ran your test — the allow-after-deny probe — and it never got a chance to matter, because
the premise collapsed. I instrumented the hook to write one unconditional line before parsing
stdin, and the hook turned out to be firing on every MCP call. 10 invocations, 10 parsed,
7 rows written, 3 exceptions — and the 3 exceptions were exactly the 3 MCP calls.
My hook logged
tool_input, and Python on Japanese Windows decodes piped stdin as cp932, sothe Japanese string the model passed to the MCP tool came through as lone surrogates and
killed the write. Reproducible with no Claude Code involved:
One-line fix (
sys.stdin.buffer.read().decode("utf-8")) and the MCP rows show up, 3/3.The deny looked causal because without it the model just reads the file and never calls the
MCP tool — so MCP calls only existed in deny sessions, and those were the only sessions with
missing rows.
So: no issue filed, and none is warranted. I've put a correction at the top of the post.
Your instinct about the enforcement layer still holds, for a different reason than I gave.
When my hook crashed it also stopped enforcing, silently — exit status ignored, no row,
no warning. "Treat PreToolUse as advisory telemetry, enforce at the
MCP server or sandbox" is the right call regardless of whose bug it was. Your startup-canary
idea would have caught this in one run, which is more than my three independent sources did.