BuildNot yet confirmed elsewhere1 publisher3 min readPublished
Claude Code hook matchers written as a bare MCP server prefix silently match no tool
Claude Code 2.1.289 hooks matched on the bare MCP prefix mcp__lab fired 0 times in 42 tool calls, a test published on dev.to found. Nothing errors, so a hook meant to log or block those calls leaves an empty log that looks like a quiet server.
The Engineer · Build desk
What happened
- Appending .* to get mcp__lab__.*, the fix the hooks reference documents, fired the hook on all 42 calls to the standalone server.
- Passed to --allowedTools as a permission rule, the same bare mcp__lab string approved every call it covered.
- Written as the hook handler's if condition, mcp__lab also fired 0 of 42 times.
- Once the same server was bundled inside a plugin, the documented mcp__lab__.* matcher fired 0 of 9 times.
Why it matters
- contradiction The hooks page calls the if field permission rule syntax, where a bare prefix means a whole server, so the docs promise a server-wide filter that the tested release did not apply.
- decision Packaging an MCP server as a plugin renames its tools, so every hook matcher written for the standalone server has to gain the plugin segment in the same change.
- exposure Teams whose bare hyphenated matchers, such as mcp__brave-search, fired by accident before 2.1.195 lost that coverage on upgrade, and the only symptom is a hook that stops running.
Claude Code decides how to read a hook matcher from the characters in it [10]. A string made only of letters, digits, underscores, hyphens, spaces, commas and pipes is an exact name, or a list of exact names [10]. Any other character makes it a JavaScript regular expression, unanchored [10]. For tool events the value tested is tool_name, and MCP tools are named mcp__<server>__<tool> [11][12]. So mcp__lab is compared whole, and no tool has that name [10][12]. The hooks reference says so directly: the bare prefix "is compared as an exact string and matches no tool" [6].
Appending .* adds a character outside the exact set [6][10]. The pattern then runs through RegExp.prototype.test, and that test succeeds on a match anywhere in the value [11]. Plugin-bundled servers get a scoped segment instead: mcp__plugin_<plugin-name>_<server-name>__<tool> [13]. In that name mcp__ is followed by plugin_, so the substring mcp__lab__ never occurs and the unanchored test finds nothing [20]. The docs warn that a matcher written against the bare server key "never fires for these tools" [13]. By the same naming rule, we'd expect mcp__plugin_<plugin-name>_lab__.* to cover the bundled copy [21].
The permissions page reads the same string another way: "mcp__puppeteer matches any tool provided by the puppeteer server" [14]. The --allowedTools result follows from that rule [3]. The hooks page describes each handler's if field as "Permission rule syntax to filter when this hook runs" [15]. In our view the if result is the one to take upstream. The matcher and plugin failures follow rules the docs state, and this one contradicts them [4][15].
Hooks on an MCP server usually exist to log every call or to check a call before it runs, according to the writeup [18]. A matcher that matches nothing gives such a hook an excellent error record. The settings file loads and the session runs, but the hook never starts [7].
Matchers have gone quiet before. The 2.1.191 changelog entry fixed "hooks with comma-separated matchers" that were "silently never firing" [16]. In 2.1.195, hyphenated matchers such as mcp__brave-search stopped "accidentally substring-matching" and now exact-match, with mcp__brave-search__.* given as the way to match a whole server [17].
The counts come from 14 headless runs on 2026-10-05, using Claude Code 2.1.289 and claude-opus-5-5 [1][9]. The test server is a dependency-free Node script that reads newline-delimited JSON-RPC on stdin [19]. The writeup registered 22 hook groups side by side against its three tools and counted which fired for which tool [8]. Matching is string comparison against tool_name, so we'd expect the fire-or-not results to hold under other models [11]. They hold on other releases only while the matcher grammar stays the same, and the changelog records two changes to it, in 2.1.191 and 2.1.195 [16][17].
What to watch
- A Claude Code release that makes the if field honour a bare server prefix the way permission rules do, or docs that stop calling it permission rule syntax.
- A load-time warning for exact-string matchers that match no registered tool, the change that would make this failure visible.
- A rerun of the 22-group test on a release after 2.1.289, since matcher rules changed in both 2.1.191 and 2.1.195.
Clarity's read
What the record supports and how the coverage leans. The claims behind it follow.
Reality
- Evidence70
- Adoption
- Insufficient
- Hype gap0
- Incentives
- Insufficient
- Confidence68
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [1]
Across 14 headless runs of Claude Code 2.1.289, a hook whose matcher was the server prefix mcp__lab fired 0 times in 42 calls to that server's tools.
- [2]
A hook with the matcher mcp__lab__.* fired on 42 of 42 calls to the lab server's tools.
- [3]
The same bare string mcp__lab passed to --allowedTools approved every call it covered.
- [4]
An if condition of mcp__lab fired 0 of 42 times.
- [5]
The documented fix mcp__lab__.* fired 0 of 9 times once the same server was bundled in a plugin.
- [6]
Claude Code hooks reference: "To match every tool from a server, append .* to the server prefix. The .* is required: a matcher like mcp__memory or mcp__brave-search contains only exact-match characters, so it is compared as an exact string and matches no tool."
ReportedSupportedSource: Claude Code hooks documentation, as quoted in the dev.to writeupView cited source - [7]
A matcher that matches nothing does not break anything visible: the settings file loads, the session runs, and the hook never starts.
- [8]
The test used a three-tool MCP server with 22 hook groups registered against it side by side, counting which fired for which tool.
- [9]
The runs took place on 2026-10-05 between 16:24 and 16:39 UTC with Claude Code 2.1.289 and the model claude-opus-5-5.
- [10]
The hooks page classifies matchers by characters: "*", "" or omitted match all; a matcher with only letters, digits, _, -, spaces, commas and | is an exact string or list of exact strings; a matcher containing any other character is a JavaScript regular expression, unanchored.
ReportedSupportedSource: Claude Code hooks documentation, as quoted in the dev.to writeupView cited source - [11]
A regular-expression matcher is tested with JavaScript's RegExp.prototype.test, which succeeds on a match anywhere in the value; for tool events the value tested is tool_name.
ReportedSupportedSource: Claude Code hooks documentation, as quoted in the dev.to writeupView cited source - [12]
MCP tools are named mcp__<server>__<tool>.
ReportedSupportedSource: Claude Code hooks documentation, as quoted in the dev.to writeupView cited source - [13]
Hooks docs: "Tools from a plugin-bundled MCP server use a scoped server segment that includes the plugin name: mcp__plugin_<plugin-name>_<server-name>__<tool>. A matcher written against the bare server key never fires for these tools."
ReportedSupportedSource: Claude Code hooks documentation, as quoted in the dev.to writeupView cited source - [14]
Permissions page: "mcp__puppeteer matches any tool provided by the puppeteer server", and mcp__puppeteer__* uses wildcard syntax and also matches all tools from that server.
ReportedSupportedSource: Claude Code permissions documentation, as quoted in the dev.to writeupView cited source - [15]
The hooks page describes the if field on each handler as "Permission rule syntax to filter when this hook runs".
ReportedSupportedSource: Claude Code hooks documentation, as quoted in the dev.to writeupView cited source - [16]
Changelog 2.1.191: "Fixed hooks with comma-separated matchers (e.g. 'Bash,PowerShell') silently never firing".
- [17]
Changelog 2.1.195: "Fixed hook matchers with hyphenated identifiers (e.g. code-reviewer, mcp__brave-search) accidentally substring-matching"; they now exact-match. "Use mcp__brave-search__.* to match all tools from a hyphenated MCP server."
- [18]
A hook on an MCP server is usually written for one of two jobs: log every call to that server, or check a call before it runs.
- [19]
The test server is a Node script with no dependencies that reads newline-delimited JSON-RPC on stdin and answers initialize, tools/list and tools/call.
- [20]
In a plugin-scoped tool name of the form mcp__plugin_<plugin-name>_lab__<tool>, the substring mcp__lab__ does not occur, so an unanchored test of mcp__lab__.* finds no match.
- [21]
By the documented naming and regex rules, a matcher that includes the plugin segment, mcp__plugin_<plugin-name>_lab__.*, would be expected to match a plugin-bundled lab server's tools.
- [22]
A bare hyphenated server matcher such as mcp__brave-search that fired by substring matching before 2.1.195 matches no tool after that release.
Sources
1 independent publisher whose own reporting we read for this story.
Topics and entities
Follow any of these and your For You feed starts watching them — no settings page required.
Topics
- AI Coding AgentsFollow
- Agent hooks and guardrailsFollow