#![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: "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)
}
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") { 58 } else { 38 };
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(), 14, "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"
);
}
#[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"));
}
}