검색
← 모듈 로드맵

Claude Code MCP 훅 matcher와 권한 규칙의 차이

발행 출처 DEV · Rulestack

클로드 코드사례전문가

나중에 보기좋아요
공유 · 인쇄
후기댓글

MCP 서버에 대한 hook은 보통 두 가지 목적 중 하나로 작성된다. 해당 서버로 가는 모든 호출을 기록하거나, 호출이 실행되기 전에 검사하는 것이다. 어느 경우든 hook은 matcher 뒤에 붙고, 거기에 자연스럽게 넣고 싶은 값은 mcp__github나 mcp__memory 같은 서버 접두사다. 권한 규칙이 전체 서버를 지칭하는 방식이 바로 그렇기 때문이다. 그런데 Claude Code의 hooks 레퍼런스는 그 문자열이 아무 일도 하지 않는다고 말한다. 즉, 그 문자열은 "정확한 문자열로 비교되며 어떤 도구와도 일치하지 않는다"고 되어 있다. 아무것도 매칭하지 않는 matcher가 눈에 보이는 문제를 일으키지는 않는다. 설정 파일은 로드되고, 세션은 실행되며, hook은 그냥 한 번도 시작되지 않는다. 우리는 그 현상을 직접 보고, 또 다른 그럴듯한 문자열들도 같은 식으로 동작하는지 확인하고 싶었다. 그래서 세 개의 도구를 가진 MCP 서버를 만들고, 그 위에 22개 hook 그룹을 나란히 등록한 뒤, 어떤 도구에서 어떤 hook이 발동하는지 집계했다.

아래 내용은 모두 2026-10-05 16:24부터 16:39 UTC 사이에 Claude Code 2.1.289(claude --version)와 claude-opus-5-5(--model opus로 선택)를 사용해 실행했다. 문서 인용은 같은 날 16:20부터 16:25 UTC 사이에 가져온 https://code.claude.com/docs/en/hooks와, 같은 사이트의 hooks.md, permissions.md, mcp.md, cli-reference.md, 그리고 GitHub의 Claude Code CHANGELOG.md에서 가져왔다.

문서가 말하는 matcher란 무엇인가

hooks 페이지는 matcher를 포함하는 문자에 따라 분류한다. 표에는 다음 세 줄이 있으며, 가져온 그대로 인용하면 다음과 같다.

  • "*", "", 또는 생략: "모두 일치", 즉 "이벤트가 발생할 때마다 발동"
  • "문자, 숫자, _, -, 공백, ,, |만 포함": "정확한 문자열, 또는 |나 ,로 구분하고 앞뒤 공백을 허용하는 정확한 문자열 목록"
  • "그 밖의 다른 문자를 하나라도 포함": "JavaScript 정규식, 비고정(unanchored)"

다음 문단은 정규식 matcher가 "JavaScript의 RegExp.prototype.test로 검사되며, 값의 어디에서든 일치하면 성공한다"고 덧붙이고, 전체 문자열 일치가 필요할 때는 패턴을 ^와 $로 감싸라고 안내한다. 도구 이벤트(PreToolUse, PostToolUse 및 그 밖의 세 가지)에서 검사되는 값은 tool_name이다.

MCP 부분은 짧다. 도구 이름은 mcp__ __ 형식으로 붙고, 이어서 다음과 같이 설명한다. "서버의 모든 도구와 일치시키려면 서버 접두사 뒤에 .*를 붙인다. .*는 필수다. mcp__memory나 mcp__brave-search 같은 matcher는 정확한 문자열만 포함하므로 정확한 문자열로 비교되며 어떤 도구와도 일치하지 않는다." 플러그인에 대해서는 이렇게 설명한다. "플러그인에 번들된 MCP 서버의 도구는 플러그인 이름을 포함하는 범위 지정 서버 세그먼트를 사용한다: mcp__plugin_ _ __. 베어 서버 키로 작성한 matcher는 이 도구들에 대해 절대 발동하지 않는다."

다른 두 페이지는 같은 문자열을 다른 의미로 쓴다. permissions 페이지의 MCP 항목 아래에는 다음이 있다. "mcp__puppeteer는 puppeteer 서버가 제공하는 모든 도구와 일치한다", 그리고 "mcp__puppeteer__*는 와일드카드 문법을 사용하며 역시 puppeteer 서버의 모든 도구와 일치한다". 즉 권한 규칙에서는 베어 서버 접두사가 전체 서버를 뜻하고, *는 glob이다. 또한 hooks 페이지는 각 handler의 두 번째 필터인 if 필드를 "이 hook이 실행되는 시점을 필터링하기 위한 권한 규칙 문법"으로 설명하며, 예시로 "Bash(git *)"나 "Edit(*.ts)"를 든다.

