lean_ctx_server/tools/registered/
ctx_call.rs1use 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
13pub(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
34pub(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 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 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
92pub(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#[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}