# Claude Code's hook matcher mcp__lab fired 0 times in 42 calls, though the same string worked as a permission rule

> Source: <https://dev.to/rulestack/claude-codes-hook-matcher-mcplab-fired-0-times-in-42-calls-though-the-same-string-worked-as-a-250o>
> Published: 2026-10-11 02:17:00+00:00

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):

``` js
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:

``` js
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.*