changelog에는 이와 관련해 중요한 항목이 두 개 있다. 2.1.191: "콤마로 구분된 matcher(예: "Bash,PowerShell")가 조용히 절대 발동하지 않던 문제를 수정". 2.1.195: "하이픈이 들어간 식별자(예: code-reviewer, mcp__brave-search)를 가진 hook matcher가 실수로 부분 문자열 매칭되던 문제를 수정 — 이제는 정확히 일치한다. 하이픈이 들어간 MCP 서버의 모든 도구와 일치시키려면 mcp__brave-search__.*를 사용하라."

즉 하나의 문자열에 대해 문법이 셋이다. hook matcher로서의 mcp__lab는 어떤 도구에도 없는 정확한 이름이다. 권한 규칙으로서의 mcp__lab는 전체 서버를 뜻한다. if 조건으로서는 문서가 권한 규칙 문법을 가리키므로, 역시 전체 서버를 뜻하는 것으로 보인다. lab 테스트에서는 이 셋 모두를 확인했다.

실험실

랩 디렉터리에는 git 저장소도 없고 CLAUDE.md도 없습니다. 여기에는 MCP 서버, 훅 로거, 설정 파일, 그리고 두 개의 작은 JSON 구성 파일이 들어 있습니다.

서버는 stdin에서 줄 단위 JSON-RPC를 읽고 initialize, tools/list, tools/call에 응답하는 의존성 없는 Node 스크립트입니다. 인자로 이름을 받습니다. lab으로 실행하면 ping, echo, echo_upper의 세 가지 도구를 제공하고, lab-two로 실행하면 ping만 제공합니다. 받아들인 모든 tools/call은 서버 이름과 함께 calls.log에 추가되므로, Claude Code에 의존하지 않고 실제로 어떤 호출이 실행되었는지 기록을 남길 수 있었습니다. --mcp-config 파일은 이를 두 번 등록합니다:

코드
{
  "mcpServers": {
    "lab":     { "command": "node", "args": ["<lab>/server.js", "lab"] },
    "lab-two": { "command": "node", "args": ["<lab>/server.js", "lab-two"] }
  }
}

그 결과 도구 이름은 네 가지가 됩니다: mcp__lab__ping, mcp__lab__echo, mcp__lab__echo_upper, mcp__lab-two__ping. 두 번째 서버는 2.1.195 항목의 하이픈을 테스트하기 위해 있고, lab-two가 lab으로 시작하므로 lab용으로 느슨하게 작성한 패턴이 이것까지 잡는지도 확인하기 위해 넣었습니다. echo_upper는 같은 이유로 echo로 시작합니다.

세 개의 이름이 더 플러그인에서 나옵니다. labkit라는 디렉터리에는 이를 labkit로 이름 붙인 .claude-plugin/plugin.json과, --plugin-dir로 로드되는 키 lab 아래 같은 스크립트를 등록하는 .mcp.json이 들어 있습니다. 이 도구들은 문서에 설명된 그대로 mcp__plugin_labkit_lab__ping, __echo, __echo_upper 형태로 나타납니다.

훅 로거는 훅의 stdin을 읽고 tool_name과 session_id를 꺼낸 다음, 이벤트와 레이블이 담긴 한 줄을 추가합니다(오류 처리는 줄였습니다):

코드
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에는 PreToolUse 아래 22개의 매처 그룹과 PostToolUse 아래 같은 22개가 있으며, 각각은 고유한 레이블로 로거를 실행하는 하나의 명령을 가집니다. 19개는 매처만 다릅니다. 나머지 3개는 매처로 mcp__.*를 사용하고 if를 추가합니다: mcp__lab, mcp__lab__*, mcp__lab__ping. 세 그룹으로 줄이면 파일은 다음과 같습니다:

코드
{
  "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" } ] }
    ]
  }
}

