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 [`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).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(&self, action: &str) -> Result<AgentToolResult, String> {
232        match action {
233            "ui-messages" => Ok(AgentToolResult::success(
234                "No UI session messages available.",
235            )),
236            _ => Ok(AgentToolResult::error(format!(
237                "Unknown action: '{}'. Supported: 'ui-messages'",
238                action
239            ))),
240        }
241    }
242}