Skip to main content

construct/tools/
mcp_tool.rs

1//! Wraps a discovered MCP tool as a construct [`Tool`] so it is dispatched
2//! through the existing tool registry and agent loop without modification.
3
4use 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
12/// A construct [`Tool`] backed by an MCP server tool.
13///
14/// The `prefixed_name` (e.g. `filesystem__read_file`) is what the agent loop
15/// sees. The registry knows how to route it to the correct server.
16pub struct McpToolWrapper {
17    /// Prefixed name: `<server_name>__<tool_name>`.
18    prefixed_name: String,
19    /// Description extracted from the MCP tool definition. Stored as an owned
20    /// String so that `description()` can return `&str` with self's lifetime.
21    description: String,
22    /// JSON schema for the tool's input parameters.
23    input_schema: serde_json::Value,
24    /// Shared registry — used to dispatch actual tool calls.
25    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        // Strip the `approved` field before forwarding to the MCP server.
56        // Construct's security model injects `approved: bool` into built-in tool
57        // calls for supervised-mode confirmation. MCP servers have no knowledge
58        // of this field and will reject calls that include it as an unexpected
59        // parameter. We strip it here so MCP servers always receive clean args.
60        //
61        // Also coerce string-encoded numbers to their native JSON types when the
62        // schema declares `"type": "integer"` or `"type": "number"`. LLMs
63        // sometimes emit `"5"` instead of `5`, which causes MCP-side jsonschema
64        // validation to reject the call with "is not of type 'integer'".
65        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
88/// Coerce string-encoded values to their schema-declared types before MCP
89/// server-side jsonschema validation runs.  LLMs frequently emit:
90///   - `"5"` instead of `5` for integer parameters
91///   - `"[\"a\",\"b\"]"` instead of `["a","b"]` for array parameters
92///   - `"true"` instead of `true` for boolean parameters
93fn 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                    // Try parsing the string as a JSON array
131                    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                    // Try parsing the string as a JSON object
141                    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    // ── Accessor tests ─────────────────────────────────────────────────────
177
178    #[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    // ── execute() error path ───────────────────────────────────────────────
228
229    #[tokio::test]
230    async fn execute_returns_non_fatal_error_for_unknown_tool() {
231        // An empty registry has no tools — execute must return Ok(ToolResult { success: false })
232        // rather than propagating an Err (non-fatal by design).
233        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        // Verify the ToolResult success-branch struct shape compiles correctly.
252        // A real happy-path requires a live MCP server; that is covered by E2E tests.
253        let _: ToolResult = ToolResult {
254            success: true,
255            output: "hello".to_string(),
256            error: None,
257        };
258    }
259
260    // ── approved-field stripping ───────────────────────────────────────────
261    // Construct's security model injects `approved: bool` into built-in tool args.
262    // MCP servers are unaware of this field and reject calls that include it.
263    // execute() must strip it before forwarding.
264
265    #[tokio::test]
266    async fn execute_strips_approved_field_from_object_args() {
267        // The wrapper should remove `approved` before forwarding to the registry.
268        // We use an empty registry (returns "unknown MCP tool" error), but the key
269        // assertion is that the call does not fail due to an unexpected `approved` arg.
270        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        // With `approved` present the call must not propagate an Err — non-fatal.
274        let result = wrapper
275            .execute(json!({ "approved": true, "param": "value" }))
276            .await
277            .expect("execute must be non-fatal even with approved field");
278        // The registry returns a non-fatal error (unknown tool), not a panic/Err.
279        assert!(!result.success);
280        // Crucially: error must not mention `approved` as the cause.
281        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    // ── string→numeric coercion ─────────────────────────────────────────
289    // LLMs sometimes emit `"5"` instead of `5` for integer parameters.
290
291    #[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        // Non-object args (string, null, array) must pass through without panicking
371        // or returning an Err — the registry error path covers the failure case.
372        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}