(실제 파일에서는 node와 스크립트가 절대 경로입니다. 는 랩 디렉터리를 뜻합니다.) 이 매처들은 문서에 나오는 것과 사람들이 입력할 법한 것들을 모두 포괄합니다: bare 접두사, 문서에 나온 수정안, 정확히 하나의 도구, | 목록과 , 목록, 권한 스타일의 glob mcp__lab__*, 두 개의 더 느슨한 접두사, 앵커가 있는 접두사, __.*가 있거나 없는 하이픈이 들어간 서버, bare 서버 키 lab, bare 도구 이름 ping, 혼합 목록 Bash|mcp__lab, 플러그인 범위 패턴, mcp__.*, 그리고 세 가지 전체 일치 형태 *, "", 그리고 매처 없음.

각 실행은 모델에게 일곱 도구를 각각 한 번씩 순서대로 호출하고, 각 호출에 대해 OK 또는 FAILED를 보고하라고 요청한 단일 claude -p 호출이었습니다:

코드
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는 세션에서 사용자 설정과, 그 설정이 활성화하는 훅과 플러그인을 제외합니다. --include-hook-events는 Claude Code가 시작한 각 훅마다 스트림에 hook_started 이벤트를 넣어 주므로, 로거를 Claude Code 자체의 카운트와 대조할 수 있었습니다. CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose는 디버그 파일에 매처 조회가 포함되게 했습니다. 또한 작업하던 세션에서 내보낸 CLAUDE* 변수들도 해제했기 때문에, 하위 실행이 그것들을 상속하지 않았습니다.

세 가지 조건이 있었습니다:

  • Allowed (4 runs): 위의 명령. --allowedTools는 bare 서버 접두사를 받는데, 권한 페이지에 따르면 이것이 하나의 전체 서버를 덮는 권한 규칙 형식입니다.
  • No allow rule (7 runs): --allowedTools 없이 같은 명령. 원래는 이 조건을 두 번만 실행해 대조군으로 쓰려 했습니다. 하지만 셸 루프에서 따옴표를 잘못 써서, 다른 두 조건용이던 다섯 번의 실행이 모드 없이 시작되었고, 그 결과 정확히 이 조건의 플래그를 갖게 되었습니다. 그래서 버리지 않고 여기로 모두 포함했습니다.
  • Plugin (3 runs): --strict-mcp-config를 제거했습니다. 허용 실행에서는 이것이 플러그인의 서버를 아예 제외했기 때문입니다(스트림의 init 이벤트에는 lab와 lab-two만 나왔고, 모델은 세 플러그인 도구를 사용할 수 없다고 보고했습니다). strict 모드를 끈 뒤 계정의 claude.ai 커넥터가 합류하지 않도록, 이 실행들에서는 ENABLE_CLAUDEAI_MCP_SERVERS=false를 설정했습니다. 그러자 init 이벤트에는 정확히 plugin:labkit:lab, lab, lab-two만 표시되었고, 그 외에는 아무것도 없었습니다.

모든 실행은 볼 수 있는 모든 도구를 정확히 한 번씩 호출했으며, 지연된 MCP 도구를 불러오기 위한 ToolSearch 호출을 한 번 한 뒤에 그랬다(한 실행에서는 아무것도 찾지 못한 두 번째 ToolSearch 호출이 있었다). 그 결과 PreToolUse에 도달한 MCP 도구 호출은 65개다: lab 서버의 세 도구에 42번, lab-two에 14번, 플러그인의 복사본에 9번. 14개 실행 각각에서 로거가 쓴 줄 수는 스트림의 hook_started 이벤트 수와 같았고, 총 999개였으므로 아래 로그는 우리가 만든 스크립트가 아니라 Claude Code 자체의 계산 결과다.

불이 켜진 표

각 셀은 해당 그룹의 훅이 실행된 호출 수이며, 그 도구에 대해 이루어진 호출 수를 기준으로 14개 실행 전체를 합산한 값이다. ping, echo, upper는 lab 서버의 세 도구이고, two는 mcp__lab-two__ping, plugin은 플러그인에 들어 있는 세 도구를 모두 합친 것이며, ToolSearch는 모델이 MCP 도구를 불러오기 위해 사용한 내장 도구다.

코드
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

어느 셀도 일부만 불이 켜진 곳은 없다. 모든 매처는 모든 조건에서 도구 호출마다 전부 실행되거나 전혀 실행되지 않았고, 그래서 14개 실행은 모두 같은 답을 14번 반복한 것과 같다. PostToolUse도 실제로 실행된 모든 호출에서 같은 패턴을 보였고, 허용된 실행과 플러그인 실행에서 나온 37개 호출이 그대로 같았는데, 이는 서버가 calls.log에 기록한 37개와 일치한다.

