1use serde::{Deserialize, Serialize};
9
10use crate::mcp::{MCP_TOOL_NAME_GRAMMAR, McpRef, try_parse_mcp_tool_name};
11
12const TOOL_NAME_ALLOWED: &str =
13 "non-empty tool name; known tools use snake_case and scoped payloads use tool(pattern)";
14const TOOL_PATTERN_ALLOWED: &str =
15 "scoped payload written as tool(pattern) with a non-empty pattern";
16const WILDCARD_ALLOWED: &str = "`*` stands alone; it takes no pattern";
17
18#[derive(Clone, Debug, PartialEq, Eq)]
20pub enum Capability {
21 Any,
23 Mcp(McpRef),
25 Builtin(&'static str),
27 Unknown(String),
30}
31
32impl Capability {
33 pub fn name(&self) -> &str {
35 match self {
36 Self::Any => "*",
37 Self::Mcp(_) => "mcp",
38 Self::Builtin(name) => name,
39 Self::Unknown(name) => name,
40 }
41 }
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct ParsedToolName {
46 pub capability: Capability,
47 pub pattern: Option<String>,
50}
51
52#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(try_from = "String", into = "String")]
57pub struct OverlayToolName {
58 raw: String,
59 parsed: ParsedToolName,
60}
61
62impl OverlayToolName {
63 pub fn parsed(&self) -> &ParsedToolName {
64 &self.parsed
65 }
66}
67
68impl TryFrom<String> for OverlayToolName {
69 type Error = String;
70
71 fn try_from(raw: String) -> Result<Self, String> {
72 match parse_mars_tool_name(&raw) {
73 Ok(parsed) => Ok(Self { raw, parsed }),
74 Err(err) => Err(format!(
75 "invalid tool name `{raw}`; allowed: {}",
76 err.allowed()
77 )),
78 }
79 }
80}
81
82impl From<OverlayToolName> for String {
83 fn from(name: OverlayToolName) -> Self {
84 name.raw
85 }
86}
87
88pub fn render_scoped(name: &str, pattern: Option<&str>) -> String {
89 match pattern {
90 Some(pattern) => format!("{name}({pattern})"),
91 None => name.to_string(),
92 }
93}
94
95#[derive(Clone, Debug, PartialEq, Eq)]
96pub enum ToolNameParseError {
97 Empty,
98 InvalidMcpRef,
99 UnbalancedPattern,
100 EmptyPattern,
101 WildcardPattern,
102}
103
104impl ToolNameParseError {
105 pub fn allowed(&self) -> &'static str {
106 match self {
107 Self::Empty => TOOL_NAME_ALLOWED,
108 Self::InvalidMcpRef => MCP_TOOL_NAME_GRAMMAR,
109 Self::UnbalancedPattern | Self::EmptyPattern => TOOL_PATTERN_ALLOWED,
110 Self::WildcardPattern => WILDCARD_ALLOWED,
111 }
112 }
113}
114
115struct CanonicalTool {
116 name: &'static str,
117 aliases: &'static [&'static str],
118}
119
120const CANONICAL_TOOLS: &[CanonicalTool] = &[
121 CanonicalTool {
122 name: "bash",
123 aliases: &["shell", "terminal", "exec_command", "shell_command"],
124 },
125 CanonicalTool {
126 name: "read",
127 aliases: &["cat", "view", "file_read"],
128 },
129 CanonicalTool {
130 name: "write",
131 aliases: &["file_write", "apply_patch"],
132 },
133 CanonicalTool {
134 name: "edit",
135 aliases: &["sed", "str_replace"],
136 },
137 CanonicalTool {
139 name: "agent",
140 aliases: &["subagent", "spawn_agent"],
141 },
142 CanonicalTool {
146 name: "task",
147 aliases: &[
148 "todo_read",
149 "todoread",
150 "todo_write",
151 "todowrite",
152 "task_create",
153 "taskcreate",
154 "task_get",
155 "taskget",
156 "task_list",
157 "tasklist",
158 "task_update",
159 "taskupdate",
160 "update_plan",
161 ],
162 },
163 CanonicalTool {
167 name: "skill",
168 aliases: &[],
169 },
170 CanonicalTool {
171 name: "workflow",
172 aliases: &[],
173 },
174 CanonicalTool {
175 name: "glob",
176 aliases: &["find"],
177 },
178 CanonicalTool {
179 name: "grep",
180 aliases: &["rg", "search", "ripgrep"],
181 },
182 CanonicalTool {
183 name: "notebook",
184 aliases: &["jupyter", "notebook_edit", "notebookedit"],
185 },
186 CanonicalTool {
188 name: "web",
189 aliases: &[],
190 },
191 CanonicalTool {
192 name: "web_search",
193 aliases: &["websearch"],
194 },
195 CanonicalTool {
196 name: "web_fetch",
197 aliases: &["webfetch", "fetch", "curl"],
198 },
199 CanonicalTool {
200 name: "ask_user",
201 aliases: &[
202 "askuser",
203 "request_user_input",
204 "ask_question",
205 "ask_user_question",
206 "askuserquestion",
207 ],
208 },
209 CanonicalTool {
210 name: "cron",
211 aliases: &[],
212 },
213 CanonicalTool {
214 name: "notifications",
215 aliases: &["pushnotification", "push_notification"],
216 },
217 CanonicalTool {
218 name: "plan_mode",
219 aliases: &["planmode", "switch_mode"],
220 },
221 CanonicalTool {
222 name: "worktree",
223 aliases: &[],
224 },
225 CanonicalTool {
226 name: "lsp",
227 aliases: &[],
228 },
229 CanonicalTool {
230 name: "monitor",
231 aliases: &[],
232 },
233 CanonicalTool {
234 name: "send_user_file",
235 aliases: &["senduserfile"],
236 },
237 CanonicalTool {
238 name: "schedule_wakeup",
239 aliases: &["schedulewakeup"],
240 },
241 CanonicalTool {
242 name: "remote_trigger",
243 aliases: &["remotetrigger"],
244 },
245 CanonicalTool {
246 name: "tool_search",
247 aliases: &["toolsearch"],
248 },
249 CanonicalTool {
253 name: "ls",
254 aliases: &[],
255 },
256 CanonicalTool {
258 name: "work",
259 aliases: &[],
260 },
261 CanonicalTool {
262 name: "image",
263 aliases: &["view_image"],
264 },
265];
266
267pub fn parse_mars_tool_name(raw: &str) -> Result<ParsedToolName, ToolNameParseError> {
268 let trimmed = raw.trim();
269 if trimmed.is_empty() {
270 return Err(ToolNameParseError::Empty);
271 }
272
273 let (head, payload) = split_tool_name(trimmed);
274 let head = head.trim();
275 if head.is_empty() {
276 return Err(ToolNameParseError::Empty);
277 }
278
279 if let Some(mcp_ref) = try_parse_mcp_tool_name(trimmed) {
280 return Ok(ParsedToolName {
281 capability: Capability::Mcp(mcp_ref),
282 pattern: None,
283 });
284 }
285
286 if head.eq_ignore_ascii_case("mcp") {
289 return Err(ToolNameParseError::InvalidMcpRef);
290 }
291
292 let pattern = parse_pattern(payload)?;
293
294 if head == "*" {
295 if pattern.is_some() {
296 return Err(ToolNameParseError::WildcardPattern);
297 }
298 return Ok(ParsedToolName {
299 capability: Capability::Any,
300 pattern: None,
301 });
302 }
303
304 let capability = canonicalize_head(head);
305 let pattern = pattern.map(|pattern| match capability {
306 Capability::Builtin("bash") => normalize_bash_pattern(&pattern),
307 _ => pattern,
308 });
309 Ok(ParsedToolName {
310 capability,
311 pattern,
312 })
313}
314
315fn parse_pattern(payload: &str) -> Result<Option<String>, ToolNameParseError> {
316 let payload = payload.trim();
317 if payload.is_empty() {
318 return Ok(None);
319 }
320 let inner = payload
321 .strip_prefix('(')
322 .and_then(|rest| rest.strip_suffix(')'))
323 .ok_or(ToolNameParseError::UnbalancedPattern)?
324 .trim();
325 if inner.is_empty() {
326 return Err(ToolNameParseError::EmptyPattern);
327 }
328 Ok(Some(inner.to_string()))
329}
330
331fn normalize_bash_pattern(pattern: &str) -> String {
336 match pattern.strip_suffix(":*") {
337 Some(prefix) => format!("{prefix} *"),
338 None => pattern.to_string(),
339 }
340}
341
342#[cfg(test)]
343pub fn is_canonical_capability(name: &str) -> bool {
344 canonical_tool_name(name).is_some()
345}
346
347pub fn canonical_capabilities() -> impl Iterator<Item = &'static str> {
348 CANONICAL_TOOLS.iter().map(|tool| tool.name)
349}
350
351#[derive(Debug, Clone, Serialize)]
353pub struct CapabilityEntry {
354 pub name: &'static str,
355 pub aliases: &'static [&'static str],
356}
357
358pub fn capabilities() -> Vec<CapabilityEntry> {
360 CANONICAL_TOOLS
361 .iter()
362 .map(|tool| CapabilityEntry {
363 name: tool.name,
364 aliases: tool.aliases,
365 })
366 .collect()
367}
368
369pub fn split_tool_name(value: &str) -> (&str, &str) {
371 match value.find('(') {
372 Some(index) => (&value[..index], &value[index..]),
373 None => (value, ""),
374 }
375}
376
377pub fn is_mcp_wire_token(name: &str) -> bool {
383 name.trim()
384 .get(..5)
385 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("mcp__"))
386}
387
388fn canonicalize_head(head: &str) -> Capability {
389 if is_mcp_wire_token(head) {
390 return Capability::Unknown(head.to_string());
391 }
392
393 let lowercase = head.to_ascii_lowercase();
394 if let Some(canonical) = canonical_tool_name(&lowercase).or_else(|| canonical_alias(&lowercase))
395 {
396 return Capability::Builtin(canonical);
397 }
398
399 if head.contains('_') || is_all_caps(head) {
400 return Capability::Unknown(lowercase);
401 }
402
403 if is_mixed_case(head) {
404 let snake = pascal_to_snake(head);
405 return match canonical_tool_name(&snake) {
406 Some(canonical) => Capability::Builtin(canonical),
407 None => Capability::Unknown(snake),
408 };
409 }
410
411 Capability::Unknown(head.to_string())
412}
413
414fn canonical_tool_name(name: &str) -> Option<&'static str> {
415 CANONICAL_TOOLS
416 .iter()
417 .find(|tool| tool.name == name)
418 .map(|tool| tool.name)
419}
420
421fn canonical_alias(alias: &str) -> Option<&'static str> {
422 CANONICAL_TOOLS
423 .iter()
424 .find(|tool| tool.aliases.contains(&alias))
425 .map(|tool| tool.name)
426}
427
428fn is_all_caps(s: &str) -> bool {
429 let mut has_upper = false;
430 for ch in s.chars().filter(|ch| ch.is_ascii_alphabetic()) {
431 if ch.is_ascii_lowercase() {
432 return false;
433 }
434 has_upper = true;
435 }
436 has_upper
437}
438
439fn is_mixed_case(s: &str) -> bool {
440 s.chars().any(|ch| ch.is_ascii_uppercase()) && s.chars().any(|ch| ch.is_ascii_lowercase())
441}
442
443fn pascal_to_snake(s: &str) -> String {
444 let mut out = String::new();
445 for (index, ch) in s.chars().enumerate() {
446 if ch.is_ascii_uppercase() {
447 if index > 0 {
448 out.push('_');
449 }
450 out.push(ch.to_ascii_lowercase());
451 } else {
452 out.push(ch);
453 }
454 }
455 out
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 fn parse(raw: &str) -> ParsedToolName {
463 parse_mars_tool_name(raw).unwrap()
464 }
465
466 fn render(parsed: &ParsedToolName) -> String {
467 render_scoped(parsed.capability.name(), parsed.pattern.as_deref())
468 }
469
470 #[test]
471 fn parses_known_tools_and_aliases_to_snake_case() {
472 let cases = [
473 ("bash", "bash"),
474 ("Bash", "bash"),
475 ("BASH", "bash"),
476 ("shell", "bash"),
477 ("ask_user", "ask_user"),
478 ("AskUser", "ask_user"),
479 ("askuser", "ask_user"),
480 ("LSP", "lsp"),
481 ("WebSearch", "web_search"),
482 ("cat", "read"),
483 ("rg", "grep"),
484 ("CustomTool", "custom_tool"),
485 ("my_custom", "my_custom"),
486 ("bash(git *)", "bash(git *)"),
487 ("skill(init)", "skill(init)"),
488 ("Skill(deep-research)", "skill(deep-research)"),
489 ("workflow", "workflow"),
490 ("web", "web"),
491 ("websearch", "web_search"),
492 ("webfetch", "web_fetch"),
493 ("AskUserQuestion", "ask_user"),
494 ("NotebookEdit", "notebook"),
495 ("TaskCreate", "task"),
496 ("TodoWrite", "task"),
497 ("update_plan", "task"),
498 ("subagent", "agent"),
499 ];
500
501 for (raw, expected) in cases {
502 assert_eq!(render(&parse(raw)), expected, "raw: {raw}");
503 }
504 }
505
506 #[test]
507 fn task_means_task_list_not_sub_agents() {
508 assert_eq!(parse("task").capability, Capability::Builtin("task"));
511 assert_eq!(parse("Task").capability, Capability::Builtin("task"));
512 assert_eq!(parse("agent").capability, Capability::Builtin("agent"));
513 }
514
515 #[test]
516 fn rejects_empty_tool_names() {
517 assert_eq!(parse_mars_tool_name(""), Err(ToolNameParseError::Empty));
518 assert_eq!(
519 parse_mars_tool_name("(git *)"),
520 Err(ToolNameParseError::Empty)
521 );
522 }
523
524 #[test]
525 fn star_is_the_any_capability_and_takes_no_pattern() {
526 assert_eq!(parse("*").capability, Capability::Any);
527 assert_eq!(
528 parse_mars_tool_name("*(x)"),
529 Err(ToolNameParseError::WildcardPattern)
530 );
531 }
532
533 #[test]
534 fn non_ascii_unknown_tools_do_not_panic() {
535 for raw in ["café_tool", "🎉🎉tool", "🚀tool"] {
536 let parsed = parse_mars_tool_name(raw).expect("parse should not panic");
537 assert_eq!(render(&parsed), raw);
538 assert!(matches!(parsed.capability, Capability::Unknown(_)));
539 }
540 }
541
542 #[test]
543 fn mcp_wire_tokens_stay_verbatim_unknown_capabilities() {
544 for raw in ["mcp__github__CreateIssue", "MCP__Server__Tool"] {
545 let parsed = parse(raw);
546 assert_eq!(parsed.capability, Capability::Unknown(raw.to_string()));
547 assert!(is_mcp_wire_token(raw));
548 }
549 }
550
551 #[test]
552 fn parse_recognizes_valid_mcp_scoped_refs() {
553 let Capability::Mcp(mcp_ref) = parse("mcp(GitHub/CreateIssue)").capability else {
554 panic!("expected an MCP capability");
555 };
556 assert_eq!(mcp_ref.to_pattern(), "GitHub/CreateIssue");
557
558 let Capability::Mcp(mcp_ref) = parse("mcp(github)").capability else {
559 panic!("expected an MCP capability");
560 };
561 assert_eq!(mcp_ref.to_pattern(), "github/*");
562 assert_eq!(parse("mcp(github)").pattern, None);
563 }
564
565 #[test]
566 fn rejects_malformed_and_bare_mcp() {
567 for raw in ["mcp()", "mcp(/x)", "mcp(x/)", "mcp(a/b/c)", "mcp", "MCP"] {
569 assert_eq!(
570 parse_mars_tool_name(raw),
571 Err(ToolNameParseError::InvalidMcpRef),
572 "expected rejection for {raw}"
573 );
574 }
575 }
576}