bug(hooks,mcp): hand-written madt_ call strings drift from the registered tool surface #706

Open
opened 2026-10-03 20:29:04 +00:00 by hexajon · 0 comments
Owner

What happens

My hook deny text and parts of my MCP text print an agent-facing call in the bare form madt_<tool>(action="..."). The worktree guard is the clearest case. Its list branch prints:

madt_worktrees(action="list") # MCP / agent session

(hooks/scripts/check-git-worktree.sh:192; the same bare form at :152, :165, :166, :179, and the header at :23-26.)

An MCP client does not register the tool under that bare name. A client that namespaces a server's tools (the common case) exposes it under a prefixed id, for example mcp__madtea__madt_worktrees. The bare madt_worktrees is not that id, so an agent that copies the printed call gets "no such tool available". A fixed bare string cannot name the callable id for every client, because the hook and the text cannot know the client's prefix.

Against the decision record

ADR 0008 (the operation registry is the source of record) already removes this drift for one layer. The internal/opspec registry is the single source of record for each operation's description, and both the CLI and the MCP schema render from it, so their descriptions cannot drift. Its drift guards run against that registry.

This issue does not fork ADR 0008. It extends the same principle to three surfaces the registry does not cover, and that have no drift guard:

  1. The plugin hook scripts. They are shell, authored outside internal/opspec, and they hand-write madt_*(action=...) call strings.
  2. The MCP server-instruction text. internal/mcp/server.go:306,312 hand-writes "Call these madt_* tools directly" and "Use the madtea tools (madt_finish, madt_clone, etc.)". internal/mcp/tools_help.go:780 and internal/mcp/prompts.go:67 repeat the bare form.
  3. The runtime refusal messages. internal/mcp/foreignrepo.go:259,271,291,371 print madt_worktrees(action="add", ...), madt_commit(...), and madt_finish(...) as the call to run.

The surfaces that drift

Plugin hooks (each hand-writes madt_*(action=...)):

  • hooks/scripts/check-git-worktree.sh
  • hooks/scripts/check-git-cli.sh
  • hooks/scripts/check-git-local.sh
  • hooks/scripts/check-git-remote.sh (:408)
  • hooks/scripts/check-foreign-primary.sh (:53)
  • hooks/scripts/check-git-commit.sh
  • hooks/scripts/surface-tool-error.sh

MCP text outside the rendered descriptions: internal/mcp/server.go:306,312, internal/mcp/tools_help.go:780, internal/mcp/prompts.go:67, internal/mcp/foreignrepo.go:259,271,291,371.

Why it drifts

The hand-written call strings and the registered tool surface share no source. Three ways they fall out of step, none caught today:

  1. A client namespaces the tool, so the bare id is not callable (the case above).
  2. A tool or an action is renamed, so a hand-written call names a dead tool or action.
  3. A new action lands, so the guidance never mentions it.

Two open issues are the same family inside the rendered layer: #686 (the madt_finish schema advertises summary=@file, which the MCP server never implements) and #690 (two tool names differ by one letter).

Fix (options)

I have not picked the approach.

  • Extend the ADR 0008 drift guard beyond the rendered descriptions. Add a test, a sibling of internal/parity/ and internal/mcp/description_drift.go, that scans the hook scripts and the hand-written MCP text for madt_<tool>(action="...") strings, and fails the gate when a string names a tool or an action that internal/opspec does not define.
  • Generate the example call strings from internal/opspec, so no hand-written surface can name a tool or an action that does not exist.
  • Separate question: whether to print an exact agent-callable id at all. A client's namespacing is unknown at author time, so naming the CLI form plus the tool and the action may be more robust than any fixed string. This is a wording decision, not a test decision.

Acceptance criteria

  • A gate check fails when a hook script or hand-written MCP text names a madt_ tool or action that internal/opspec does not define.
  • The check covers the plugin hook scripts and the MCP server-instruction and refusal text, not only the rendered descriptions.
  • The worktree guard's agent-facing guidance resolves to a call an MCP client can make.