14개 실행에 걸친 집계의 터미널 출력: 매처 `mcp__lab`는 42번의 `lab` 호출 중 0번, `mcp__lab__.*`는 42번 중 42번, `mcp__lab`의 if 조건은 42번 중 0번, 그리고 `mcp__lab__.*`는 서버가 플러그인에 번들로 포함한 복사본에 대한 9번의 호출 중 0번
14개 실행에 걸친 집계의 터미널 출력: 매처 `mcp__lab`는 42번의 `lab` 호출 중 0번, `mcp__lab__.*`는 42번 중 42번, `mcp__lab`의 if 조건은 42번 중 0번, 그리고 `mcp__lab__.*`는 서버가 플러그인에 번들로 포함한 복사본에 대한 9번의 호출 중 0번

절대 실행되지 않은 다섯 문자열

mcp__lab는 65개의 MCP 호출 중 어느 것에도 실행되지 않았고, mcp__lab-two, lab, ping, Bash|mcp__lab도 마찬가지였다. 이들 각각은 정확히 일치하는 문자만 포함하므로 전체 이름으로 비교되었고, mcp__lab, mcp__lab-two, lab, ping이라는 이름의 도구는 없다. 목록 형식도 같은 방식으로 실패한다: Bash|mcp__lab는 두 개의 정확한 이름이지만, 어느 쪽도 MCP 도구가 아니다. 이는 문서의 설명과 한 글자도 다르지 않다. 우리가 알고 싶었던 것은 그것이 얼마나 요란하게 실패하느냐였다.

트랜스크립트, 스트림, 그리고 프로세스의 stderr(14개 실행 모두에서 비어 있었음) 어디에서도 그 흔적은 보이지 않았다. -p 실행은 0으로 종료되었고 모델은 평소처럼 호출 내역을 보고했다. 유일한 흔적은 --debug-file로 요청해 둔 디버그 파일에 있었는데, 14개 실행 모두에서 세 줄이 있었고 세션의 첫 도구 호출 시점에 이렇게 기록되었다:

