Skip to main content

agentmux_mcp/tools/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `call_tool`'s handlers, one file per tool family
5//! (docs/specs/SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md ยง4.2 (a)).
6//! Each family's `call` handles the tools it owns and returns
7//! `not_in_family()` for any other name, so no tool list is written twice.
8
9use super::*;
10
11mod accounts;
12mod browser;
13mod cron;
14mod fleet;
15mod global_memory;
16mod history;
17mod loops;
18mod memory;
19mod open_prs;
20mod overlap;
21mod panes;
22mod presets;
23mod pty_shell;
24mod shell;
25mod ui;
26mod widgets;
27mod work;
28
29/// What every handler may need from the MCP process.
30#[derive(Clone, Copy)]
31pub(crate) struct ToolCtx<'a> {
32    pub(crate) local_url: &'a str,
33    pub(crate) auth_key: &'a str,
34    pub(crate) block_id: &'a str,
35    pub(crate) client: &'a reqwest::Client,
36    pub(crate) loops: &'a LoopRegistry,
37    pub(crate) loop_counter: &'a AtomicU64,
38}
39
40/// The optional `pane` argument: a browser pane this agent opened with
41/// `OpenBrowser`. srv checks ownership; absent = the agent's own pane.
42fn pane_arg(arguments: &Value) -> Option<String> {
43    arguments
44        .get("pane")
45        .and_then(|v| v.as_str())
46        .map(str::trim)
47        .filter(|s| !s.is_empty())
48        .map(str::to_string)
49}
50
51/// A family's answer for a tool it doesn't own.
52#[derive(Debug)]
53struct NotInFamily;
54
55impl std::fmt::Display for NotInFamily {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        f.write_str("not in this tool family")
58    }
59}
60
61impl std::error::Error for NotInFamily {}
62
63fn not_in_family() -> anyhow::Error {
64    anyhow::Error::new(NotInFamily)
65}
66
67/// Try each family in turn; the first that owns `name` answers.
68pub(crate) async fn call(name: &str, arguments: &Value, cx: &ToolCtx<'_>) -> Result<String> {
69    macro_rules! route {
70        ($($family:ident),*) => {$(
71            match $family::call(name, arguments, cx).await {
72                Err(e) if e.is::<NotInFamily>() => {}
73                answered => return answered,
74            }
75        )*};
76    }
77    route!(
78        shell,
79        pty_shell,
80        panes,
81        fleet,
82        history,
83        overlap,
84        ui,
85        browser,
86        loops,
87        work,
88        cron,
89        memory,
90        global_memory,
91        presets,
92        accounts,
93        widgets
94    );
95    anyhow::bail!("unknown tool: {name}")
96}
97
98#[cfg(test)]
99mod tests {
100    use std::collections::BTreeMap;
101
102    const FAMILIES: &[(&str, &str)] = &[
103        ("accounts", include_str!("accounts.rs")),
104        ("browser", include_str!("browser.rs")),
105        ("cron", include_str!("cron.rs")),
106        ("fleet", include_str!("fleet.rs")),
107        ("global_memory", include_str!("global_memory.rs")),
108        ("history", include_str!("history.rs")),
109        ("loops", include_str!("loops.rs")),
110        ("memory", include_str!("memory.rs")),
111        ("overlap", include_str!("overlap.rs")),
112        ("panes", include_str!("panes.rs")),
113        ("presets", include_str!("presets.rs")),
114        ("pty_shell", include_str!("pty_shell.rs")),
115        ("shell", include_str!("shell.rs")),
116        ("ui", include_str!("ui.rs")),
117        ("widgets", include_str!("widgets.rs")),
118        ("work", include_str!("work.rs")),
119    ];
120
121    /// The tool names in a family's `match name` arms (`"A" | "B" => {`).
122    fn arm_names(src: &str) -> Vec<String> {
123        src.lines()
124            .filter_map(|l| l.strip_prefix("        \""))
125            .filter_map(|l| l.strip_suffix(" => {"))
126            .flat_map(|pat| pat.split(" | ").map(|n| n.trim_matches('"').to_string()))
127            .collect()
128    }
129
130    /// Every tool `tools/list` can advertise has exactly one handler, and
131    /// every handler belongs to an advertised tool. A name in two families
132    /// would make the later one unreachable.
133    #[test]
134    fn every_advertised_tool_has_exactly_one_handler() {
135        let mut owner: BTreeMap<String, &str> = BTreeMap::new();
136        for (family, src) in FAMILIES {
137            let names = arm_names(src);
138            assert!(!names.is_empty(), "{family} has no arms");
139            for n in names {
140                if let Some(prev) = owner.insert(n.clone(), family) {
141                    panic!("{n} is handled by both {prev} and {family}");
142                }
143            }
144        }
145        let schemas = include_str!("../tool_schemas.rs");
146        let advertised: Vec<String> = schemas
147            .lines()
148            .filter_map(|l| l.trim().strip_prefix("\"name\": \""))
149            .filter_map(|l| l.split('"').next())
150            .map(str::to_string)
151            .collect();
152        assert!(!advertised.is_empty());
153        for n in &advertised {
154            assert!(owner.contains_key(n), "{n} is advertised but has no handler");
155        }
156        for n in owner.keys() {
157            assert!(advertised.contains(n), "{n} has a handler but no schema");
158        }
159    }
160
161    #[tokio::test]
162    async fn an_unknown_tool_is_reported_by_name() {
163        let client = reqwest::Client::new();
164        let loops = super::LoopRegistry::default();
165        let counter = std::sync::atomic::AtomicU64::new(0);
166        let cx = super::ToolCtx {
167            local_url: "",
168            auth_key: "",
169            block_id: "",
170            client: &client,
171            loops: &loops,
172            loop_counter: &counter,
173        };
174        let err = super::call("NoSuchTool", &serde_json::json!({}), &cx)
175            .await
176            .unwrap_err();
177        assert_eq!(err.to_string(), "unknown tool: NoSuchTool");
178    }
179}