fix(web): match MCP hook tool_name globs on the owning server (S15a, DOCS-04)

The documented `mcp__*` tool_name glob never matched a real MCP tool: the
model calls server tools by `McpPool::mcp_model_tool_name`
(`mcp_<server>_<tool>`), so the double-underscore pattern missed them all,
and the single-underscore `mcp_*` a user might try instead also caught the
built-in helpers (`mcp_read_resource`, `mcp_get_prompt`).

tool_name conditions now treat any `mcp__…` pattern (translated to the
model spelling via `mcp_model_tool_name`) and any glob starting with `mcp_`
as scoped to server-owned tools; the helpers are reachable by exact name
only. Known limits are written beside the matcher.

Checks run:
- New test mcp_glob_matches_real_model_tool_names_by_owning_server failed
  against the old matcher ("mcp_github_create_issue must match mcp__*"):
  0 passed; 1 failed.
- cargo test -p codewhale-tui hooks:: after the fix: lib 154 passed;
  0 failed (other test binaries matched 0).
- rustfmt --check on executor.rs: clean.
Not run: reproduction against a live MCP server; docs (S15b) unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-22 11:48:38 -07:00
1 parent ea59eddc84
commit 091f05245f
1 file changed
+84 -14
+84 -14
View File
@@ -2055,7 +2055,45 @@ impl HookExecutor {
}
/// Check whether a tool name matches a condition pattern with `*` glob support.
///
/// DOCS-04: MCP-scoped patterns match on the owning MCP server, not on the
/// `mcp_` name prefix. The model calls a server tool by
/// [`crate::mcp::McpPool::mcp_model_tool_name`] (`mcp_<server>_<tool>`),
/// while the documented spelling is `mcp__<server>__<tool>`; both are
/// accepted. Any glob starting with `mcp_`, and any `mcp__` pattern, only
/// ever selects tools a server owns, so the built-in MCP helpers such as
/// `mcp_read_resource` are reachable by exact name only.
///
/// Known limit: ownership is read from the model name, not the live pool,
/// so `mcp__<server>__…` splits at the first `__` (a server whose name
/// contains `__` needs the `mcp_<server>_…` spelling), and a server tool
/// whose model name collides with a helper name is treated as the helper.
fn tool_name_matches_condition(tool_name: &str, pattern: &str) -> bool {
if tool_name == pattern {
return true;
}
if let Some(rest) = pattern.strip_prefix("mcp_") {
let documented = rest.strip_prefix('_');
if documented.is_some() || pattern.contains('*') {
if !is_mcp_server_tool(tool_name) {
return false;
}
let model_pattern = match documented {
Some(rest) => match rest.split_once("__") {
Some((server, tool)) => {
crate::mcp::McpPool::mcp_model_tool_name(server, tool)
}
None => format!("mcp_{rest}"),
},
None => pattern.to_string(),
};
return Self::glob_matches(tool_name, &model_pattern);
}
}
Self::glob_matches(tool_name, pattern)
}
fn glob_matches(tool_name: &str, pattern: &str) -> bool {
if !pattern.contains('*') {
return tool_name == pattern;
}
@@ -2073,7 +2111,8 @@ impl HookExecutor {
None | Some(HookCondition::Always) => true,
Some(HookCondition::ToolName { name }) => {
// #3026: Support `*` globs in tool_name conditions so
// `mcp__*` matches all MCP tools. Exact names keep working.
// `mcp__*` matches every tool an MCP server owns (DOCS-04:
// not the built-in `mcp_*` helpers). Exact names keep working.
context
.tool_name
.as_ref()
@@ -2488,6 +2527,21 @@ impl HookExecutor {
/// An unparseable or absent argument blob is treated as the tool's most
/// dangerous action, because a gate that cannot see the action must not
/// assume the harmless one.
/// Whether `name` is a tool some MCP server owns, as opposed to one of the
/// built-in MCP helpers the TUI itself registers (`McpPool::is_mcp_tool`
/// counts both). Server tools are named by `McpPool::mcp_model_tool_name`.
fn is_mcp_server_tool(name: &str) -> bool {
name.starts_with("mcp_")
&& !matches!(
name,
"mcp_read_resource"
| "mcp_get_prompt"
| "list_mcp_resources"
| "list_mcp_resource_templates"
| "read_mcp_resource"
)
}
fn tool_category_for(tool_name: &str, tool_args: Option<&str>) -> &'static str {
let action = tool_args
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok())
@@ -4299,16 +4353,36 @@ exit 7
// ── #3026: glob matchers for tool_name conditions ──────────────────────
/// DOCS-04: the documented `mcp__*` glob must match the name the model
/// actually calls (built by `McpPool::mcp_model_tool_name`, which is
/// `mcp_<server>_<tool>`), and must not catch the built-in MCP helpers.
#[test]
fn tool_name_glob_matches_mcp_prefix() {
assert!(HookExecutor::tool_name_matches_condition(
"mcp__github__create_issue",
"mcp__*"
));
assert!(!HookExecutor::tool_name_matches_condition(
"read_file",
"mcp__*"
));
fn mcp_glob_matches_real_model_tool_names_by_owning_server() {
let served = crate::mcp::McpPool::mcp_model_tool_name("github", "create_issue");
let other = crate::mcp::McpPool::mcp_model_tool_name("wiki", "lookup");
let matches = HookExecutor::tool_name_matches_condition;
assert!(matches(&served, "mcp__*"), "{served} must match mcp__*");
assert!(matches(&served, "mcp_*"), "{served} must match mcp_*");
assert!(matches(&served, "mcp__github__*"));
assert!(!matches(&other, "mcp__github__*"));
assert!(matches(&served, "mcp__github__create_issue"));
assert!(matches(&served, "mcp__*__create_issue"));
assert!(!matches(&other, "mcp__*__create_issue"));
for helper in [
"mcp_read_resource",
"mcp_get_prompt",
"list_mcp_resources",
"list_mcp_resource_templates",
"read_mcp_resource",
] {
assert!(!matches(helper, "mcp__*"), "{helper} is built in");
assert!(!matches(helper, "mcp_*"), "{helper} is built in");
// Exact names still select a helper deliberately.
assert!(matches(helper, helper));
}
assert!(!matches("read_file", "mcp__*"));
}
#[test]
@@ -4343,10 +4417,6 @@ exit 7
#[test]
fn tool_name_glob_supports_infix_and_suffix_positions() {
assert!(HookExecutor::tool_name_matches_condition(
"mcp__github__create_issue",
"mcp__*__create_issue"
));
assert!(HookExecutor::tool_name_matches_condition(
"task_shell_start",
"*_shell_start"