Skip to main content

a3s_code_core/mcp/
tools.rs

1//! MCP Tools Integration
2//!
3//! Integrates MCP tools with the A3S Code tool system.
4
5use crate::mcp::manager::McpManager;
6use crate::mcp::protocol::McpTool;
7use crate::mcp::result::project_tool_result;
8use crate::tools::{Tool, ToolContext, ToolOutput};
9use anyhow::Result;
10use async_trait::async_trait;
11use std::sync::Arc;
12
13/// MCP tool wrapper that implements the Tool trait
14pub struct McpToolWrapper {
15    /// Full tool name (mcp__server__tool)
16    full_name: String,
17    /// Original MCP tool definition
18    mcp_tool: McpTool,
19    /// Server name
20    server_name: String,
21    /// MCP manager reference
22    manager: Arc<McpManager>,
23}
24
25impl McpToolWrapper {
26    /// Create a new MCP tool wrapper
27    pub fn new(server_name: String, mcp_tool: McpTool, manager: Arc<McpManager>) -> Self {
28        let full_name = format!("mcp__{}__{}", server_name, mcp_tool.name);
29        Self {
30            full_name,
31            mcp_tool,
32            server_name,
33            manager,
34        }
35    }
36
37    /// Get the server name
38    pub fn server_name(&self) -> &str {
39        &self.server_name
40    }
41
42    /// Get the original MCP tool name
43    pub fn mcp_tool_name(&self) -> &str {
44        &self.mcp_tool.name
45    }
46}
47
48pub(super) fn annotation_requires_confirmation(tool: &McpTool) -> bool {
49    let Some(annotations) = tool.annotations.as_ref() else {
50        // Missing behavior metadata is unknown, not read-only.
51        return true;
52    };
53
54    if annotations.destructive_hint == Some(true)
55        || annotations.read_only_hint != Some(true)
56        || annotations.open_world_hint != Some(false)
57    {
58        return true;
59    }
60
61    annotations
62        .additional
63        .iter()
64        .any(|(key, value)| custom_risk_requires_confirmation(key, value))
65        || tool.meta.as_ref().is_some_and(|meta| {
66            meta.as_object().is_some_and(|fields| {
67                fields
68                    .iter()
69                    .any(|(key, value)| custom_risk_requires_confirmation(key, value))
70            })
71        })
72}
73
74fn custom_risk_requires_confirmation(key: &str, value: &serde_json::Value) -> bool {
75    let key = key.to_ascii_lowercase();
76    if !matches!(
77        key.as_str(),
78        "x-a3s-risk" | "a3s/risk" | "a3s.risk" | "risk"
79    ) {
80        return false;
81    }
82
83    match value {
84        serde_json::Value::String(value) => !matches!(
85            value.trim().to_ascii_lowercase().as_str(),
86            "read" | "read_only" | "read-only" | "routine" | "closed_world_read"
87        ),
88        serde_json::Value::Array(values) => values
89            .iter()
90            .any(|value| custom_risk_requires_confirmation(key.as_str(), value)),
91        // A declared but malformed risk value cannot reduce confirmation.
92        _ => true,
93    }
94}
95
96#[async_trait]
97impl Tool for McpToolWrapper {
98    fn name(&self) -> &str {
99        &self.full_name
100    }
101
102    fn description(&self) -> &str {
103        self.mcp_tool.description.as_deref().unwrap_or("MCP tool")
104    }
105
106    fn parameters(&self) -> serde_json::Value {
107        self.mcp_tool.input_schema.clone()
108    }
109
110    fn requires_confirmation(&self, _args: &serde_json::Value) -> bool {
111        annotation_requires_confirmation(&self.mcp_tool)
112    }
113
114    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
115        if ctx.is_cancelled() {
116            return Ok(ToolOutput::error(format!(
117                "MCP tool '{}' cancelled by caller",
118                self.full_name
119            )));
120        }
121
122        let cancellation = ctx.cancellation_token();
123        let call = self.manager.call_server_tool(
124            &self.server_name,
125            &self.mcp_tool.name,
126            Some(args.clone()),
127        );
128        let result = tokio::select! {
129            _ = cancellation.cancelled() => {
130                return Ok(ToolOutput::error(format!(
131                    "MCP tool '{}' cancelled by caller",
132                    self.full_name
133                )));
134            }
135            result = call => result,
136        };
137
138        match result {
139            Ok(tool_result) => project_tool_result(&self.full_name, &tool_result, ctx).await,
140            Err(e) => Ok(ToolOutput::error(format!("MCP tool error: {}", e))),
141        }
142    }
143}
144
145/// Create tool wrappers for all tools from an MCP server
146pub fn create_mcp_tools(
147    server_name: &str,
148    tools: Vec<McpTool>,
149    manager: Arc<McpManager>,
150) -> Vec<Arc<dyn Tool>> {
151    tools
152        .into_iter()
153        .map(|tool| {
154            Arc::new(McpToolWrapper::new(
155                server_name.to_string(),
156                tool,
157                manager.clone(),
158            )) as Arc<dyn Tool>
159        })
160        .collect()
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::mcp::protocol::McpToolAnnotations;
167    use crate::tools::Tool;
168    use std::collections::HashMap;
169
170    #[test]
171    fn test_mcp_tool_wrapper_name() {
172        let manager = Arc::new(McpManager::new());
173        let mcp_tool = McpTool {
174            name: "create_issue".to_string(),
175            title: None,
176            description: Some("Create a GitHub issue".to_string()),
177            input_schema: serde_json::json!({
178                "type": "object",
179                "properties": {
180                    "title": {"type": "string"}
181                }
182            }),
183            output_schema: None,
184            annotations: None,
185            icons: Vec::new(),
186            meta: None,
187        };
188
189        let wrapper = McpToolWrapper::new("github".to_string(), mcp_tool, manager);
190
191        assert_eq!(wrapper.name(), "mcp__github__create_issue");
192        assert_eq!(wrapper.server_name(), "github");
193        assert_eq!(wrapper.mcp_tool_name(), "create_issue");
194        assert_eq!(wrapper.description(), "Create a GitHub issue");
195    }
196
197    #[tokio::test]
198    async fn wrapper_calls_the_server_it_was_bound_to() {
199        let manager = Arc::new(McpManager::new());
200        for name in ["git", "git__hub"] {
201            manager
202                .register_server(crate::mcp::protocol::McpServerConfig {
203                    name: name.to_string(),
204                    transport: crate::mcp::protocol::McpTransportConfig::Stdio {
205                        command: "echo".to_string(),
206                        args: Vec::new(),
207                    },
208                    enabled: true,
209                    env: HashMap::new(),
210                    oauth: None,
211                    tool_timeout_secs: 5,
212                })
213                .await;
214        }
215        let wrapper = McpToolWrapper::new(
216            "git__hub".to_string(),
217            McpTool {
218                name: "create".to_string(),
219                title: None,
220                description: None,
221                input_schema: serde_json::json!({}),
222                output_schema: None,
223                annotations: None,
224                icons: Vec::new(),
225                meta: None,
226            },
227            manager,
228        );
229        let ctx = crate::tools::ToolContext::new(std::path::PathBuf::from("/tmp"));
230        let output = wrapper
231            .execute(&serde_json::json!({}), &ctx)
232            .await
233            .expect("disconnected MCP server is a tool error, not a transport failure");
234
235        assert!(
236            output.content.contains("not connected: git__hub"),
237            "the bound server must be the one named in the refusal: {}",
238            output.content
239        );
240    }
241
242    #[test]
243    fn test_create_mcp_tools() {
244        let manager = Arc::new(McpManager::new());
245        let tools = vec![
246            McpTool {
247                name: "tool1".to_string(),
248                title: None,
249                description: Some("Tool 1".to_string()),
250                input_schema: serde_json::json!({}),
251                output_schema: None,
252                annotations: None,
253                icons: Vec::new(),
254                meta: None,
255            },
256            McpTool {
257                name: "tool2".to_string(),
258                title: None,
259                description: Some("Tool 2".to_string()),
260                input_schema: serde_json::json!({}),
261                output_schema: None,
262                annotations: None,
263                icons: Vec::new(),
264                meta: None,
265            },
266        ];
267
268        let wrappers = create_mcp_tools("test", tools, manager);
269
270        assert_eq!(wrappers.len(), 2);
271        assert_eq!(wrappers[0].name(), "mcp__test__tool1");
272        assert_eq!(wrappers[1].name(), "mcp__test__tool2");
273    }
274
275    fn annotated_tool(annotations: Option<McpToolAnnotations>) -> McpTool {
276        McpTool {
277            name: "fixture".to_string(),
278            title: None,
279            description: None,
280            input_schema: serde_json::json!({"type": "object"}),
281            output_schema: None,
282            annotations,
283            icons: Vec::new(),
284            meta: None,
285        }
286    }
287
288    #[test]
289    fn closed_world_read_only_annotation_does_not_escalate_confirmation() {
290        let manager = Arc::new(McpManager::new());
291        let wrapper = McpToolWrapper::new(
292            "use_fixture".to_string(),
293            annotated_tool(Some(McpToolAnnotations {
294                read_only_hint: Some(true),
295                destructive_hint: Some(false),
296                idempotent_hint: Some(true),
297                open_world_hint: Some(false),
298                ..Default::default()
299            })),
300            manager,
301        );
302
303        assert!(!wrapper.requires_confirmation(&serde_json::json!({})));
304    }
305
306    #[test]
307    fn unknown_open_world_mutating_and_submit_tools_escalate_confirmation() {
308        let cases = [
309            None,
310            Some(McpToolAnnotations {
311                read_only_hint: Some(true),
312                open_world_hint: Some(true),
313                ..Default::default()
314            }),
315            Some(McpToolAnnotations {
316                read_only_hint: Some(false),
317                open_world_hint: Some(false),
318                ..Default::default()
319            }),
320            Some(McpToolAnnotations {
321                read_only_hint: Some(true),
322                open_world_hint: Some(false),
323                additional: HashMap::from([(
324                    "x-a3s-risk".to_string(),
325                    serde_json::json!("submit"),
326                )]),
327                ..Default::default()
328            }),
329        ];
330
331        for annotations in cases {
332            let wrapper = McpToolWrapper::new(
333                "use_fixture".to_string(),
334                annotated_tool(annotations),
335                Arc::new(McpManager::new()),
336            );
337            assert!(wrapper.requires_confirmation(&serde_json::json!({})));
338        }
339    }
340}