#![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: "list_files",
why: "bounded directory inventory so agents need no second filesystem MCP (#2076)",
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: "explain_plan",
why: "readonly plan summary; CLI explain has no write Operation (#2541)",
kind: CustomKind::MultiOp,
},
CustomMcpTool {
name: "tidy_check",
why: "readonly tidy scan; does not write (CLI tidy check) (#2541)",
kind: CustomKind::MultiFileOrScan,
},
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,
},
CustomMcpTool {
name: "undo_list",
why: "list --apply backup sessions; not a write Operation (#2541)",
kind: CustomKind::Meta,
},
CustomMcpTool {
name: "undo_restore",
why: "preview/restore backup sessions; CLI undo has no plan op (#2541)",
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)
}
pub(super) const MCP_SURFACE_ENV: &str = "PATCHLOOM_MCP_SURFACE";
pub(super) use crate::cmd::agent_packaging::CORE_MCP_TOOL_NAMES;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum McpSurface {
#[default]
Full,
Core,
}
impl McpSurface {
pub(super) fn from_env() -> anyhow::Result<Self> {
match std::env::var(MCP_SURFACE_ENV) {
Err(std::env::VarError::NotPresent) => Ok(Self::Full),
Err(e) => Err(anyhow::anyhow!("failed to read {MCP_SURFACE_ENV}: {e}")),
Ok(raw) => Self::parse(&raw),
}
}
pub(super) fn parse(raw: &str) -> anyhow::Result<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"" | "full" => Ok(Self::Full),
"core" => Ok(Self::Core),
other => Err(anyhow::anyhow!(
"invalid {MCP_SURFACE_ENV}={other:?}; expected \"core\" or \"full\""
)),
}
}
#[must_use]
pub(super) fn as_str(self) -> &'static str {
match self {
Self::Full => "full",
Self::Core => "core",
}
}
#[must_use]
pub(super) fn allows(self, tool_name: &str) -> bool {
match self {
Self::Full => true,
Self::Core => CORE_MCP_TOOL_NAMES.contains(&tool_name),
}
}
#[must_use]
pub(super) fn expected_tool_count(self) -> usize {
match self {
Self::Full => {
let registry_n = super::registry::MCP_TOOL_REGISTRY.len();
let custom_n = custom_mcp_tools().count();
registry_n + custom_n
}
Self::Core => CORE_MCP_TOOL_NAMES.len(),
}
}
}
#[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") { 64 } else { 42 };
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(), 18, "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_")));
}
}
#[test]
fn mcp_surface_parse_core_full_and_reject_unknown() {
assert_eq!(McpSurface::parse("").unwrap(), McpSurface::Full);
assert_eq!(McpSurface::parse("full").unwrap(), McpSurface::Full);
assert_eq!(McpSurface::parse("FULL").unwrap(), McpSurface::Full);
assert_eq!(McpSurface::parse("core").unwrap(), McpSurface::Core);
assert_eq!(McpSurface::parse(" Core ").unwrap(), McpSurface::Core);
let err = McpSurface::parse("minimal").unwrap_err().to_string();
assert!(
err.contains(MCP_SURFACE_ENV) && err.contains("core"),
"got: {err}"
);
}
#[test]
fn core_mcp_tool_names_are_unique_and_exist_in_full_inventory() {
let mut seen = BTreeSet::new();
for name in CORE_MCP_TOOL_NAMES {
assert!(seen.insert(*name), "duplicate core tool: {name}");
}
let registry: BTreeSet<_> = MCP_TOOL_REGISTRY.iter().map(|t| t.tool_name).collect();
let custom: BTreeSet<_> = custom_tool_names().collect();
for name in CORE_MCP_TOOL_NAMES {
assert!(
registry.contains(name) || custom.contains(name),
"core tool {name} missing from registry and custom inventory"
);
assert!(
!name.starts_with("ast_"),
"core surface must not include AST tool {name}"
);
}
assert_eq!(
McpSurface::Core.expected_tool_count(),
CORE_MCP_TOOL_NAMES.len()
);
assert_eq!(
McpSurface::Full.expected_tool_count(),
MCP_TOOL_REGISTRY.len() + custom_mcp_tools().count()
);
assert!(
McpSurface::Core.expected_tool_count() < McpSurface::Full.expected_tool_count(),
"core pack must be a strict subset of full"
);
}
const CLI_WRITE_TO_MCP: &[(&str, &str)] = &[
("append", "append_file"),
("create", "create_file"),
("delete", "delete_file"),
("prepend", "prepend_file"),
("rename", "move_file"),
("replace", "replace_text"),
("apply-fragment", "apply_fragment"),
("patch", "apply_patch"),
("md", "md_* family"),
("doc", "doc_* family"),
("ast", "ast_* family"),
("tidy", "fix_whitespace / tidy_check / batch_tidy"),
("batch", "batch_replace / batch_tidy"),
("tx", "execute_plan"),
("undo", "undo_restore"),
];
const MCP_NON_WRITE_OR_ALIASED: &[(&str, &str)] = &[
("server_info", "MCP-only server metadata"),
("list_files", "CLI list-files (read-only)"),
("search_files", "CLI search"),
("read_file", "CLI read"),
("doc_get", "CLI doc get"),
("doc_query", "CLI doc query"),
("doc_diff", "CLI doc diff"),
("md_lint", "CLI md lint-agents"),
("git_status", "CLI status"),
("undo_list", "CLI undo --list"),
("explain_plan", "CLI explain"),
("tidy_check", "CLI tidy check"),
];
const CLI_ONLY_ALLOWLIST: &[(&str, &str)] = &[
("mcp-server", "MCP server process; no tool equivalent"),
("completions", "shell completions"),
("init", "project setup"),
("agent-rules", "docs generator"),
("schema", "schema export"),
];
fn mcp_write_has_cli(name: &str) -> bool {
if name.starts_with("doc_") || name.starts_with("md_") || name.starts_with("ast_") {
return true;
}
CLI_WRITE_TO_MCP.iter().any(|(_, mcp)| *mcp == name)
|| matches!(
name,
"fix_whitespace"
| "batch_tidy"
| "batch_replace"
| "execute_plan"
| "undo_restore"
| "replace_text"
| "apply_patch"
)
}
#[test]
fn mcp_cli_write_parity_allowlist() {
let inventory: BTreeSet<&str> = MCP_TOOL_REGISTRY
.iter()
.map(|t| t.tool_name)
.chain(custom_tool_names())
.collect();
for (cli, mcp) in CLI_WRITE_TO_MCP {
assert!(!mcp.is_empty(), "CLI write `{cli}` needs an MCP mapping");
if mcp.contains("family") || mcp.contains('/') {
continue;
}
assert!(
inventory.contains(mcp),
"CLI write `{cli}` maps to missing MCP tool `{mcp}`"
);
}
for meta in MCP_TOOL_REGISTRY {
if meta.tool_name == "read_file" {
continue;
}
assert!(
mcp_write_has_cli(meta.tool_name),
"registry write `{}` needs a CLI command or allowlist",
meta.tool_name
);
}
for t in custom_mcp_tools() {
if MCP_NON_WRITE_OR_ALIASED
.iter()
.any(|(n, why)| *n == t.name && !why.is_empty())
{
continue;
}
if matches!(t.kind, CustomKind::DocReadonly | CustomKind::Meta)
&& t.name != "undo_restore"
{
continue;
}
assert!(
mcp_write_has_cli(t.name),
"custom write `{}` needs a CLI command or MCP_NON_WRITE_OR_ALIASED why",
t.name
);
}
for (name, why) in CLI_ONLY_ALLOWLIST {
assert!(!why.is_empty(), "CLI-only `{name}` needs a why");
assert!(
!inventory.contains(name),
"CLI-only `{name}` must not be an MCP tool"
);
}
assert!(
inventory.contains("list_files") && inventory.contains("explain_plan"),
"list_files and explain_plan must be registered after #2541"
);
}
#[test]
fn core_surface_allows_only_core_names() {
assert!(McpSurface::Core.allows("doc_set"));
assert!(McpSurface::Core.allows("server_info"));
assert!(!McpSurface::Core.allows("doc_diff"));
assert!(!McpSurface::Core.allows("ast_list"));
assert!(!McpSurface::Core.allows("create_file"));
assert!(McpSurface::Full.allows("ast_list"));
assert!(McpSurface::Full.allows("create_file"));
}
}