1use std::sync::Arc;
5
6use async_trait::async_trait;
7
8use crate::tools::mcp_client::McpRegistry;
9use crate::tools::mcp_protocol::McpToolDef;
10use crate::tools::traits::{Tool, ToolResult};
11
12pub struct McpToolWrapper {
17 prefixed_name: String,
19 description: String,
22 input_schema: serde_json::Value,
24 registry: Arc<McpRegistry>,
26}
27
28impl McpToolWrapper {
29 pub fn new(prefixed_name: String, def: McpToolDef, registry: Arc<McpRegistry>) -> Self {
30 let description = def.description.unwrap_or_else(|| "MCP tool".to_string());
31 Self {
32 prefixed_name,
33 description,
34 input_schema: def.input_schema,
35 registry,
36 }
37 }
38}
39
40#[async_trait]
41impl Tool for McpToolWrapper {
42 fn name(&self) -> &str {
43 &self.prefixed_name
44 }
45
46 fn description(&self) -> &str {
47 &self.description
48 }
49
50 fn parameters_schema(&self) -> serde_json::Value {
51 self.input_schema.clone()
52 }
53
54 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
55 let args = match args {
66 serde_json::Value::Object(mut map) => {
67 map.remove("approved");
68 coerce_string_numerics(&mut map, &self.input_schema);
69 serde_json::Value::Object(map)
70 }
71 other => other,
72 };
73 match self.registry.call_tool(&self.prefixed_name, args).await {
74 Ok(output) => Ok(ToolResult {
75 success: true,
76 output,
77 error: None,
78 }),
79 Err(e) => Ok(ToolResult {
80 success: false,
81 output: String::new(),
82 error: Some(e.to_string()),
83 }),
84 }
85 }
86}
87
88fn coerce_string_numerics(
94 map: &mut serde_json::Map<String, serde_json::Value>,
95 schema: &serde_json::Value,
96) {
97 let props = match schema.get("properties").and_then(|p| p.as_object()) {
98 Some(p) => p,
99 None => return,
100 };
101 for (key, prop_schema) in props {
102 let expected_type = match prop_schema.get("type").and_then(|t| t.as_str()) {
103 Some(t) => t,
104 None => continue,
105 };
106 if let Some(serde_json::Value::String(s)) = map.get(key) {
107 match expected_type {
108 "integer" => {
109 if let Ok(n) = s.parse::<i64>() {
110 map.insert(key.clone(), serde_json::Value::Number(n.into()));
111 }
112 }
113 "number" => {
114 if let Ok(n) = s.parse::<f64>() {
115 if let Some(num) = serde_json::Number::from_f64(n) {
116 map.insert(key.clone(), serde_json::Value::Number(num));
117 }
118 }
119 }
120 "boolean" => match s.as_str() {
121 "true" => {
122 map.insert(key.clone(), serde_json::Value::Bool(true));
123 }
124 "false" => {
125 map.insert(key.clone(), serde_json::Value::Bool(false));
126 }
127 _ => {}
128 },
129 "array" => {
130 if s.starts_with('[') {
132 if let Ok(arr) = serde_json::from_str::<serde_json::Value>(s) {
133 if arr.is_array() {
134 map.insert(key.clone(), arr);
135 }
136 }
137 }
138 }
139 "object" => {
140 if s.starts_with('{') {
142 if let Ok(obj) = serde_json::from_str::<serde_json::Value>(s) {
143 if obj.is_object() {
144 map.insert(key.clone(), obj);
145 }
146 }
147 }
148 }
149 _ => {}
150 }
151 }
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use serde_json::json;
159
160 fn make_def(name: &str, description: Option<&str>, schema: serde_json::Value) -> McpToolDef {
161 McpToolDef {
162 name: name.to_string(),
163 description: description.map(str::to_string),
164 input_schema: schema,
165 }
166 }
167
168 async fn empty_registry() -> Arc<McpRegistry> {
169 Arc::new(
170 McpRegistry::connect_all(&[])
171 .await
172 .expect("empty connect_all should succeed"),
173 )
174 }
175
176 #[tokio::test]
179 async fn name_returns_prefixed_name() {
180 let registry = empty_registry().await;
181 let def = make_def("read_file", Some("Reads a file"), json!({}));
182 let wrapper = McpToolWrapper::new("filesystem__read_file".to_string(), def, registry);
183 assert_eq!(wrapper.name(), "filesystem__read_file");
184 }
185
186 #[tokio::test]
187 async fn description_returns_def_description() {
188 let registry = empty_registry().await;
189 let def = make_def("navigate", Some("Navigate browser"), json!({}));
190 let wrapper = McpToolWrapper::new("playwright__navigate".to_string(), def, registry);
191 assert_eq!(wrapper.description(), "Navigate browser");
192 }
193
194 #[tokio::test]
195 async fn description_falls_back_to_mcp_tool_when_none() {
196 let registry = empty_registry().await;
197 let def = make_def("mystery", None, json!({}));
198 let wrapper = McpToolWrapper::new("srv__mystery".to_string(), def, registry);
199 assert_eq!(wrapper.description(), "MCP tool");
200 }
201
202 #[tokio::test]
203 async fn parameters_schema_returns_input_schema() {
204 let registry = empty_registry().await;
205 let schema = json!({
206 "type": "object",
207 "properties": { "path": { "type": "string" } },
208 "required": ["path"]
209 });
210 let def = make_def("read_file", Some("Read"), schema.clone());
211 let wrapper = McpToolWrapper::new("fs__read_file".to_string(), def, registry);
212 assert_eq!(wrapper.parameters_schema(), schema);
213 }
214
215 #[tokio::test]
216 async fn spec_returns_all_three_fields() {
217 let registry = empty_registry().await;
218 let schema = json!({ "type": "object", "properties": {} });
219 let def = make_def("list_dir", Some("List directory"), schema.clone());
220 let wrapper = McpToolWrapper::new("fs__list_dir".to_string(), def, registry);
221 let spec = wrapper.spec();
222 assert_eq!(spec.name, "fs__list_dir");
223 assert_eq!(spec.description, "List directory");
224 assert_eq!(spec.parameters, schema);
225 }
226
227 #[tokio::test]
230 async fn execute_returns_non_fatal_error_for_unknown_tool() {
231 let registry = empty_registry().await;
234 let def = make_def("ghost", Some("Ghost tool"), json!({}));
235 let wrapper = McpToolWrapper::new("nowhere__ghost".to_string(), def, registry);
236 let result = wrapper
237 .execute(json!({}))
238 .await
239 .expect("execute should be non-fatal");
240 assert!(!result.success);
241 let err_msg = result.error.expect("error message should be present");
242 assert!(
243 err_msg.contains("unknown MCP tool"),
244 "unexpected error: {err_msg}"
245 );
246 assert!(result.output.is_empty());
247 }
248
249 #[tokio::test]
250 async fn execute_success_sets_success_true_and_output() {
251 let _: ToolResult = ToolResult {
254 success: true,
255 output: "hello".to_string(),
256 error: None,
257 };
258 }
259
260 #[tokio::test]
266 async fn execute_strips_approved_field_from_object_args() {
267 let registry = empty_registry().await;
271 let def = make_def("do_thing", Some("Do a thing"), json!({}));
272 let wrapper = McpToolWrapper::new("srv__do_thing".to_string(), def, registry);
273 let result = wrapper
275 .execute(json!({ "approved": true, "param": "value" }))
276 .await
277 .expect("execute must be non-fatal even with approved field");
278 assert!(!result.success);
280 let err = result.error.unwrap_or_default();
282 assert!(
283 !err.to_lowercase().contains("approved"),
284 "approved field should have been stripped, but got: {err}"
285 );
286 }
287
288 #[test]
292 fn coerce_string_to_integer() {
293 let schema = json!({
294 "type": "object",
295 "properties": {
296 "limit": { "type": "integer" },
297 "name": { "type": "string" }
298 }
299 });
300 let mut map = serde_json::Map::new();
301 map.insert("limit".into(), json!("5"));
302 map.insert("name".into(), json!("hello"));
303 coerce_string_numerics(&mut map, &schema);
304 assert_eq!(map["limit"], json!(5));
305 assert_eq!(map["name"], json!("hello"));
306 }
307
308 #[test]
309 fn coerce_string_to_number() {
310 let schema = json!({
311 "type": "object",
312 "properties": { "score": { "type": "number" } }
313 });
314 let mut map = serde_json::Map::new();
315 map.insert("score".into(), json!("3.14"));
316 coerce_string_numerics(&mut map, &schema);
317 assert_eq!(map["score"], json!(3.14));
318 }
319
320 #[test]
321 fn coerce_leaves_already_correct_types() {
322 let schema = json!({
323 "type": "object",
324 "properties": { "limit": { "type": "integer" } }
325 });
326 let mut map = serde_json::Map::new();
327 map.insert("limit".into(), json!(10));
328 coerce_string_numerics(&mut map, &schema);
329 assert_eq!(map["limit"], json!(10));
330 }
331
332 #[test]
333 fn coerce_ignores_non_numeric_strings() {
334 let schema = json!({
335 "type": "object",
336 "properties": { "limit": { "type": "integer" } }
337 });
338 let mut map = serde_json::Map::new();
339 map.insert("limit".into(), json!("not_a_number"));
340 coerce_string_numerics(&mut map, &schema);
341 assert_eq!(map["limit"], json!("not_a_number"));
342 }
343
344 #[test]
345 fn coerce_string_to_array() {
346 let schema = json!({
347 "type": "object",
348 "properties": { "tags": { "type": "array", "items": { "type": "string" } } }
349 });
350 let mut map = serde_json::Map::new();
351 map.insert("tags".into(), json!("[\"rust\",\"testing\"]"));
352 coerce_string_numerics(&mut map, &schema);
353 assert_eq!(map["tags"], json!(["rust", "testing"]));
354 }
355
356 #[test]
357 fn coerce_string_to_boolean() {
358 let schema = json!({
359 "type": "object",
360 "properties": { "enabled": { "type": "boolean" } }
361 });
362 let mut map = serde_json::Map::new();
363 map.insert("enabled".into(), json!("true"));
364 coerce_string_numerics(&mut map, &schema);
365 assert_eq!(map["enabled"], json!(true));
366 }
367
368 #[tokio::test]
369 async fn execute_handles_non_object_args_without_panic() {
370 let registry = empty_registry().await;
373 let def = make_def("noop", None, json!({}));
374 let wrapper = McpToolWrapper::new("srv__noop".to_string(), def, registry);
375 for non_obj in [json!(null), json!("a string"), json!([1, 2, 3])] {
376 let result = wrapper
377 .execute(non_obj.clone())
378 .await
379 .expect("non-object args must not propagate Err");
380 assert!(!result.success, "expected non-fatal failure for {non_obj}");
381 }
382 }
383}