## What happens My hook deny text and parts of my MCP text print an agent-facing call in the bare form `madt_<tool>(action="...")`. The worktree guard is the clearest case. Its list branch prints: `madt_worktrees(action="list") # MCP / agent session` (`hooks/scripts/check-git-worktree.sh:192`; the same bare form at `:152`, `:165`, `:166`, `:179`, and the header at `:23-26`.) An MCP client does not register the tool under that bare name. A client that namespaces a server's tools (the common case) exposes it under a prefixed id, for example `mcp__madtea__madt_worktrees`. The bare `madt_worktrees` is not that id, so an agent that copies the printed call gets "no such tool available". A fixed bare string cannot name the callable id for every client, because the hook and the text cannot know the client's prefix. ## Against the decision record ADR 0008 (the operation registry is the source of record) already removes this drift for one layer. The `internal/opspec` registry is the single source of record for each operation's description, and both the CLI and the MCP schema render from it, so their descriptions cannot drift. Its drift guards run against that registry. This issue does not fork ADR 0008. It extends the same principle to three surfaces the registry does not cover, and that have no drift guard: 1. The plugin hook scripts. They are shell, authored outside `internal/opspec`, and they hand-write `madt_*(action=...)` call strings. 2. The MCP server-instruction text. `internal/mcp/server.go:306,312` hand-writes "Call these madt_* tools directly" and "Use the madtea tools (madt_finish, madt_clone, etc.)". `internal/mcp/tools_help.go:780` and `internal/mcp/prompts.go:67` repeat the bare form. 3. The runtime refusal messages. `internal/mcp/foreignrepo.go:259,271,291,371` print `madt_worktrees(action="add", ...)`, `madt_commit(...)`, and `madt_finish(...)` as the call to run. ## The surfaces that drift Plugin hooks (each hand-writes `madt_*(action=...)`): - `hooks/scripts/check-git-worktree.sh` - `hooks/scripts/check-git-cli.sh` - `hooks/scripts/check-git-local.sh` - `hooks/scripts/check-git-remote.sh` (`:408`) - `hooks/scripts/check-foreign-primary.sh` (`:53`) - `hooks/scripts/check-git-commit.sh` - `hooks/scripts/surface-tool-error.sh` MCP text outside the rendered descriptions: `internal/mcp/server.go:306,312`, `internal/mcp/tools_help.go:780`, `internal/mcp/prompts.go:67`, `internal/mcp/foreignrepo.go:259,271,291,371`. ## Why it drifts The hand-written call strings and the registered tool surface share no source. Three ways they fall out of step, none caught today: 1. A client namespaces the tool, so the bare id is not callable (the case above). 2. A tool or an action is renamed, so a hand-written call names a dead tool or action. 3. A new action lands, so the guidance never mentions it. Two open issues are the same family inside the rendered layer: #686 (the `madt_finish` schema advertises `summary=@file`, which the MCP server never implements) and #690 (two tool names differ by one letter). ## Fix (options) I have not picked the approach. - Extend the ADR 0008 drift guard beyond the rendered descriptions. Add a test, a sibling of `internal/parity/` and `internal/mcp/description_drift.go`, that scans the hook scripts and the hand-written MCP text for `madt_<tool>(action="...")` strings, and fails the gate when a string names a tool or an action that `internal/opspec` does not define. - Generate the example call strings from `internal/opspec`, so no hand-written surface can name a tool or an action that does not exist. - Separate question: whether to print an exact agent-callable id at all. A client's namespacing is unknown at author time, so naming the CLI form plus the tool and the action may be more robust than any fixed string. This is a wording decision, not a test decision. ## Acceptance criteria - [ ] A gate check fails when a hook script or hand-written MCP text names a `madt_` tool or action that `internal/opspec` does not define. - [ ] The check covers the plugin hook scripts and the MCP server-instruction and refusal text, not only the rendered descriptions. - [ ] The worktree guard's agent-facing guidance resolves to a call an MCP client can make.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
sixfold-space/madtea#706
No description provided.