코드
[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.

이 세 줄은 서로 1밀리초 이내에 기록되었고, 첫 번째 PreToolUse 조회 바로 직전이었으며, 같은 문자열을 사용한 PostToolUse 조회에서는 추가된 것이 없었다. 두 번째 mcp__lab 줄은 Bash|mcp__lab 그룹에서 나온 것이다. 확인을 위해 해당 그룹만 설정에 남아 있는 별도의 디렉터리에서 한 번 더 실행했는데, 그 디버그 파일에는 정확히 한 줄의 mcp__lab 경고만 있었고, 서버가 기록한 ping 호출에서는 훅이 조용했다. 따라서 Claude Code는 서버 접두사 실수를, 목록 안에 있을 때까지 포함해, 인식하고 수정 방법도 알려 준다. 다만 --debug-file로 요청했을 때만 디버그 로그에 알려 준다. lab과 ping은 아무것도 매칭되지 않았고 경고도 전혀 없었다. 검사는 mcp__ 형태에만 적용되는 듯하다. 또한 우리는 claude doctor를 lab 디렉터리에서 실행했는데, 이는 읽기 전용 진단 명령이며, 훅에 대해서는 아무 말 없이 "설치 문제를 찾지 못했습니다."라고만 출력했다.

14개 실행 모두에서 Claude Code가 남긴 디버그 로그 경고 발췌: 훅 매처 `mcp__lab`는 정확한 문자열로 비교되므로 어떤 도구와도 일치하지 않으며, `mcp__lab__.*`는 서버의 모든 도구와 일치하는 형식이다
14개 실행 모두에서 Claude Code가 남긴 디버그 로그 경고 발췌: 훅 매처 `mcp__lab`는 정확한 문자열로 비교되므로 어떤 도구와도 일치하지 않으며, `mcp__lab__.*`는 서버의 모든 도구와 일치하는 형식이다

하이픈이 들어간 서버는 버전 기록이 중요한 부분이다. 2.1.195 변경 로그 항목에 따르면, mcp__lab-two 같은 매처는 예전에는 부분 문자열로 매칭되었고, 그 경우 lab-two의 모든 도구에서 실행되었을 것이다. 그러나 2.1.195부터는 정확히 일치해야 하며 아무 데서도 실행되지 않는다. 2.1.289에서는 그 둘째 절반을 측정했는데, 결과는 0/14였다. 더 오래된 버전은 실행하지 않았으므로 첫째 절반은 변경 로그의 설명일 뿐 측정값은 아니다. 문서의 표와 함께 읽으면, 하이픈이 있는 서버에 대해 mcp__로 작성한 훅은 그 릴리스 이전에는 동작했을 수 있고 이후에는 동작하지 않게 되었으며, 세션 출력만으로는 그 변화를 알 수 없다.

같은 문자열, 세 가지 문법

허용된 실행에서는 --allowedTools mcp__lab mcp__lab-two mcp__plugin_labkit_lab를 넘겼습니다. 그 실행들에서 lab 도구에 대한 21번의 호출은 모두 실행되어 pong, a 또는 B를 반환했고, calls.log에는 그 모든 항목이 기록되었습니다. 플래그가 없는 7번의 실행에서는 모든 MCP 호출이 Claude requested permissions to use mcp__lab__ping, but you haven't granted it yet.라는 오류로 돌아왔고(도구의 실제 이름이 포함됨), calls.log에는 아무것도 추가되지 않았습니다. 따라서 권한 규칙에서는, 권한 페이지가 말하듯이, 앞부분 접두사 mcp__lab가 세 개의 도구를 모두 포괄했습니다. 같은 실행들에서 매처 mcp__lab는 어떤 훅도 시작하지 않았습니다.

if 필드가 예상 밖이었습니다. 훅 페이지는 이것을 권한 규칙 문법이라고 부르고, 권한 페이지는 mcp__puppeteer와 mcp__puppeteer__*가 각각 전체 서버를 매칭한다고 설명합니다. 하지만 if 조건으로는 두 형태 모두 실패했습니다. 매처가 mcp__.*이고 if: "mcp__lab"인 그룹은 42번의 lab 호출에서 0번만 발동했고, if: "mcp__lab__*"인 그룹도 0번 발동했습니다. 디버그 파일은 각 호출의 이유를 이렇게 적었습니다: Skipping hook due to if condition "mcp__lab" not matching, 그리고 "mcp__lab__*"에 대해서도 동일했습니다. 전체 도구 이름은 동작했습니다. if: "mcp__lab__ping"은 14번의 ping 호출 중 14번 모두에서 발동했고, 다른 어떤 호출에도 반응하지 않았습니다. 따라서 2.1.289의 이 실험 환경에서는 if가 단일 MCP 도구에 대해서는 권한 규칙 형태를 받아들였지만, 전체 서버 형태는 받아들이지 않았습니다. 세 핸들러 앞의 매처는 모두 일치했으므로, if가 유일한 판단 기준이었습니다. 매처 경우와 달리, 디버그 로그에는 이에 대한 경고가 없고 호출별 건너뜀 줄만 있었습니다.

허용하지 않은 실행에서는 PreToolUse에 대해서도 한 가지를 더 보여 주었습니다. 이 훅은 거부된 28개의 MCP 호출 각각에 대해 모두 발동했으며, 표와 같은 매처 패턴을 사용했습니다. 반면 PostToolUse는 그 어떤 호출에도 발동하지 않았습니다. PreToolUse 로거는 권한 시스템이 나중에 거부하는 호출까지 포함해 시도 횟수를 셉니다. 실제로 실행된 호출의 기록이 필요하다면 PostToolUse에 두어야 합니다.

발동한 문자열과 범위

권한 스타일의 글롭처럼 보이는 두 문자열은 우연히 유효한 정규식이어서 발동했고, 세 번째 문자열은 느슨한 정규식이 얼마나 멀리 뻗는지 보여 줍니다. mcp__lab__*는 권한 규칙에서는 글롭이지만, 매처에서 *가 들어간 형태는 정규식 경로로 내려가며, 이때 mcp__lab_ 뒤에 밑줄이 0개 이상 오는 뜻이 됩니다. 이름 어디에서든 찾아보면 이것은 세 개의 lab 도구 이름 모두에서 발견되므로 42번 중 42번 발동했고, lab 뒤에 하이픈이 오는 mcp__lab-two__ping에서는 발동하지 않았습니다. 동작은 하지만, 보이는 그대로의 이유로 동작한 것은 아닙니다. mcp__lab*(mcp__la 뒤에 b가 0개 이상 오는 형태)와 mcp__lab.*는 더 멀리 가서 mcp__lab-two__ping에서도 14번 중 14번 발동했습니다. 두 패턴 모두 그 이름의 시작 부분과도 일치하기 때문입니다. 앵커가 붙은 ^mcp__lab__echo는 echo와 echo_upper에서 발동했는데, 이는 MCP 형식의 문서에 있는 Edit.* 예시와 같습니다. 앞의 ^는 접두사 검사로만 만들고, 이름 전체 일치로 만들려면 닫는 $가 있어야 합니다.

정확한 형태들은 문서의 표가 말한 대로 정확히 동작했습니다. mcp__lab__ping은 ping에서만 발동했습니다. mcp__lab__ping|mcp__lab__echo와 mcp__lab__ping, mcp__lab__echo는 각각 ping과 echo에서 발동했고 echo_upper에서는 발동하지 않았습니다. 이는 이 버전에서 2.1.191의 쉼표 목록 수정이, 공백 포함해서, 그대로 유지된다는 것을 확인해 줍니다.

문서와 디버그 경고 둘 다 권장하는 형태인 mcp__lab__.*는 lab의 42개 호출 모두에서 발동했고, lab-two의 어떤 호출에서도 발동하지 않았습니다. 서버 이름 뒤의 이중 밑줄이 접두사가 lab-two로 흘러들어 가지 못하게 막기 때문입니다. 하지만 이것은 같은 서버의 플러그인 복사본에 대한 9개 호출 중 어느 것도 잡지 못했습니다. 그 도구 이름은 mcp__plugin_labkit_lab__*입니다. 그 호출들을 잡은 것은 오직 mcp__plugin_labkit_lab__.*, mcp__.*, 그리고 세 가지 전체 일치 형태뿐이었습니다. 이것이 문서에 적힌 플러그인 동작입니다. 실제로는 서버가 어떻게 설치되었는지에 따라 올바른 매처가 달라진다는 뜻이며, 같은 서버도 두 방식으로 모두 설치될 수 있습니다.

*, "", 그리고 매처를 생략한 경우는 모두 동일했습니다. MCP 호출 전부에 더해 ToolSearch 호출 15건까지 모두 포함되었습니다. mcp__.*는 65개의 MCP 호출 모두를 잡았고, 내장 도구는 잡지 않았습니다.

이 실행들 뒤에 무엇을 바꿔야 하는가

한 줄 수정은 경고가 이미 출력하는 그 수정입니다. 도구 이벤트 훅의 "matcher": "mcp__ "를 보는 곳마다 "matcher": "mcp__ __.*"로 바꾸세요. 서버 이름이 같은 방식으로 시작하는 경우와 겹치지 않도록 이중 밑줄은 그대로 유지해야 합니다. 서버가 플러그인에서 온 것이라면 대신 mcp__plugin_ _ __.*를 쓰세요. 단독 서버 키만으로는 일치하지 않습니다. 하나의 도구에 하나의 훅만 걸고 싶다면, 정확한 이름을 그대로 쓰는 것이 가장 엄격한 형식입니다. 목록이 더 읽기 쉽다면 |와 , 둘 다 동작했습니다.

이미 가지고 있는 죽은 matcher를 찾으려면, 우리가 찾은 가장 저렴한 점검은 위 경고를 만들어낸 바로 그 방법입니다. --debug-file을 붙인 상태로 적어도 하나의 도구 호출을 수행하는 claude -p 세션을 하나 실행하고, 파일에서 matches no tool을 검색하세요. 이 실험실에서는 mcp__ 형태의 matcher를 하나도 빠짐없이 잡아냈고, 목록 안에 들어 있는 것까지 잡았습니다. 하지만 lab이나 ping은 잡지 못했습니다. 즉, 이것은 서버 접두사 실수에 대한 점검이지 일반적인 점검은 아닙니다.

if의 경우, 이 버전에서는 서버 전체 권한 형식에 의존하지 마세요. matcher 안에 서버 필터를 넣고(mcp__lab__.*) if는 단일 도구 이름에만 쓰거나, 아니면 빼세요.

그리고 어떤 서버가 한 일을 기록하기 위한 훅이 있다면, PostToolUse에 등록하고 그 서버 자체 로그와 한 번 대조하세요. 여기서는 그 두 수치가 37로 일치했지만, PreToolUse는 실제로 실행되지 않은 28개의 호출도 함께 세었습니다.

재현하기

Node와 Claude Code가 필요하며, 서버에는 의존성이 없습니다. server.js의 요청 루프에서 도구 정의, calls.log 줄, 오류 응답을 잘라낸 부분은 다음과 같습니다:

코드
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 }] } });
  }
});

