Skip to main content

microclaw_engine/tools/
mcp.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4
5use crate::mcp::{McpServer, McpToolInfo};
6use microclaw_core::llm_types::ToolDefinition;
7
8use super::{Tool, ToolResult};
9
10pub struct McpTool {
11    server: Arc<McpServer>,
12    tool_info: McpToolInfo,
13    qualified_name: String,
14}
15
16impl McpTool {
17    pub fn new(server: Arc<McpServer>, tool_info: McpToolInfo) -> Self {
18        // Namespaced name: mcp_{server}_{tool} to avoid conflicts with built-in tools
19        let qualified_name = format!("mcp_{}_{}", tool_info.server_name, tool_info.name);
20        // Sanitize: tool names must match [a-zA-Z0-9_-]{1,64}
21        let qualified_name: String = qualified_name
22            .chars()
23            .map(|c| {
24                if c.is_alphanumeric() || c == '_' || c == '-' {
25                    c
26                } else {
27                    '_'
28                }
29            })
30            .take(64)
31            .collect();
32
33        McpTool {
34            server,
35            tool_info,
36            qualified_name,
37        }
38    }
39
40    fn classify_mcp_error_type(err: &str) -> &'static str {
41        let lower = err.to_ascii_lowercase();
42        if lower.contains("rate-limited") {
43            "mcp_rate_limited"
44        } else if lower.contains("busy; exceeded queue wait") {
45            "mcp_bulkhead_rejected"
46        } else if lower.contains("circuit open") {
47            "mcp_circuit_open"
48        } else {
49            "mcp_error"
50        }
51    }
52
53    fn sanitize_input_for_mcp(input: serde_json::Value) -> serde_json::Value {
54        let mut map = input.as_object().cloned().unwrap_or_default();
55        map.retain(|k, _| !k.starts_with("__microclaw_"));
56        serde_json::Value::Object(map)
57    }
58}
59
60#[async_trait]
61impl Tool for McpTool {
62    fn name(&self) -> &str {
63        &self.qualified_name
64    }
65
66    fn definition(&self) -> ToolDefinition {
67        ToolDefinition {
68            name: self.qualified_name.clone(),
69            description: format!(
70                "[MCP:{}] {}",
71                self.tool_info.server_name, self.tool_info.description
72            ),
73            input_schema: self.tool_info.input_schema.clone(),
74        }
75    }
76
77    async fn execute(&self, input: serde_json::Value) -> ToolResult {
78        let sanitized_input = Self::sanitize_input_for_mcp(input);
79        match self
80            .server
81            .call_tool(&self.tool_info.name, sanitized_input)
82            .await
83        {
84            Ok(output) => ToolResult::success(output),
85            Err(e) => ToolResult::error(format!("MCP tool error: {e}"))
86                .with_error_type(Self::classify_mcp_error_type(&e)),
87        }
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::McpTool;
94    use serde_json::json;
95
96    #[test]
97    fn test_classify_mcp_error_type() {
98        assert_eq!(
99            McpTool::classify_mcp_error_type("rate-limited; retry in 3s"),
100            "mcp_rate_limited"
101        );
102        assert_eq!(
103            McpTool::classify_mcp_error_type("busy; exceeded queue wait of 200ms"),
104            "mcp_bulkhead_rejected"
105        );
106        assert_eq!(
107            McpTool::classify_mcp_error_type("circuit open; retry in 10s"),
108            "mcp_circuit_open"
109        );
110        assert_eq!(
111            McpTool::classify_mcp_error_type("transport disconnected"),
112            "mcp_error"
113        );
114    }
115
116    #[test]
117    fn test_sanitize_input_for_mcp_strips_internal_keys() {
118        let input = json!({
119            "query": "status",
120            "__microclaw_auth": {"caller_chat_id": 1},
121            "__microclaw_async": true,
122            "__microclaw_other": "x"
123        });
124        let out = McpTool::sanitize_input_for_mcp(input);
125        assert_eq!(out, json!({"query": "status"}));
126    }
127}