Skip to main content

mars_schema/
tool_names.rs

1//! Mars tool-name grammar.
2//!
3//! A tool entry is a *capability* (`bash`, `web`, …) with an optional *pattern*
4//! (`bash(git status *)`). [`parse_mars_tool_name`] canonicalizes the capability and
5//! normalizes the pattern. What a harness calls a capability lives in
6//! [`crate::tool_natives`].
7
8use 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/// What a tool-policy rule is about.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub enum Capability {
21    /// `*`: every tool not otherwise named. An explicit `'*': deny` makes an allowlist.
22    Any,
23    /// `mcp(server/tool)`.
24    Mcp(McpRef),
25    /// A capability in the vocabulary below.
26    Builtin(&'static str),
27    /// The author's spelling of a name Mars has no row for, or an `mcp__…` wire token that
28    /// passes through verbatim. Only a name that parsed gets here.
29    Unknown(String),
30}
31
32impl Capability {
33    /// The spelling in profiles and in the launch bundle's `capability` field.
34    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    /// Scope inside `capability(...)`; a `bash` command prefix is normalized (`X:*` → `X *`).
48    /// `None` for `*` and `mcp`, which carry their scope in the capability.
49    pub pattern: Option<String>,
50}
51
52/// A tool name written in `mars.toml` (`[agents.<name>.tools]`, `[skills.<name>.tools]`),
53/// parsed when the config loads: an overlay cannot carry a name a profile would reject.
54/// Keeps the author's spelling, so saving `mars.toml` does not rewrite it.
55#[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    // Sub-agents (Claude `Agent`, OpenCode `task`). Not the task-list tools: see `task`.
138    CanonicalTool {
139        name: "agent",
140        aliases: &["subagent", "spawn_agent"],
141    },
142    // Task-list tools (Claude `TaskCreate`…`TaskUpdate`, OpenCode `todowrite`, Codex
143    // `update_plan`). One capability: a profile gates the list, not each verb. `todoread` stays
144    // as an input spelling only; OpenCode has no such tool.
145    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    // Scoped payloads like skill(init) gate a specific skill via the harness skill tool.
164    // Recognition here only affects name normalization/projection — not whether a harness
165    // enforces disallowed_tools at runtime (Meridian-side).
166    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    // `web` = `web_search` + `web_fetch`: the capability profiles write for "reach the web".
187    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    // Viewing an image file. Only Codex has a tool for it (`view_image`); elsewhere the read
250    // tool opens images.
251    // Listing one folder. OpenCode `list` and Pi `ls` are native; `glob` stays "find by name".
252    CanonicalTool {
253        name: "ls",
254        aliases: &[],
255    },
256    // Meridian Flow's work tool; no harness has an equivalent.
257    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    // `mcp` is only ever a reference: a bare `mcp`, `MCP` or a malformed `mcp(...)` is an error,
287    // not an unknown tool that happens to share the bundle's `mcp` capability name.
288    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
331/// Claude's legacy command-prefix form `git revert:*` and the current `git revert *` mean
332/// the same thing, but OpenCode reads `:` literally. Mars writes only the space form. Only a
333/// shell command has a prefix form: `read(./secrets:*)` and `web_fetch(domain:*)` keep the
334/// colon, which is part of the path or the host.
335fn 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/// One capability and its accepted legacy spellings, as published in the vocabulary.
352#[derive(Debug, Clone, Serialize)]
353pub struct CapabilityEntry {
354    pub name: &'static str,
355    pub aliases: &'static [&'static str],
356}
357
358/// The authored capability vocabulary, including accepted legacy spellings.
359pub 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
369/// Split `head(payload)` into `head` and `(payload)`; a name without parentheses has no payload.
370pub 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
377/// Whether `name` is a Claude-style MCP wire token (`mcp__server__tool`, case-insensitive prefix).
378///
379/// Such a name passes to harnesses verbatim, casing included: the prefix is reserved on the MCP
380/// wire. The server/tool segments are not validated here, so whole-server `mcp__server__*` and
381/// global `mcp__*` forms pass through as written.
382pub 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        // `task` was an alias of `agent`. Shipped profiles write both `agent: deny` and
509        // `task: deny`, and they name different tools.
510        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        // A bare `mcp` or `MCP` names no server, so it is as invalid as `mcp()`.
568        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}