그다음 단계는 다음과 같습니다:

  1. 빈 디렉터리에 mcp.json(위의 두 서버)과 labkit 플러그인 디렉터리(.claude-plugin/plugin.json 및 같은 스크립트를 lab 아래에 등록하는 .mcp.json)를 넣으세요.
  1. PreToolUse와 PostToolUse에서 테스트하려는 matcher마다 하나씩 그룹을 두고, 각 그룹이 자기만의 레이블로 로거를 호출하도록 .claude/settings.json을 생성하세요.
  1. 위의 claude -p 명령을 --plugin-dir 및 표시된 --allowedTools와 함께 실행하세요. 플러그인 조건의 경우 --strict-mcp-config를 빼고 ENABLE_CLAUDEAI_MCP_SERVERS=false를 앞에 붙이세요.
  1. 레이블과 도구별로 fires.log를 세고, 이를 스트림의 hook_started 이벤트 및 calls.log와 비교하세요.
  1. grep "matches no tool" runs/*/debug.txt와 grep "Skipping hook due to if condition" runs/*/debug.txt를 실행하세요.

전송된 프롬프트:

코드
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.

측정하지 않은 것

모든 실행은 헤드리스로 수행했습니다. 대화형 /hooks 메뉴나 세션 중 /doctor를 열어 둘 중 어느 쪽에 matcher 경고가 표시되는지는 확인하지 않았습니다. 2.1.195보다 오래된 버전은 실행하지 않았으므로, 하이픈이 들어간 서버 matcher가 예전에는 동작했다는 주장은 변경 기록의 내용입니다. PreToolUse와 PostToolUse만 테스트했고, 문서상 같은 도구 이름으로 매칭된다고 되어 있는 PostToolUseFailure, PermissionRequest, PermissionDenied는 테스트하지 않았으며, matcher가 도구 이름이 아니라 서버 이름인 Elicitation 이벤트도 테스트하지 않았습니다. if에서는 세 가지 형식만 시도했고 매개변수 형식은 시도하지 않았습니다. 플러그인 복사본과 직접 복사본 두 개를 모두 포괄하려는 단일 패턴은 테스트하지 않았고, HTTP나 MCP-tool 훅 핸들러, Windows, 또는 claude-opus-5-5 이외의 모델도 테스트하지 않았습니다.

