Skip to main content

oxi_agent/mcp/
tool.rs

1//! MCP proxy tool.
2//!
3//! Implements [`AgentTool`] for the unified `mcp` tool that acts as a
4//! gateway to all MCP servers.  The LLM calls this single tool with
5//! different parameters to search, describe, connect, and call MCP tools.
6//!
7//! The proxy tool is the **fallback / search** path. Specific tools may
8//! also be registered directly via [`crate::mcp::McpDirectTool`] (Phase 3); the two
9//! paths coexist.
10
11use crate::tools::{AgentTool, AgentToolResult, ToolContext};
12use async_trait::async_trait;
13use serde_json::Value;
14use std::sync::Arc;
15use tokio::sync::oneshot;
16
17use super::McpManager;
18use super::content;
19
20/// The unified MCP gateway tool.
21///
22/// Parameters follow the pi-mcp-adapter convention:
23///
24/// ```text
25/// Mode: tool (call) > connect > describe > search > server (list) > action > nothing (status)
26/// ```
27pub struct McpTool {
28    manager: Arc<McpManager>,
29}
30
31impl std::fmt::Debug for McpTool {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        f.debug_struct("McpTool").finish()
34    }
35}
36
37impl McpTool {
38    /// Create a new MCP tool with the given manager.
39    pub fn new(manager: Arc<McpManager>) -> Self {
40        Self { manager }
41    }
42
43    /// Get a clone of the underlying manager (used by other code paths
44    /// that need to read McpManager state, e.g. the TUI dashboard).
45    pub fn manager(&self) -> Arc<McpManager> {
46        Arc::clone(&self.manager)
47    }
48}
49
50#[async_trait]
51impl AgentTool for McpTool {
52    fn name(&self) -> &str {
53        "mcp"
54    }
55
56    fn label(&self) -> &str {
57        "MCP"
58    }
59
60    fn description(&self) -> &str {
61        "MCP gateway - connect to MCP servers and call their tools. Non-MCP Pi tools should be called directly, not through mcp.\n\nUsage:\n  mcp({ }) → status\n  mcp({ tool: \"name\", args: '{}' }) → call tool\n  mcp({ connect: \"server\" }) → connect\n  mcp({ search: \"query\" }) → search\n  mcp({ describe: \"tool\" }) → describe\n  mcp({ server: \"name\" }) → list tools\n\nMode: tool > connect > describe > search > server > status"
62    }
63
64    fn parameters_schema(&self) -> Value {
65        serde_json::json!({
66            "type": "object",
67            "properties": {
68                "tool": {
69                    "type": "string",
70                    "description": "Tool name to call (e.g. 'xcodebuild_list_sims')"
71                },
72                "args": {
73                    "type": "string",
74                    "description": "Arguments as JSON string (e.g. '{\"key\": \"value\"}')"
75                },
76                "connect": {
77                    "type": "string",
78                    "description": "Server name to connect (lazy connect + metadata refresh)"
79                },
80                "describe": {
81                    "type": "string",
82                    "description": "Tool name to describe (shows parameters)"
83                },
84                "search": {
85                    "type": "string",
86                    "description": "Search tools by name/description"
87                },
88                "regex": {
89                    "type": "boolean",
90                    "description": "Treat search as regex (default: substring match)"
91                },
92                "server": {
93                    "type": "string",
94                    "description": "Filter to specific server (also disambiguates tool calls)"
95                },
96                "action": {
97                    "type": "string",
98                    "description": "Action: 'ui-messages' to retrieve prompts/intents from UI sessions"
99                }
100            },
101            "additionalProperties": false
102        })
103    }
104
105    fn essential(&self) -> bool {
106        false
107    }
108
109    async fn execute(
110        &self,
111        _tool_call_id: &str,
112        params: Value,
113        _signal: Option<oneshot::Receiver<()>>,
114        _ctx: &ToolContext,
115    ) -> Result<AgentToolResult, String> {
116        let obj = params
117            .as_object()
118            .ok_or("Parameters must be a JSON object")?;
119
120        // Parse optional args
121        let parsed_args = if let Some(args_val) = obj.get("args").and_then(|v| v.as_str()) {
122            serde_json::from_str::<Value>(args_val)
123                .map_err(|e| format!("Invalid args JSON: {}", e))?
124        } else {
125            Value::Object(serde_json::Map::new())
126        };
127
128        // ── Route by priority ─────────────────────────────────────
129        if let Some(action) = obj.get("action").and_then(|v| v.as_str()) {
130            return self.handle_action(action, obj).await;
131        }
132
133        if let Some(tool_name) = obj.get("tool").and_then(|v| v.as_str()) {
134            let server = obj.get("server").and_then(|v| v.as_str());
135            return self.handle_call(tool_name, parsed_args, server).await;
136        }
137
138        if let Some(server_name) = obj.get("connect").and_then(|v| v.as_str()) {
139            return self.handle_connect(server_name).await;
140        }
141
142        if let Some(tool_name) = obj.get("describe").and_then(|v| v.as_str()) {
143            return self.handle_describe(tool_name).await;
144        }
145
146        if let Some(query) = obj.get("search").and_then(|v| v.as_str()) {
147            let regex = obj.get("regex").and_then(|v| v.as_bool()).unwrap_or(false);
148            let server = obj.get("server").and_then(|v| v.as_str());
149            return self.handle_search(query, regex, server).await;
150        }
151
152        if let Some(server_name) = obj.get("server").and_then(|v| v.as_str()) {
153            return self.handle_list(server_name).await;
154        }
155
156        // Default: status
157        self.handle_status().await
158    }
159}
160
161// ── Action handlers ──────────────────────────────────────────────────
162
163impl McpTool {
164    async fn handle_status(&self) -> Result<AgentToolResult, String> {
165        let status = self.manager.status().await;
166        Ok(AgentToolResult::success(status))
167    }
168
169    async fn handle_connect(&self, server_name: &str) -> Result<AgentToolResult, String> {
170        let result = self
171            .manager
172            .connect(server_name)
173            .await
174            .map_err(|e| e.to_string())?;
175        Ok(AgentToolResult::success(result))
176    }
177
178    async fn handle_describe(&self, tool_name: &str) -> Result<AgentToolResult, String> {
179        let result = self
180            .manager
181            .describe(tool_name)
182            .await
183            .map_err(|e| e.to_string())?;
184        Ok(AgentToolResult::success(result))
185    }
186
187    async fn handle_search(
188        &self,
189        query: &str,
190        regex: bool,
191        server: Option<&str>,
192    ) -> Result<AgentToolResult, String> {
193        let result = self
194            .manager
195            .search(query, regex, server)
196            .await
197            .map_err(|e| e.to_string())?;
198        Ok(AgentToolResult::success(result))
199    }
200
201    async fn handle_list(&self, server_name: &str) -> Result<AgentToolResult, String> {
202        let result = self
203            .manager
204            .list_tools(server_name)
205            .await
206            .map_err(|e| e.to_string())?;
207        Ok(AgentToolResult::success(result))
208    }
209
210    async fn handle_call(
211        &self,
212        tool_name: &str,
213        args: Value,
214        server: Option<&str>,
215    ) -> Result<AgentToolResult, String> {
216        let result = self
217            .manager
218            .call_tool(tool_name, args, server)
219            .await
220            .map_err(|e| e.to_string())?;
221
222        if result.is_error {
223            let text = content::transform_mcp_content(&result.content);
224            Ok(AgentToolResult::error(format!("Error: {}", text)))
225        } else {
226            let text = content::transform_mcp_content(&result.content);
227            Ok(AgentToolResult::success(text))
228        }
229    }
230
231    async fn handle_action(
232        &self,
233        action: &str,
234        obj: &serde_json::Map<String, Value>,
235    ) -> Result<AgentToolResult, String> {
236        let server = obj.get("server").and_then(|v| v.as_str()).unwrap_or("");
237        match action {
238            "ui-messages" => Ok(AgentToolResult::success(
239                "No UI session messages available.",
240            )),
241            "list-resources" => {
242                if server.is_empty() {
243                    return Ok(AgentToolResult::error(String::from(
244                        "list-resources requires 'server'",
245                    )));
246                }
247                match self.manager.list_resources(server).await {
248                    Ok(resources) => Ok(AgentToolResult::success(
249                        serde_json::to_string_pretty(&resources).unwrap_or_default(),
250                    )),
251                    Err(e) => Ok(AgentToolResult::error(format!(
252                        "list_resources('{}') failed: {}",
253                        server, e
254                    ))),
255                }
256            }
257            "read-resource" => {
258                let uri = obj.get("uri").and_then(|v| v.as_str()).unwrap_or("");
259                if server.is_empty() || uri.is_empty() {
260                    return Ok(AgentToolResult::error(String::from(
261                        "read-resource requires 'server' and 'uri'",
262                    )));
263                }
264                match self.manager.read_resource(server, uri).await {
265                    Ok(content) => Ok(AgentToolResult::success(content::transform_mcp_content(
266                        &content,
267                    ))),
268                    Err(e) => Ok(AgentToolResult::error(format!(
269                        "read_resource('{}','{}') failed: {}",
270                        server, uri, e
271                    ))),
272                }
273            }
274            "list-prompts" => {
275                if server.is_empty() {
276                    return Ok(AgentToolResult::error(String::from(
277                        "list-prompts requires 'server'",
278                    )));
279                }
280                match self.manager.list_prompts(server).await {
281                    Ok(prompts) => {
282                        let summary: Vec<String> = prompts
283                            .iter()
284                            .map(|p| {
285                                format!(
286                                    "- {}{}",
287                                    p.name,
288                                    p.description
289                                        .as_deref()
290                                        .map(|d| format!(" — {}", d))
291                                        .unwrap_or_default()
292                                )
293                            })
294                            .collect();
295                        Ok(AgentToolResult::success(summary.join("\n")))
296                    }
297                    Err(e) => Ok(AgentToolResult::error(format!(
298                        "list_prompts('{}') failed: {}",
299                        server, e
300                    ))),
301                }
302            }
303            "get-prompt" => {
304                let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
305                let arguments = obj
306                    .get("arguments")
307                    .and_then(|v| v.as_object())
308                    .map(|m| {
309                        m.iter()
310                            .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
311                            .collect::<std::collections::HashMap<_, _>>()
312                    })
313                    .unwrap_or_default();
314                if server.is_empty() || name.is_empty() {
315                    return Ok(AgentToolResult::error(String::from(
316                        "get-prompt requires 'server' and 'name'",
317                    )));
318                }
319                match self.manager.get_prompt(server, name, arguments).await {
320                    Ok(messages) => Ok(AgentToolResult::success(
321                        serde_json::to_string_pretty(&messages).unwrap_or_default(),
322                    )),
323                    Err(e) => Ok(AgentToolResult::error(format!(
324                        "get_prompt('{}','{}') failed: {}",
325                        server, name, e
326                    ))),
327                }
328            }
329            _ => Ok(AgentToolResult::error(format!(
330                "Unknown action: '{}'. Supported: 'ui-messages', 'list-resources', 'read-resource', 'list-prompts', 'get-prompt'",
331                action
332            ))),
333        }
334    }
335}