1use 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
13pub struct McpToolWrapper {
15 full_name: String,
17 mcp_tool: McpTool,
19 server_name: String,
21 manager: Arc<McpManager>,
23}
24
25impl McpToolWrapper {
26 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 pub fn server_name(&self) -> &str {
39 &self.server_name
40 }
41
42 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 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 _ => 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
145pub 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}