기록용 수치

2026-10-05 UTC 16:24부터 16:32 사이에 표에 대한 claude -p 실행 14회, 여기에 16:38의 1그룹 점검 1회가 더해졌고, Claude Code 2.1.289는 macOS에서 claude-opus-5-5, 서버와 로거는 Node 22를 사용했습니다. 허용된 실행 4회, 허용 규칙이 없는 실행 7회, 플러그인 실행 3회였습니다. 그 14회 동안 65개의 MCP 도구 호출이 PreToolUse에 도달했고, 37개가 실행되었으며, 서버는 같은 37개를 기록했습니다. 로거는 999줄을 썼고 스트림에는 999개의 hook_started 이벤트가 실렸습니다. 각 실행은 18초에서 38초가 걸렸습니다. 보고된 비용은 14회에 대해 $0.617, 점검 실행까지 포함하면 $0.675였습니다.

전체 실험실은 61줄짜리 서버, 20줄짜리 로거, 그리고 하나의 설정 파일로 이루어져 있으며, 표 뒤의 14회 실행 비용은 $0.617이었으므로, matcher를 신뢰하기 전에 자신만의 matcher로 다시 실행해 보기에는 저렴합니다.

아무것도 매칭하지 않는 훅 matcher가 있거나, if: "mcp__ "가 실제로 매칭되는 버전이 있다면, 아래 댓글에 문자열과 Claude Code 버전을 남겨 주세요.

출처

  • 원문: https://dev.to/rulestack/claude-codes-hook-matcher-mcplab-fired-0-times-in-42-calls-though-the-same-string-worked-as-a-250o (2026-10-11 · Rulestack)

자료 변경 기록을 불러오는 중입니다.

다음 자료를 불러오는 중입니다.

후기

아직 후기가 없습니다

직접 적용한 결과와 주의할 점을 공유하는 공간입니다.

로그인하고 후기 쓰기

참고 자료 · 도구와 릴리스 노트

댓글