use serde::{Deserialize, Serialize};
use crate::mcp::{MCP_TOOL_NAME_GRAMMAR, McpRef, try_parse_mcp_tool_name};
const TOOL_NAME_ALLOWED: &str =
"non-empty tool name; known tools use snake_case and scoped payloads use tool(pattern)";
const TOOL_PATTERN_ALLOWED: &str =
"scoped payload written as tool(pattern) with a non-empty pattern";
const WILDCARD_ALLOWED: &str = "`*` stands alone; it takes no pattern";
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Capability {
Any,
Mcp(McpRef),
Builtin(&'static str),
Unknown(String),
}
impl Capability {
pub fn name(&self) -> &str {
match self {
Self::Any => "*",
Self::Mcp(_) => "mcp",
Self::Builtin(name) => name,
Self::Unknown(name) => name,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ParsedToolName {
pub capability: Capability,
pub pattern: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct OverlayToolName {
raw: String,
parsed: ParsedToolName,
}
impl OverlayToolName {
pub fn parsed(&self) -> &ParsedToolName {
&self.parsed
}
}
impl TryFrom<String> for OverlayToolName {
type Error = String;
fn try_from(raw: String) -> Result<Self, String> {
match parse_mars_tool_name(&raw) {
Ok(parsed) => Ok(Self { raw, parsed }),
Err(err) => Err(format!(
"invalid tool name `{raw}`; allowed: {}",
err.allowed()
)),
}
}
}
impl From<OverlayToolName> for String {
fn from(name: OverlayToolName) -> Self {
name.raw
}
}
pub fn render_scoped(name: &str, pattern: Option<&str>) -> String {
match pattern {
Some(pattern) => format!("{name}({pattern})"),
None => name.to_string(),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ToolNameParseError {
Empty,
InvalidMcpRef,
UnbalancedPattern,
EmptyPattern,
WildcardPattern,
}
impl ToolNameParseError {
pub fn allowed(&self) -> &'static str {
match self {
Self::Empty => TOOL_NAME_ALLOWED,
Self::InvalidMcpRef => MCP_TOOL_NAME_GRAMMAR,
Self::UnbalancedPattern | Self::EmptyPattern => TOOL_PATTERN_ALLOWED,
Self::WildcardPattern => WILDCARD_ALLOWED,
}
}
}
struct CanonicalTool {
name: &'static str,
aliases: &'static [&'static str],
}
const CANONICAL_TOOLS: &[CanonicalTool] = &[
CanonicalTool {
name: "bash",
aliases: &["shell", "terminal", "exec_command", "shell_command"],
},
CanonicalTool {
name: "read",
aliases: &["cat", "view", "file_read"],
},
CanonicalTool {
name: "write",
aliases: &["file_write", "apply_patch"],
},
CanonicalTool {
name: "edit",
aliases: &["sed", "str_replace"],
},
CanonicalTool {
name: "agent",
aliases: &["subagent", "spawn_agent"],
},
CanonicalTool {
name: "task",
aliases: &[
"todo_read",
"todoread",
"todo_write",
"todowrite",
"task_create",
"taskcreate",
"task_get",
"taskget",
"task_list",
"tasklist",
"task_update",
"taskupdate",
"update_plan",
],
},
CanonicalTool {
name: "skill",
aliases: &[],
},
CanonicalTool {
name: "workflow",
aliases: &[],
},
CanonicalTool {
name: "glob",
aliases: &["find"],
},
CanonicalTool {
name: "grep",
aliases: &["rg", "search", "ripgrep"],
},
CanonicalTool {
name: "notebook",
aliases: &["jupyter", "notebook_edit", "notebookedit"],
},
CanonicalTool {
name: "web",
aliases: &[],
},
CanonicalTool {
name: "web_search",
aliases: &["websearch"],
},
CanonicalTool {
name: "web_fetch",
aliases: &["webfetch", "fetch", "curl"],
},
CanonicalTool {
name: "ask_user",
aliases: &[
"askuser",
"request_user_input",
"ask_question",
"ask_user_question",
"askuserquestion",
],
},
CanonicalTool {
name: "cron",
aliases: &[],
},
CanonicalTool {
name: "notifications",
aliases: &["pushnotification", "push_notification"],
},
CanonicalTool {
name: "plan_mode",
aliases: &["planmode", "switch_mode"],
},
CanonicalTool {
name: "worktree",
aliases: &[],
},
CanonicalTool {
name: "lsp",
aliases: &[],
},
CanonicalTool {
name: "monitor",
aliases: &[],
},
CanonicalTool {
name: "send_user_file",
aliases: &["senduserfile"],
},
CanonicalTool {
name: "schedule_wakeup",
aliases: &["schedulewakeup"],
},
CanonicalTool {
name: "remote_trigger",
aliases: &["remotetrigger"],
},
CanonicalTool {
name: "tool_search",
aliases: &["toolsearch"],
},
CanonicalTool {
name: "ls",
aliases: &[],
},
CanonicalTool {
name: "work",
aliases: &[],
},
CanonicalTool {
name: "image",
aliases: &["view_image"],
},
];
pub fn parse_mars_tool_name(raw: &str) -> Result<ParsedToolName, ToolNameParseError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(ToolNameParseError::Empty);
}
let (head, payload) = split_tool_name(trimmed);
let head = head.trim();
if head.is_empty() {
return Err(ToolNameParseError::Empty);
}
if let Some(mcp_ref) = try_parse_mcp_tool_name(trimmed) {
return Ok(ParsedToolName {
capability: Capability::Mcp(mcp_ref),
pattern: None,
});
}
if head.eq_ignore_ascii_case("mcp") {
return Err(ToolNameParseError::InvalidMcpRef);
}
let pattern = parse_pattern(payload)?;
if head == "*" {
if pattern.is_some() {
return Err(ToolNameParseError::WildcardPattern);
}
return Ok(ParsedToolName {
capability: Capability::Any,
pattern: None,
});
}
let capability = canonicalize_head(head);
let pattern = pattern.map(|pattern| match capability {
Capability::Builtin("bash") => normalize_bash_pattern(&pattern),
_ => pattern,
});
Ok(ParsedToolName {
capability,
pattern,
})
}
fn parse_pattern(payload: &str) -> Result<Option<String>, ToolNameParseError> {
let payload = payload.trim();
if payload.is_empty() {
return Ok(None);
}
let inner = payload
.strip_prefix('(')
.and_then(|rest| rest.strip_suffix(')'))
.ok_or(ToolNameParseError::UnbalancedPattern)?
.trim();
if inner.is_empty() {
return Err(ToolNameParseError::EmptyPattern);
}
Ok(Some(inner.to_string()))
}
fn normalize_bash_pattern(pattern: &str) -> String {
match pattern.strip_suffix(":*") {
Some(prefix) => format!("{prefix} *"),
None => pattern.to_string(),
}
}
#[cfg(test)]
pub fn is_canonical_capability(name: &str) -> bool {
canonical_tool_name(name).is_some()
}
pub fn canonical_capabilities() -> impl Iterator<Item = &'static str> {
CANONICAL_TOOLS.iter().map(|tool| tool.name)
}
#[derive(Debug, Clone, Serialize)]
pub struct CapabilityEntry {
pub name: &'static str,
pub aliases: &'static [&'static str],
}
pub fn capabilities() -> Vec<CapabilityEntry> {
CANONICAL_TOOLS
.iter()
.map(|tool| CapabilityEntry {
name: tool.name,
aliases: tool.aliases,
})
.collect()
}
pub fn split_tool_name(value: &str) -> (&str, &str) {
match value.find('(') {
Some(index) => (&value[..index], &value[index..]),
None => (value, ""),
}
}
pub fn is_mcp_wire_token(name: &str) -> bool {
name.trim()
.get(..5)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("mcp__"))
}
fn canonicalize_head(head: &str) -> Capability {
if is_mcp_wire_token(head) {
return Capability::Unknown(head.to_string());
}
let lowercase = head.to_ascii_lowercase();
if let Some(canonical) = canonical_tool_name(&lowercase).or_else(|| canonical_alias(&lowercase))
{
return Capability::Builtin(canonical);
}
if head.contains('_') || is_all_caps(head) {
return Capability::Unknown(lowercase);
}
if is_mixed_case(head) {
let snake = pascal_to_snake(head);
return match canonical_tool_name(&snake) {
Some(canonical) => Capability::Builtin(canonical),
None => Capability::Unknown(snake),
};
}
Capability::Unknown(head.to_string())
}
fn canonical_tool_name(name: &str) -> Option<&'static str> {
CANONICAL_TOOLS
.iter()
.find(|tool| tool.name == name)
.map(|tool| tool.name)
}
fn canonical_alias(alias: &str) -> Option<&'static str> {
CANONICAL_TOOLS
.iter()
.find(|tool| tool.aliases.contains(&alias))
.map(|tool| tool.name)
}
fn is_all_caps(s: &str) -> bool {
let mut has_upper = false;
for ch in s.chars().filter(|ch| ch.is_ascii_alphabetic()) {
if ch.is_ascii_lowercase() {
return false;
}
has_upper = true;
}
has_upper
}
fn is_mixed_case(s: &str) -> bool {
s.chars().any(|ch| ch.is_ascii_uppercase()) && s.chars().any(|ch| ch.is_ascii_lowercase())
}
fn pascal_to_snake(s: &str) -> String {
let mut out = String::new();
for (index, ch) in s.chars().enumerate() {
if ch.is_ascii_uppercase() {
if index > 0 {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
} else {
out.push(ch);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(raw: &str) -> ParsedToolName {
parse_mars_tool_name(raw).unwrap()
}
fn render(parsed: &ParsedToolName) -> String {
render_scoped(parsed.capability.name(), parsed.pattern.as_deref())
}
#[test]
fn parses_known_tools_and_aliases_to_snake_case() {
let cases = [
("bash", "bash"),
("Bash", "bash"),
("BASH", "bash"),
("shell", "bash"),
("ask_user", "ask_user"),
("AskUser", "ask_user"),
("askuser", "ask_user"),
("LSP", "lsp"),
("WebSearch", "web_search"),
("cat", "read"),
("rg", "grep"),
("CustomTool", "custom_tool"),
("my_custom", "my_custom"),
("bash(git *)", "bash(git *)"),
("skill(init)", "skill(init)"),
("Skill(deep-research)", "skill(deep-research)"),
("workflow", "workflow"),
("web", "web"),
("websearch", "web_search"),
("webfetch", "web_fetch"),
("AskUserQuestion", "ask_user"),
("NotebookEdit", "notebook"),
("TaskCreate", "task"),
("TodoWrite", "task"),
("update_plan", "task"),
("subagent", "agent"),
];
for (raw, expected) in cases {
assert_eq!(render(&parse(raw)), expected, "raw: {raw}");
}
}
#[test]
fn task_means_task_list_not_sub_agents() {
assert_eq!(parse("task").capability, Capability::Builtin("task"));
assert_eq!(parse("Task").capability, Capability::Builtin("task"));
assert_eq!(parse("agent").capability, Capability::Builtin("agent"));
}
#[test]
fn rejects_empty_tool_names() {
assert_eq!(parse_mars_tool_name(""), Err(ToolNameParseError::Empty));
assert_eq!(
parse_mars_tool_name("(git *)"),
Err(ToolNameParseError::Empty)
);
}
#[test]
fn star_is_the_any_capability_and_takes_no_pattern() {
assert_eq!(parse("*").capability, Capability::Any);
assert_eq!(
parse_mars_tool_name("*(x)"),
Err(ToolNameParseError::WildcardPattern)
);
}
#[test]
fn non_ascii_unknown_tools_do_not_panic() {
for raw in ["café_tool", "🎉🎉tool", "🚀tool"] {
let parsed = parse_mars_tool_name(raw).expect("parse should not panic");
assert_eq!(render(&parsed), raw);
assert!(matches!(parsed.capability, Capability::Unknown(_)));
}
}
#[test]
fn mcp_wire_tokens_stay_verbatim_unknown_capabilities() {
for raw in ["mcp__github__CreateIssue", "MCP__Server__Tool"] {
let parsed = parse(raw);
assert_eq!(parsed.capability, Capability::Unknown(raw.to_string()));
assert!(is_mcp_wire_token(raw));
}
}
#[test]
fn parse_recognizes_valid_mcp_scoped_refs() {
let Capability::Mcp(mcp_ref) = parse("mcp(GitHub/CreateIssue)").capability else {
panic!("expected an MCP capability");
};
assert_eq!(mcp_ref.to_pattern(), "GitHub/CreateIssue");
let Capability::Mcp(mcp_ref) = parse("mcp(github)").capability else {
panic!("expected an MCP capability");
};
assert_eq!(mcp_ref.to_pattern(), "github/*");
assert_eq!(parse("mcp(github)").pattern, None);
}
#[test]
fn rejects_malformed_and_bare_mcp() {
for raw in ["mcp()", "mcp(/x)", "mcp(x/)", "mcp(a/b/c)", "mcp", "MCP"] {
assert_eq!(
parse_mars_tool_name(raw),
Err(ToolNameParseError::InvalidMcpRef),
"expected rejection for {raw}"
);
}
}
}