#![allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct CustomMcpTool {
pub name: &'static str,
pub why: &'static str,
pub kind: CustomKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum CustomKind {
DocReadonly,
MultiFileOrScan,
MultiOp,
MdCustom,
Patch,
Ast,
Meta,
}
pub(super) const CUSTOM_MCP_TOOLS_CORE: &[CustomMcpTool] = &[
CustomMcpTool {
name: "doc_get",
why: "readonly doc get; not a write Operation",
kind: CustomKind::DocReadonly,
},
CustomMcpTool {
name: "doc_query",
why: "readonly multi-action query (has/keys/len/select/flatten)",
kind: CustomKind::DocReadonly,
},
CustomMcpTool {
name: "doc_diff",
why: "readonly structured file compare",
kind: CustomKind::DocReadonly,
},
CustomMcpTool {
name: "search_files",
why: "multi-path search with layered ignores and report modes",
kind: CustomKind::MultiFileOrScan,
},
CustomMcpTool {
name: "replace_text",
why: "parallel multi-file scan + precomputed engine handoff",
kind: CustomKind::MultiFileOrScan,
},
CustomMcpTool {
name: "batch_replace",
why: "builds multi-file replace batch, not one Operation",
kind: CustomKind::MultiOp,
},
CustomMcpTool {
name: "batch_tidy",
why: "builds multi-file tidy batch, not one Operation",
kind: CustomKind::MultiOp,
},
CustomMcpTool {
name: "execute_plan",
why: "full transaction plan (inline or path), not one Operation",
kind: CustomKind::MultiOp,
},
CustomMcpTool {
name: "md_move_section",
why: "cross-file section move + custom result shape",
kind: CustomKind::MdCustom,
},
CustomMcpTool {
name: "md_lint",
why: "readonly AGENTS.md lint; not a write Operation",
kind: CustomKind::MdCustom,
},
CustomMcpTool {
name: "apply_patch",
why: "unified-diff apply with stale/conflict exit mapping",
kind: CustomKind::Patch,
},
CustomMcpTool {
name: "git_status",
why: "readonly git status vs HEAD",
kind: CustomKind::Meta,
},
CustomMcpTool {
name: "server_info",
why: "server/workspace metadata for agents",
kind: CustomKind::Meta,
},
];
#[cfg(feature = "ast")]
pub(super) const CUSTOM_MCP_TOOLS_AST: &[CustomMcpTool] = &[
CustomMcpTool {
name: "ast_list",
why: "AST symbol listing (analyze, not plan write)",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_read",
why: "AST symbol body read",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_rename",
why: "AST multi-file rename with scan/filter then stage",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_validate",
why: "AST syntax validation report",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_search",
why: "structural AST search",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_refs",
why: "cross-file symbol references",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_deps",
why: "import/dependency extraction",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_map",
why: "repo map / PageRank over symbols",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_diff",
why: "structural symbol diff across git refs",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_impact",
why: "transitive impact analysis",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_replace",
why: "symbol-scoped replace with custom resolve/output",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_rewrite_signature",
why: "function signature rewrite (structured + full text)",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_insert",
why: "AST insert with position/container resolve",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_wrap",
why: "AST wrap with container resolve",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_imports",
why: "import list/add/remove/dedupe actions",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_reorder",
why: "symbol reorder strategies",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_group",
why: "group symbols into module blocks",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_move",
why: "move symbols across files",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_extract_to_file",
why: "extract symbol to new file",
kind: CustomKind::Ast,
},
CustomMcpTool {
name: "ast_split",
why: "split file across targets by symbols",
kind: CustomKind::Ast,
},
];
#[cfg(not(feature = "ast"))]
pub(super) const CUSTOM_MCP_TOOLS_AST: &[CustomMcpTool] = &[];
pub(super) fn custom_mcp_tools() -> impl Iterator<Item = &'static CustomMcpTool> {
CUSTOM_MCP_TOOLS_CORE
.iter()
.chain(CUSTOM_MCP_TOOLS_AST.iter())
}
pub(super) fn custom_tool_names() -> impl Iterator<Item = &'static str> {
custom_mcp_tools().map(|t| t.name)
}
#[cfg(test)]
mod tests {
use super::super::registry::MCP_TOOL_REGISTRY;
use super::*;
use std::collections::BTreeSet;
#[test]
fn custom_tool_names_are_unique() {
let mut seen = BTreeSet::new();
for t in custom_mcp_tools() {
assert!(
seen.insert(t.name),
"duplicate custom tool name: {}",
t.name
);
assert!(!t.why.is_empty(), "{} missing why", t.name);
}
}
#[test]
fn registry_and_custom_are_disjoint() {
let registry: BTreeSet<_> = MCP_TOOL_REGISTRY.iter().map(|t| t.tool_name).collect();
let custom: BTreeSet<_> = custom_tool_names().collect();
let overlap: Vec<_> = registry.intersection(&custom).copied().collect();
assert!(
overlap.is_empty(),
"tool(s) listed as both registry and custom: {overlap:?}"
);
}
#[test]
fn registry_plus_custom_count_matches_list_tools_expectation() {
let registry_n = MCP_TOOL_REGISTRY.len();
let custom_n = custom_mcp_tools().count();
let expected_total = if cfg!(feature = "ast") { 55 } else { 35 };
assert_eq!(
registry_n + custom_n,
expected_total,
"registry ({registry_n}) + custom ({custom_n}) must equal total MCP tools ({expected_total})"
);
assert_eq!(CUSTOM_MCP_TOOLS_CORE.len(), 13, "core custom tool count");
#[cfg(feature = "ast")]
assert_eq!(CUSTOM_MCP_TOOLS_AST.len(), 20, "ast custom tool count");
#[cfg(not(feature = "ast"))]
assert!(CUSTOM_MCP_TOOLS_AST.is_empty());
}
#[test]
fn fix_whitespace_is_registry_not_custom() {
assert!(
MCP_TOOL_REGISTRY
.iter()
.any(|t| t.tool_name == "fix_whitespace")
);
assert!(!custom_tool_names().any(|n| n == "fix_whitespace"));
}
#[test]
fn ast_custom_tools_are_feature_gated_in_inventory() {
let names: BTreeSet<_> = custom_tool_names().collect();
if cfg!(feature = "ast") {
assert!(names.contains("ast_list"));
assert!(names.contains("ast_split"));
} else {
assert!(!names.iter().any(|n| n.starts_with("ast_")));
}
}
}