Skip to main content

lean_ctx_server/tools/registered/
ctx_call.rs

1// SPDX-License-Identifier: Apache-2.0
2use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5
6use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_str};
7use lean_ctx::tool_defs::tool_def;
8
9pub struct CtxCallTool;
10
11pub(crate) type ResolvedCall = (String, Option<Map<String, Value>>);
12
13/// Normalize the advertised `ctx` alias without discarding `ctx_call` gates.
14pub(crate) fn normalize_outer(
15    name: &str,
16    args: Option<&Map<String, Value>>,
17) -> Result<ResolvedCall, ErrorData> {
18    if name != "ctx" {
19        return Ok((name.to_owned(), args.cloned()));
20    }
21    let sub = args
22        .and_then(|args| get_str(args, "tool"))
23        .ok_or_else(|| ErrorData::invalid_params("'tool' is required for ctx meta-tool", None))?;
24    let name = if sub.starts_with("ctx_") {
25        sub
26    } else {
27        format!("ctx_{sub}")
28    };
29    let mut args = args.cloned().unwrap_or_default();
30    args.remove("tool");
31    Ok((name, Some(args)))
32}
33
34/// The same argument decoder must serve admission, planning and dispatch.
35pub(crate) fn resolve_inner(args: Option<&Map<String, Value>>) -> Result<ResolvedCall, ErrorData> {
36    let inner = args.and_then(|args| get_str(args, "name")).ok_or_else(|| {
37        let hint = args
38            .and_then(|args| {
39                ["tool", "tool_name", "toolName"]
40                    .iter()
41                    .find(|key| args.contains_key(**key))
42            })
43            .map_or(String::new(), |bad| {
44                format!(" (found '{bad}' — the key is 'name')")
45            });
46        ErrorData::invalid_params(format!("name is required{hint}"), None)
47    })?;
48    if inner == "ctx_call" {
49        return Err(ErrorData::invalid_params(
50            "ctx_call cannot invoke itself",
51            None,
52        ));
53    }
54    // Internal host hooks (ctx_shape) stay reachable here on purpose: hosts that
55    // only call tools the server lists (Claude Code 2.1.296+ `$.mcp.call`)
56    // reach an unlisted hook through the listed ctx_call (#2089). They remain
57    // out of tools/list and discovery.
58    let arguments = match args.and_then(|args| args.get("arguments")) {
59        None | Some(Value::Null) => {
60            if let Some(args) = args
61                && let Some(bad) = ["args", "params", "parameters", "arg"]
62                    .iter()
63                    .find(|key| args.contains_key(**key))
64            {
65                return Err(ErrorData::invalid_params(
66                    format!(
67                        "unknown key '{bad}' — pass the inner tool's arguments under 'arguments'"
68                    ),
69                    None,
70                ));
71            }
72            // Preserve the existing flattened-call contract, including null.
73            let flattened: Map<String, Value> = args
74                .into_iter()
75                .flat_map(|args| args.iter())
76                .filter(|(key, _)| key.as_str() != "name")
77                .map(|(key, value)| (key.clone(), value.clone()))
78                .collect();
79            (!flattened.is_empty()).then_some(flattened)
80        }
81        Some(Value::Object(map)) => Some(map.clone()),
82        Some(_) => {
83            return Err(ErrorData::invalid_params(
84                "arguments must be an object",
85                None,
86            ));
87        }
88    };
89    Ok((inner, arguments))
90}
91
92/// Resolve semantic task identity; the actual dispatcher retains both gates.
93pub(crate) fn resolve(
94    name: &str,
95    args: Option<&Map<String, Value>>,
96) -> Result<ResolvedCall, ErrorData> {
97    let (name, args) = normalize_outer(name, args)?;
98    if name == "ctx_call" {
99        resolve_inner(args.as_ref())
100    } else {
101        Ok((name, args))
102    }
103}
104
105/// Whether the tool that runs for `name` + `args` declares that it reads
106/// every source through the admitted readers (`McpTool::publishes_every_source`).
107/// A `ctx_call` wrapper is unwrapped one level, as admission does; an unknown
108/// tool or an unresolvable wrapper declares nothing. Feeds
109/// `core::archive::delivered::record_tool_delivery`.
110#[must_use]
111pub fn declares_every_source(
112    registry: &crate::server::registry::ToolRegistry,
113    name: &str,
114    args: Option<&Map<String, Value>>,
115) -> bool {
116    resolve(name, args)
117        .ok()
118        .and_then(|(inner, inner_args)| {
119            let tool = registry.get_arc(&inner)?;
120            Some(tool.publishes_every_source(inner_args.as_ref()))
121        })
122        .unwrap_or(false)
123}
124
125impl McpTool for CtxCallTool {
126    fn name(&self) -> &'static str {
127        "ctx_call"
128    }
129
130    fn tool_def(&self) -> Tool {
131        tool_def(
132            "ctx_call",
133            "Invoke any non-core lean-ctx tool by name — for tools not exposed as standalone MCP tools.\n\
134            Categories: arch, debug, memory, batch, agent, util. Find exact names with\n\
135            ctx_discover_tools (query=keyword; empty query lists all). Cannot invoke itself.",
136            json!({
137                "type": "object",
138                "properties": {
139                    "name": { "type": "string", "description": "Tool name" },
140                    "arguments": { "type": "object",                         "description": "Tool arguments" }
141                },
142                "required": ["name"]
143            }),
144        )
145    }
146
147    fn handle(
148        &self,
149        args: &Map<String, Value>,
150        _ctx: &ToolContext,
151    ) -> Result<ToolOutput, ErrorData> {
152        let name = get_str(args, "name")
153            .ok_or_else(|| ErrorData::invalid_params("'name' is required", None))?;
154
155        if name == "ctx_call" {
156            return Err(ErrorData::invalid_params(
157                "ctx_call cannot invoke itself",
158                None,
159            ));
160        }
161
162        Err(ErrorData::internal_error(
163            format!(
164                "ctx_call dispatch for '{name}' must be handled by the async dispatch layer. \
165                 If you see this error, the tool was routed to the sync handler by mistake."
166            ),
167            None,
168        ))
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn nested_flattened_and_universal_calls_resolve_the_same_semantics() {
178        let expected = (
179            "ctx_compose".to_owned(),
180            json!({"task":"inner task"}).as_object().cloned(),
181        );
182        for (name, args) in [
183            ("ctx_compose", json!({"task":"inner task"})),
184            ("ctx", json!({"tool":"compose", "task":"inner task"})),
185            ("ctx", json!({"tool":"ctx_compose", "task":"inner task"})),
186            (
187                "ctx_call",
188                json!({"name":"ctx_compose", "arguments":{"task":"inner task"}}),
189            ),
190            (
191                "ctx_call",
192                json!({"name":"ctx_compose", "task":"inner task"}),
193            ),
194            (
195                "ctx",
196                json!({"tool":"call", "name":"ctx_compose", "arguments":{"task":"inner task"}}),
197            ),
198        ] {
199            assert_eq!(resolve(name, args.as_object()).unwrap(), expected);
200        }
201    }
202
203    #[test]
204    fn nested_arguments_win_and_flattened_null_contract_is_preserved() {
205        let nested = json!({"name":"ctx_search", "query":"outer", "path":"outer", "arguments":{"query":"inner", "path":"inner"}});
206        assert_eq!(
207            resolve_inner(nested.as_object()).unwrap().1,
208            json!({"query":"inner", "path":"inner"})
209                .as_object()
210                .cloned()
211        );
212        let null = json!({"name":"ctx_search", "arguments":null, "query":"flat"});
213        assert_eq!(
214            resolve_inner(null.as_object()).unwrap().1,
215            json!({"arguments":null,"query":"flat"})
216                .as_object()
217                .cloned()
218        );
219        assert_eq!(
220            resolve_inner(json!({"name":"ctx_search"}).as_object())
221                .unwrap()
222                .1,
223            None
224        );
225    }
226
227    #[test]
228    fn invalid_envelopes_fail_before_any_inner_invocation() {
229        for args in [
230            json!({}),
231            json!({"tool":"ctx_search"}),
232            json!({"name":42}),
233            json!({"name":"ctx_call"}),
234            json!({"name":"ctx_search", "arguments":[]}),
235            json!({"name":"ctx_search", "arguments":42}),
236            json!({"name":"ctx_search", "arguments":false}),
237            json!({"name":"ctx_search", "arguments":"query"}),
238        ] {
239            assert!(resolve_inner(args.as_object()).is_err(), "{args}");
240        }
241        for key in ["args", "params", "parameters", "arg"] {
242            let mut args = json!({"name":"ctx_search"});
243            args[key] = json!({"query":"quasar"});
244            let error = resolve_inner(args.as_object()).unwrap_err();
245            assert!(error.message.contains(key));
246        }
247        assert!(normalize_outer("ctx", None).is_err());
248    }
249}