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
7use crate::tools::{AgentTool, AgentToolResult, ToolContext};
8use async_trait::async_trait;
9use serde_json::Value;
10use std::sync::Arc;
11use tokio::sync::oneshot;
12
13use super::content;
14use super::McpManager;
15
16/// The unified MCP gateway tool.
17///
18/// Parameters follow the pi-mcp-adapter convention:
19///
20/// ```text
21/// Mode: tool (call) > connect > describe > search > server (list) > action > nothing (status)
22/// ```
23pub struct McpTool {
24    manager: Arc<McpManager>,
25}
26
27impl McpTool {
28    /// Create a new MCP tool with the given manager.
29    pub fn new(manager: Arc<McpManager>) -> Self {
30        Self { manager }
31    }
32}
33
34#[async_trait]
35impl AgentTool for McpTool {
36    fn name(&self) -> &str {
37        "mcp"
38    }
39
40    fn label(&self) -> &str {
41        "MCP"
42    }
43
44    fn description(&self) -> &str {
45        "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"
46    }
47
48    fn parameters_schema(&self) -> Value {
49        serde_json::json!({
50            "type": "object",
51            "properties": {
52                "tool": {
53                    "type": "string",
54                    "description": "Tool name to call (e.g. 'xcodebuild_list_sims')"
55                },
56                "args": {
57                    "type": "string",
58                    "description": "Arguments as JSON string (e.g. '{\"key\": \"value\"}')"
59                },
60                "connect": {
61                    "type": "string",
62                    "description": "Server name to connect (lazy connect + metadata refresh)"
63                },
64                "describe": {
65                    "type": "string",
66                    "description": "Tool name to describe (shows parameters)"
67                },
68                "search": {
69                    "type": "string",
70                    "description": "Search tools by name/description"
71                },
72                "regex": {
73                    "type": "boolean",
74                    "description": "Treat search as regex (default: substring match)"
75                },
76                "server": {
77                    "type": "string",
78                    "description": "Filter to specific server (also disambiguates tool calls)"
79                },
80                "action": {
81                    "type": "string",
82                    "description": "Action: 'ui-messages' to retrieve prompts/intents from UI sessions"
83                }
84            },
85            "additionalProperties": false
86        })
87    }
88
89    async fn execute(
90        &self,
91        _tool_call_id: &str,
92        params: Value,
93        _signal: Option<oneshot::Receiver<()>>,
94        _ctx: &ToolContext,
95    ) -> Result<AgentToolResult, String> {
96        let obj = params
97            .as_object()
98            .ok_or("Parameters must be a JSON object")?;
99
100        // Parse optional args
101        let parsed_args = if let Some(args_val) = obj.get("args").and_then(|v| v.as_str()) {
102            serde_json::from_str::<Value>(args_val)
103                .map_err(|e| format!("Invalid args JSON: {}", e))?
104        } else {
105            Value::Object(serde_json::Map::new())
106        };
107
108        // ── Route by priority ─────────────────────────────────────
109        if let Some(action) = obj.get("action").and_then(|v| v.as_str()) {
110            return self.handle_action(action).await;
111        }
112
113        if let Some(tool_name) = obj.get("tool").and_then(|v| v.as_str()) {
114            let server = obj.get("server").and_then(|v| v.as_str());
115            return self.handle_call(tool_name, parsed_args, server).await;
116        }
117
118        if let Some(server_name) = obj.get("connect").and_then(|v| v.as_str()) {
119            return self.handle_connect(server_name).await;
120        }
121
122        if let Some(tool_name) = obj.get("describe").and_then(|v| v.as_str()) {
123            return self.handle_describe(tool_name).await;
124        }
125
126        if let Some(query) = obj.get("search").and_then(|v| v.as_str()) {
127            let regex = obj.get("regex").and_then(|v| v.as_bool()).unwrap_or(false);
128            let server = obj.get("server").and_then(|v| v.as_str());
129            return self.handle_search(query, regex, server).await;
130        }
131
132        if let Some(server_name) = obj.get("server").and_then(|v| v.as_str()) {
133            return self.handle_list(server_name).await;
134        }
135
136        // Default: status
137        self.handle_status().await
138    }
139}
140
141// ── Action handlers ──────────────────────────────────────────────────
142
143impl McpTool {
144    async fn handle_status(&self) -> Result<AgentToolResult, String> {
145        let status = self.manager.status().await;
146        Ok(AgentToolResult::success(status))
147    }
148
149    async fn handle_connect(&self, server_name: &str) -> Result<AgentToolResult, String> {
150        let result = self
151            .manager
152            .connect(server_name)
153            .await
154            .map_err(|e| e.to_string())?;
155        Ok(AgentToolResult::success(result))
156    }
157
158    async fn handle_describe(&self, tool_name: &str) -> Result<AgentToolResult, String> {
159        let result = self
160            .manager
161            .describe(tool_name)
162            .await
163            .map_err(|e| e.to_string())?;
164        Ok(AgentToolResult::success(result))
165    }
166
167    async fn handle_search(
168        &self,
169        query: &str,
170        regex: bool,
171        server: Option<&str>,
172    ) -> Result<AgentToolResult, String> {
173        let result = self
174            .manager
175            .search(query, regex, server)
176            .await
177            .map_err(|e| e.to_string())?;
178        Ok(AgentToolResult::success(result))
179    }
180
181    async fn handle_list(&self, server_name: &str) -> Result<AgentToolResult, String> {
182        let result = self
183            .manager
184            .list_tools(server_name)
185            .await
186            .map_err(|e| e.to_string())?;
187        Ok(AgentToolResult::success(result))
188    }
189
190    async fn handle_call(
191        &self,
192        tool_name: &str,
193        args: Value,
194        server: Option<&str>,
195    ) -> Result<AgentToolResult, String> {
196        let result = self
197            .manager
198            .call_tool(tool_name, args, server)
199            .await
200            .map_err(|e| e.to_string())?;
201
202        if result.is_error {
203            let text = content::transform_mcp_content(&result.content);
204            Ok(AgentToolResult::error(format!("Error: {}", text)))
205        } else {
206            let text = content::transform_mcp_content(&result.content);
207            Ok(AgentToolResult::success(text))
208        }
209    }
210
211    async fn handle_action(&self, action: &str) -> Result<AgentToolResult, String> {
212        match action {
213            "ui-messages" => Ok(AgentToolResult::success(
214                "No UI session messages available.",
215            )),
216            _ => Ok(AgentToolResult::error(format!(
217                "Unknown action: '{}'. Supported: 'ui-messages'",
218                action
219            ))),
220        }
221    }
222}