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, while mcp__lab__.* fired 42 of 42, and the same bare mcp__lab passed to --allowedTools approved every call it covered. Two more strings stayed silent: an if condition of mcp__lab (0 of 42), and the documented fix mcp__lab__.* itself once the same server was bundled in a plugin (0 of 9).
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. Either way the hook sits behind a matcher, and the natural thing to type there is the server's prefix, mcp__github or mcp__memory, because that is how a permission rule names a whole server. Claude Code's hooks reference says that string does nothing: it "is compared as an exact string and matches no tool". A matcher that matches nothing doesn't break anything you can see. The settings file loads, the session runs, and the hook just never starts. We wanted to see that happen, and to see which other plausible strings behave the same way, so we built a three-tool MCP server, registered 22 hook groups against it side by side, and counted which ones fired for which tool.
Everything below ran on 2026-10-05 between 16:24 and 16:39 UTC with Claude Code 2.1.289 (claude --version) and claude-opus-5-5 (selected with --model opus). The documentation quotes come from https://code.claude.com/docs/en/hooks and hooks.md, permissions.md, mcp.md and cli-reference.md on the same site, plus the Claude Code CHANGELOG.md on GitHub, all fetched between 16:20 and 16:25 UTC the same day.
The hooks page sorts matchers by the characters they contain. Its table has three rows, quoted as fetched:
"*", "", or omitted: "Match all", which "fires on every occurrence of the event"._, -, spaces, ,, and |": "Exact string, or list of exact strings separated by The next paragraph adds that a regular-expression matcher "is tested with JavaScript's RegExp.prototype.test, which succeeds on a match anywhere in the value", and that you should wrap a pattern in ^ and $ "when you need a whole-string match". For tool events (PreToolUse, PostToolUse and three others) the value being tested is tool_name.
The MCP part is short. Tools are named mcp__<server>__<tool>, and then: "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." For plugins: "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."
Two other pages use the same strings for something else. The permissions page, under its MCP heading: "mcp__puppeteer matches any tool provided by the puppeteer server", and "mcp__puppeteer__* uses wildcard syntax and also matches all tools from the puppeteer server". So in a permission rule the bare server prefix means the whole server, and * is a glob. And the hooks page describes a second filter on each handler, the if field, as "Permission rule syntax to filter when this hook runs, such as "Bash(git *)" or "Edit(*.ts)"."
The changelog has two entries that matter here. 2.1.191: "Fixed hooks with comma-separated matchers (e.g. "Bash,PowerShell") silently never firing". 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."
That gives three grammars for one string. As a hook matcher, mcp__lab is an exact name that no tool has. As a permission rule, it is the whole server. As an if condition, the docs point you to permission rule syntax, which suggests the whole server again. The lab tests all three.
The lab directory has no git repository and no CLAUDE.md. It holds an MCP server, a hook logger, a settings file and two small JSON configs.
The server is a Node script with no dependencies that reads newline-delimited JSON-RPC on stdin and answers initialize, tools/list and tools/call. It takes a name as its argument. Started as lab it offers three tools, ping, echo and echo_upper; started as lab-two it offers only ping. Every tools/call it receives is appended to calls.log with the server name, so we had a record of which calls actually executed that does not depend on Claude Code. The --mcp-config file registers it twice:
{
"mcpServers": {
"lab": { "command": "node", "args": ["<lab>/server.js", "lab"] },
"lab-two": { "command": "node", "args": ["<lab>/server.js", "lab-two"] }
}
}
That gives four tool names: mcp__lab__ping, mcp__lab__echo, mcp__lab__echo_upper and mcp__lab-two__ping. The second server is there to test the hyphen from the 2.1.195 entry and, because lab-two starts with lab, to see whether a loose pattern written for lab also catches it. echo_upper starts with echo for the same reason.
Three more names come from a plugin. A directory called labkit holds a .claude-plugin/plugin.json naming it labkit and a .mcp.json that registers the same script under the key lab, loaded with --plugin-dir. Its tools appear as mcp__plugin_labkit_lab__ping, __echo and __echo_upper, exactly the form the docs describe.
The hook logger reads the hook's stdin, pulls out tool_name and session_id, and appends one line with its event and a label (error handling trimmed):
const [eventName, label] = process.argv.slice(2);
let raw = "";
process.stdin.on("data", (c) => (raw += c));
process.stdin.on("end", () => {
const input = JSON.parse(raw);
fs.appendFileSync(path.join(__dirname, "fires.log"),
`${new Date().toISOString()}\t${input.session_id}\t${eventName}\t${label}\t${input.tool_name}\n`);
});
.claude/settings.json has 22 matcher groups under PreToolUse and the same 22 under PostToolUse, each with one command that runs the logger with its own label. Nineteen differ only in the matcher. The other three use mcp__.* as the matcher and add an if: mcp__lab, mcp__lab__*, and mcp__lab__ping. Trimmed to three groups, the file looks like this:
{
"hooks": {
"PreToolUse": [
{ "matcher": "mcp__lab",
"hooks": [ { "type": "command", "command": "node <lab>/hooklog.js PRE m01" } ] },
{ "matcher": "mcp__lab__.*",
"hooks": [ { "type": "command", "command": "node <lab>/hooklog.js PRE m02" } ] },
{ "matcher": "mcp__.*",
"hooks": [ { "type": "command", "command": "node <lab>/hooklog.js PRE i01", "if": "mcp__lab" } ] }
]
}
}
(In the real file node and the script are absolute paths; <lab> stands for the lab directory.) The matchers cover what the docs list and what we would expect people to type: the bare prefix, the documented fix, one exact tool, a | list and a , list, the permission-style glob mcp__lab__*, two looser prefixes, an anchored prefix, the hyphenated server with and without __.*, the bare server key lab, the bare tool name ping, a mixed list Bash|mcp__lab, the plugin-scoped pattern, mcp__.*, and the three match-all forms *, "" and no matcher at all.
Each run was one claude -p call that asked the model to call the seven tools once each, in order, and report OK or FAILED for each one:
claude -p "$PROMPT" --setting-sources project,local \
--strict-mcp-config --mcp-config "<lab>/mcp.json" --plugin-dir "<lab>/labkit" \
--allowedTools mcp__lab mcp__lab-two mcp__plugin_labkit_lab \
--model opus --output-format stream-json --verbose --include-hook-events \
--max-turns 20 --session-id "$SID" --debug-file "runs/$NAME/debug.txt" < /dev/null
--setting-sources project,local keeps our user settings, and the hooks and plugins they enable, out of the session. --include-hook-events puts a hook_started event into the stream for every hook Claude Code starts, so we could check the logger against Claude Code's own count. CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose made the debug file include the matcher lookups. We also unset the CLAUDE* variables exported by the session we were working from, so the child runs did not inherit them.
There were three conditions:
--allowedTools gets the bare server prefixes, which is the permission-rule form the permissions page says covers a whole server.--strict-mcp-config removed, because in the allowed runs it kept the plugin's server out entirely (the stream's init event listed only lab and lab-two, and the model reported the three plugin tools as unavailable). To keep the account's claude.ai connectors from joining once strict mode was off, these runs set ENABLE_CLAUDEAI_MCP_SERVERS=false. The plugin:labkit:lab, Every run called every tool it could see exactly once, after one ToolSearch call to load the deferred MCP tools (one run made a second ToolSearch call that found nothing). That makes 65 MCP tool calls that reached PreToolUse: 42 to the lab server's three tools, 14 to lab-two, and 9 to the plugin's copy. In every one of the 14 runs, the number of lines the logger wrote equaled the number of hook_started events in the stream, 999 in total, so the log below is Claude Code's own count, not our script's.
Each cell is the number of calls for which that group's hook ran, out of the calls made to that tool, summed over all 14 runs. ping, echo and upper are the lab server's three tools, two is mcp__lab-two__ping, plugin is the plugin's three tools together, and ToolSearch is the built-in tool the model used to load the MCP tools.
matcher (PreToolUse) ping echo upper two plugin ToolSearch
mcp__lab 0/14 0/14 0/14 0/14 0/9 0/15
mcp__lab-two 0/14 0/14 0/14 0/14 0/9 0/15
lab 0/14 0/14 0/14 0/14 0/9 0/15
ping 0/14 0/14 0/14 0/14 0/9 0/15
Bash|mcp__lab 0/14 0/14 0/14 0/14 0/9 0/15
mcp__.* + if: mcp__lab 0/14 0/14 0/14 0/14 0/9 0/15
mcp__.* + if: mcp__lab__* 0/14 0/14 0/14 0/14 0/9 0/15
mcp__.* + if: mcp__lab__ping 14/14 0/14 0/14 0/14 0/9 0/15
mcp__lab__ping 14/14 0/14 0/14 0/14 0/9 0/15
mcp__lab__ping|mcp__lab__echo 14/14 14/14 0/14 0/14 0/9 0/15
mcp__lab__ping, mcp__lab__echo 14/14 14/14 0/14 0/14 0/9 0/15
^mcp__lab__echo 0/14 14/14 14/14 0/14 0/9 0/15
mcp__lab__.* 14/14 14/14 14/14 0/14 0/9 0/15
mcp__lab__* 14/14 14/14 14/14 0/14 0/9 0/15
mcp__lab* 14/14 14/14 14/14 14/14 0/9 0/15
mcp__lab.* 14/14 14/14 14/14 14/14 0/9 0/15
mcp__lab-two__.* 0/14 0/14 0/14 14/14 0/9 0/15
mcp__plugin_labkit_lab__.* 0/14 0/14 0/14 0/14 9/9 0/15
mcp__.* 14/14 14/14 14/14 14/14 9/9 0/15
* 14/14 14/14 14/14 14/14 9/9 15/15
"" 14/14 14/14 14/14 14/14 9/9 15/15
(omitted) 14/14 14/14 14/14 14/14 9/9 15/15
There is no partial cell anywhere. Every matcher either fired on every call to a tool or on none of them, in every condition, so the 14 runs are 14 copies of the same answer. PostToolUse gave the same pattern for every call that executed: 37 calls in the allowed and plugin runs, the same 37 that the server recorded in calls.log.
mcp__lab fired for none of the 65 MCP calls, and neither did mcp__lab-two, lab, ping or Bash|mcp__lab. Each of them has only exact-match characters, so each was compared as a whole name, and no tool is called mcp__lab, mcp__lab-two, lab or ping. The list form fails the same way: Bash|mcp__lab is two exact names, and neither is an MCP tool. That matches the documentation word for word. What we wanted to know was how loudly it fails.
In the transcript, the stream, and the process's stderr (empty in all 14 runs) there was no sign of it. The -p run exited 0 and the model reported its calls as usual. The only trace was in the debug file we had asked for with --debug-file, which had three lines like this in all 14 runs, written at the first tool call of the session:
[WARN] Hook matcher `mcp__lab` matches no tool (it is compared as an exact string). To match all tools from this server, use `mcp__lab__.*`. See CHANGELOG v2.1.195.
[WARN] Hook matcher `mcp__lab-two` matches no tool (it is compared as an exact string). To match all tools from this server, use `mcp__lab-two__.*`. See CHANGELOG v2.1.195.
[WARN] Hook matcher `mcp__lab` matches no tool (it is compared as an exact string). To match all tools from this server, use `mcp__lab__.*`. See CHANGELOG v2.1.195.
All three were written within a millisecond of each other, just before the first PreToolUse lookup, and the PostToolUse lookups with the same strings added none. The second mcp__lab line comes from the Bash|mcp__lab group: to make sure, we ran one more session in a separate directory whose settings held only that group, and its debug file had exactly one mcp__lab warning while the hook stayed silent on the ping call the server logged. So Claude Code does recognize the server-prefix mistake, including inside a list, and tells you the fix. It just tells the debug log, which we had to ask for with --debug-file. lab and ping matched nothing and got no warning at all; the check seems to cover only the mcp__<server> shape. We also ran claude doctor in the lab directory, the read-only diagnostics command, and it printed "No installation issues found." with nothing about hooks.
The hyphenated server is where version history matters. By the 2.1.195 changelog entry, a matcher like mcp__lab-two used to substring-match, which would have made it fire on every lab-two tool, and since 2.1.195 it exact-matches and fires on none. On 2.1.289 we measured the second half: 0 of 14. We did not run an older version, so the first half is the changelog's word, not a measurement. Read together with the docs table, a hook written as mcp__<server> for a hyphenated server may have worked before that release and stopped working after it, with nothing in the session output to show the change.
The allowed runs passed --allowedTools mcp__lab mcp__lab-two mcp__plugin_labkit_lab. All 21 calls to lab tools in those runs (4 allowed runs and 3 plugin runs, three tools each) executed and returned pong, a or B, and calls.log has all of them. In the seven runs without the flag, every MCP call came back as an error with the text Claude requested permissions to use mcp__lab__ping, but you haven't granted it yet. (with the tool's own name), and calls.log gained nothing. So in a permission rule, the bare prefix mcp__lab covered all three tools, as the permissions page says. In the same runs, the matcher mcp__lab started no hooks.
The if field was the surprise. The hooks page calls it permission rule syntax, and the permissions page says mcp__puppeteer and mcp__puppeteer__* each match a whole server. Both forms failed as if conditions: the group with matcher mcp__.* and if: "mcp__lab" fired 0 times in 42 lab calls, and the one with if: "mcp__lab__*" also fired 0 times. The debug file names the reason for each call: Skipping hook due to if condition "mcp__lab" not matching, and the same for "mcp__lab__*". A full tool name worked. if: "mcp__lab__ping" fired on 14 of 14 ping calls and on nothing else. So on 2.1.289, in this lab, if accepted the permission-rule form for one MCP tool but not the forms for a whole server. Note that the matcher in front of all three handlers matched, so the if was the only thing deciding. Unlike the matcher case, the debug log gave no warning for these, only the per-call skip lines.
The no-allow runs showed one more thing about PreToolUse. It fired for every one of the 28 denied MCP calls, with the same matcher pattern as the table, and PostToolUse fired for none of them. A PreToolUse logger counts attempts, including ones the permission system then refuses; if you want a record of calls that ran, it belongs in PostToolUse.
Two strings that look like permission-style globs fired because they happen to be valid regular expressions, and a third shows how far a loose regex reaches. mcp__lab__* is a glob in a permission rule, but a matcher with * in it goes down the regular-expression path, where it means mcp__lab_ followed by any number of underscores. Tested anywhere in the name, that is found in all three lab tool names, so it fired 42 of 42, and not on mcp__lab-two__ping, where lab is followed by a hyphen. It works, but not for the reason it looks like it does. mcp__lab* (mcp__la followed by any number of b s) and mcp__lab.* went further and fired on mcp__lab-two__ping too, 14 of 14, because both patterns match the start of that name as well. The anchored ^mcp__lab__echo fired on echo and on echo_upper, which is the docs' Edit.* example in MCP form: a leading ^ only makes it a prefix test, and only a closing $ makes it a whole-name match.
The exact forms did exactly what the docs table says. mcp__lab__ping fired on ping only. mcp__lab__ping|mcp__lab__echo and mcp__lab__ping, mcp__lab__echo each fired on ping and echo and not on echo_upper, which confirms on this version that the comma-list fix from 2.1.191 holds, space included.
mcp__lab__.*, the form the docs and the debug warning both recommend, fired on all 42 lab calls and on nothing from lab-two, because the double underscore after the server name stops the prefix from running into lab-two. But it fired on none of the 9 calls to the plugin's copy of the same server, whose tools are mcp__plugin_labkit_lab__*. Only mcp__plugin_labkit_lab__.*, mcp__.* and the three match-all forms caught those. This is the documented plugin behavior. What it means in practice is that the right matcher depends on how the server was installed, and the same server can be installed both ways.
*, "" and an omitted matcher were identical: every MCP call plus all 15 ToolSearch calls. mcp__.* caught all 65 MCP calls and no built-in tool.
The one-line fix is the one the warning already prints. Wherever a tool-event hook says "matcher": "mcp__<server>", make it "matcher": "mcp__<server>__.*", keeping the double underscore so it can't run into a server whose name starts the same way. If the server comes from a plugin, write mcp__plugin_<plugin>_<server>__.* instead; the bare server key will not match. If you want one hook on one tool, the plain exact name is the tightest form there is. If a list is easier to read, | and , both worked.
To find the dead matchers you already have, the cheapest check we found is the one that produced the warnings above. Run one claude -p session that makes at least one tool call, with --debug-file, and search the file for matches no tool. It caught every mcp__<server>-shaped matcher in this lab, including one inside a list. It did not catch lab or ping, so it is a check for the server-prefix mistake, not a general one.
For if, don't rely on the server-wide permission forms on this version. Put the server filter in the matcher (mcp__lab__.*) and use if for a single tool name, or drop it.
And if a hook exists to keep a record of what a server did, register it on PostToolUse and check it against the server's own log once. Here those two counts agreed at 37, while PreToolUse also counted the 28 calls that never ran.
You need Node and Claude Code; the server has no dependencies. The request loop of server.js, with the tool definitions, the calls.log line and the error replies cut out:
const serverLabel = process.argv[2] || "lab";
const tools = serverLabel === "lab-two" ? fullToolSet.slice(0, 1) : fullToolSet;
const send = (message) => process.stdout.write(JSON.stringify(message) + "\n");
readline.createInterface({ input: process.stdin }).on("line", (line) => {
if (!line.trim()) return;
const request = JSON.parse(line);
if (request.id === undefined) return;
if (request.method === "initialize") {
return send({ jsonrpc: "2.0", id: request.id, result: {
protocolVersion: request.params?.protocolVersion ?? "2025-06-18",
capabilities: { tools: {} },
serverInfo: { name: serverLabel, version: "1.0.0" } } });
}
if (request.method === "tools/list") return send({ jsonrpc: "2.0", id: request.id, result: { tools } });
if (request.method === "tools/call") {
const output = runTool({ name: request.params?.name, args: request.params?.arguments ?? {} });
return send({ jsonrpc: "2.0", id: request.id, result: { content: [{ type: "text", text: output }] } });
}
});
Then the steps:
mcp.json (two servers, above) and the labkit plugin directory (.claude-plugin/plugin.json plus a .mcp.json registering the same script under .claude/settings.json with one group per matcher you want to test under PreToolUse and PostToolUse, each calling the logger with its own label. claude -p command above, with --plugin-dir and fires.log by label and tool, and compare it with the hook_started events in the stream and with calls.log. grep "matches no tool" runs/*/debug.txt and grep "Skipping hook due to if condition" runs/*/debug.txt.
The prompt, as sent:
This is a plumbing test of MCP tools. Call each of the following tools exactly once, one call at a
time, in this order. Do not call any other tool, except ToolSearch if you need it to load these tools.
1. mcp__lab__ping
2. mcp__lab__echo with text "a"
3. mcp__lab__echo_upper with text "b"
4. mcp__lab-two__ping
5. mcp__plugin_labkit_lab__ping
6. mcp__plugin_labkit_lab__echo with text "c"
7. mcp__plugin_labkit_lab__echo_upper with text "d"
If a tool is missing, denied or fails, do not retry it; move on. After the last one, reply with one
line per tool: its name, then OK or FAILED.
Every run was headless. We did not open the interactive /hooks menu or the in-session /doctor to see whether either shows the matcher warning. We did not run a version older than 2.1.195, so the claim that hyphenated server matchers used to fire is the changelog's. We tested PreToolUse and PostToolUse only, not PostToolUseFailure, PermissionRequest or PermissionDenied, which the docs say match on the same tool name, and not the Elicitation events, whose matcher is the server name rather than the tool name. In if we tried three forms and no parameter form. We did not test a single pattern meant to cover both the plugin copy and the direct copy of a server, and we did not test HTTP or MCP-tool hook handlers, Windows, or a model other than claude-opus-5-5.
Fourteen claude -p runs for the table on 2026-10-05 between 16:24 and 16:32 UTC, plus the one-group check at 16:38, Claude Code 2.1.289 on macOS, claude-opus-5-5, Node 22 for the server and the logger. Four allowed runs, seven runs without an allow rule, three plugin runs. In those fourteen, 65 MCP tool calls reached PreToolUse, 37 executed, and the server logged the same 37. The logger wrote 999 lines and the streams carried 999 hook_started events. Each run took 18 to 38 seconds. The reported cost was $0.617 for the fourteen and $0.675 with the check run.
The whole lab is a 61-line server, a 20-line logger and one settings file, and the fourteen runs behind the table cost $0.617, so it is cheap to rerun against your own matchers before trusting them.
If you have a hook matcher that turned out to match nothing, or a version where if: "mcp__<server>" does match, post the string and your Claude Code version in the comments below.