Skip to main content

phi_core/mcp/
tool_adapter.rs

1//! Adapts MCP tools to the AgentTool trait.
2/*
3ARCHITECTURE: McpToolAdapter — the adapter pattern between two interfaces
4
5`McpToolAdapter` bridges two worlds:
6  External (MCP side):  `McpClient::call_tool(name, arguments)` → `McpToolCallResult`
7  Internal (agent side): `AgentTool::execute(params, ctx)` → `Result<ToolResult, ToolError>`
8
9By implementing `AgentTool`, each MCP tool becomes indistinguishable from a built-in tool
10to the agent loop. The agent loop sees `Arc<dyn AgentTool>` and doesn't know whether
11it's a BashTool or a remote MCP filesystem tool.
12
13This is the Adapter design pattern: converts one interface (`McpClient`) into another
14(`AgentTool`) without modifying either.
15
16Name collision prevention:
17  If two MCP servers both have a tool named "read_file", there's a conflict.
18  `prefix` adds a namespace: "filesystem__read_file" vs "git__read_file".
19  The prefix is optional — not needed when using a single MCP server.
20
21`Arc<Mutex<McpClient>>` shared across adapters:
22  All adapters for the same server share ONE `McpClient` (same process/connection).
23  `Arc::clone()` bumps the reference count — cheap, no data duplication.
24  `Mutex` ensures only one `call_tool()` runs at a time on this client (serial protocol).
25
26RUST QUIRK: `self.tool.description.as_deref().unwrap_or("MCP tool (no description)")`
27  `self.tool.description` is `Option<String>`.
28  `.as_deref()` converts `Option<String>` → `Option<&str>` (borrow inner String as &str).
29  `.unwrap_or("...")` returns the &str if Some, or the fallback &'static str if None.
30  Both have type `&str` so the return type is consistent.
31  Python analogy: `self.tool.description or "MCP tool (no description)"`
32*/
33
34use super::client::McpClient;
35use super::types::{McpContent, McpError, McpToolInfo};
36use crate::types::{AgentTool, Content, ToolContext, ToolError, ToolResult};
37use async_trait::async_trait;
38use std::sync::Arc;
39use tokio::sync::Mutex;
40
41/// Wraps an MCP server tool as an `AgentTool` so it can be used by the agent.
42pub struct McpToolAdapter {
43    client: Arc<Mutex<McpClient>>, // shared connection to the MCP server
44    tool: McpToolInfo,             // the tool's metadata (name, description, schema)
45    /// Prefix to avoid name collisions (e.g., "server_name__tool_name").
46    prefix: Option<String>,
47}
48
49impl McpToolAdapter {
50    /// Create a new adapter.
51    pub fn new(client: Arc<Mutex<McpClient>>, tool: McpToolInfo) -> Self {
52        Self {
53            client,
54            tool,
55            prefix: None,
56        }
57    }
58
59    /// Create with a name prefix for disambiguation.
60    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
61        self.prefix = Some(prefix.into());
62        self
63    }
64
65    /// Create adapters for all tools from an MCP client.
66    /*
67    RUST QUIRK: `client.lock().await.list_tools().await?`
68
69    This is a chain of two async operations:
70      1. `client.lock().await` — acquire the async mutex, get `MutexGuard<McpClient>`
71      2. `.list_tools().await?` — call `list_tools()` on the McpClient inside the guard
72
73    The guard is temporarily held for the `list_tools()` call, then dropped.
74    After this line, the mutex is released and available for other operations.
75
76    `client.clone()` in the `.map()` — clones the `Arc<Mutex<McpClient>>`, NOT the McpClient.
77    Each adapter gets its own `Arc` pointing to the same underlying `McpClient`.
78    Python analogy: list comprehension with `[McpToolAdapter(client, tool) for tool in tools]`
79    */
80    pub async fn from_client(
81        client: Arc<Mutex<McpClient>>, // SHARED MCP CLIENT — Arc so each adapter can clone the reference cheaply
82    ) -> Result<Vec<Self>, McpError> {
83        // one McpToolAdapter per tool discovered from tools/list
84        let tools = client.lock().await.list_tools().await?;
85        Ok(tools
86            .into_iter()
87            .map(|tool| McpToolAdapter::new(client.clone(), tool)) // Arc::clone implicit here
88            .collect())
89    }
90
91    /// Create adapters with a name prefix for all tools from an MCP client.
92    pub async fn from_client_with_prefix(
93        client: Arc<Mutex<McpClient>>, // SHARED MCP CLIENT — each adapter holds an Arc clone
94        prefix: impl Into<String>, // NAME PREFIX — prepended to every tool name to avoid collisions (e.g. "myserver_")
95    ) -> Result<Vec<Self>, McpError> {
96        let prefix = prefix.into();
97        let tools = client.lock().await.list_tools().await?;
98        Ok(tools
99            .into_iter()
100            .map(|tool| McpToolAdapter::new(client.clone(), tool).with_prefix(prefix.clone()))
101            .collect())
102    }
103}
104
105#[async_trait]
106impl AgentTool for McpToolAdapter {
107    fn name(&self) -> &str {
108        // Return the tool name; prefix is applied in label for display.
109        // We use the raw name so MCP server recognizes it in call_tool.
110        &self.tool.name
111    }
112
113    fn label(&self) -> &str {
114        &self.tool.name
115    }
116
117    fn description(&self) -> &str {
118        self.tool
119            .description
120            .as_deref()
121            .unwrap_or("MCP tool (no description)")
122    }
123
124    fn parameters_schema(&self) -> serde_json::Value {
125        if self.tool.input_schema.is_null() {
126            serde_json::json!({"type": "object", "properties": {}})
127        } else {
128            self.tool.input_schema.clone()
129        }
130    }
131
132    async fn execute(
133        &self,
134        params: serde_json::Value, // LLM INPUT — forwarded directly to McpClient::call_tool as arguments
135        _ctx: ToolContext, // SYSTEM ENV — cancel/callbacks; prefixed _ because MCP tools can't use them yet
136    ) -> Result<ToolResult, ToolError> {
137        let client = self.client.lock().await;
138        let result = client
139            .call_tool(&self.tool.name, params)
140            .await
141            .map_err(|e| ToolError::Failed(format!("MCP call failed: {}", e)))?;
142
143        if result.is_error {
144            let error_text = result
145                .content
146                .iter()
147                .filter_map(|c| match c {
148                    McpContent::Text { text } => Some(text.as_str()),
149                    _ => None,
150                })
151                .collect::<Vec<_>>()
152                .join("\n");
153            return Err(ToolError::Failed(error_text));
154        }
155
156        let content: Vec<Content> = result
157            .content
158            .into_iter()
159            .map(|c| match c {
160                McpContent::Text { text } => Content::Text { text },
161                McpContent::Image { data, mime_type } => Content::Image { data, mime_type },
162            })
163            .collect();
164
165        Ok(ToolResult {
166            content,
167            details: serde_json::Value::Null,
168            child_loop_id: None,
169        })
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::mcp::transport::McpTransport;
177    use crate::mcp::types::*;
178
179    /// A mock transport that returns predefined responses.
180    struct MockTransport {
181        responses: std::sync::Mutex<Vec<JsonRpcResponse>>,
182    }
183
184    impl MockTransport {
185        fn new(responses: Vec<JsonRpcResponse>) -> Self {
186            Self {
187                responses: std::sync::Mutex::new(responses),
188            }
189        }
190    }
191
192    #[async_trait]
193    impl McpTransport for MockTransport {
194        async fn send(&self, _request: JsonRpcRequest) -> Result<JsonRpcResponse, McpError> {
195            let mut responses = self.responses.lock().unwrap();
196            if responses.is_empty() {
197                Err(McpError::ConnectionClosed)
198            } else {
199                Ok(responses.remove(0))
200            }
201        }
202
203        async fn close(&self) -> Result<(), McpError> {
204            Ok(())
205        }
206    }
207
208    fn ok_response(id: u64, result: serde_json::Value) -> JsonRpcResponse {
209        JsonRpcResponse {
210            jsonrpc: "2.0".into(),
211            id: Some(id),
212            result: Some(result),
213            error: None,
214        }
215    }
216
217    #[tokio::test]
218    async fn test_tool_adapter_wraps_mcp_tool() {
219        let tool_info = McpToolInfo {
220            name: "read_file".into(),
221            description: Some("Read a file from disk".into()),
222            input_schema: serde_json::json!({
223                "type": "object",
224                "properties": {
225                    "path": {"type": "string"}
226                },
227                "required": ["path"]
228            }),
229        };
230
231        // Mock: initialize response, initialized notification response, tools/call response
232        let transport = MockTransport::new(vec![
233            // tools/call response
234            ok_response(
235                1,
236                serde_json::json!({
237                    "content": [{"type": "text", "text": "file contents"}],
238                    "isError": false
239                }),
240            ),
241        ]);
242
243        let client = McpClient::from_transport(Box::new(transport));
244        let client = Arc::new(Mutex::new(client));
245
246        let adapter = McpToolAdapter::new(client, tool_info);
247
248        assert_eq!(adapter.name(), "read_file");
249        assert_eq!(adapter.description(), "Read a file from disk");
250
251        let schema = adapter.parameters_schema();
252        assert_eq!(schema["type"], "object");
253
254        let result = adapter
255            .execute(
256                serde_json::json!({"path": "/tmp/test"}),
257                ToolContext {
258                    tool_call_id: "tc-1".into(),
259                    tool_name: "read_file".into(),
260                    cancel: tokio_util::sync::CancellationToken::new(),
261                    on_update: None,
262                    on_progress: None,
263                },
264            )
265            .await
266            .unwrap();
267
268        assert_eq!(result.content.len(), 1);
269        if let Content::Text { text } = &result.content[0] {
270            assert_eq!(text, "file contents");
271        } else {
272            panic!("Expected text content");
273        }
274    }
275
276    #[tokio::test]
277    async fn test_tool_adapter_handles_error() {
278        let tool_info = McpToolInfo {
279            name: "fail_tool".into(),
280            description: None,
281            input_schema: serde_json::Value::Null,
282        };
283
284        let transport = MockTransport::new(vec![ok_response(
285            1,
286            serde_json::json!({
287                "content": [{"type": "text", "text": "something went wrong"}],
288                "isError": true
289            }),
290        )]);
291
292        let client = McpClient::from_transport(Box::new(transport));
293        let client = Arc::new(Mutex::new(client));
294
295        let adapter = McpToolAdapter::new(client, tool_info);
296        assert_eq!(adapter.description(), "MCP tool (no description)");
297
298        let result = adapter
299            .execute(
300                serde_json::json!({}),
301                ToolContext {
302                    tool_call_id: "tc-1".into(),
303                    tool_name: "fail_tool".into(),
304                    cancel: tokio_util::sync::CancellationToken::new(),
305                    on_update: None,
306                    on_progress: None,
307                },
308            )
309            .await;
310        assert!(result.is_err());
311    }
312
313    #[tokio::test]
314    async fn test_from_client_creates_adapters() {
315        // Mock: list_tools response
316        let transport = MockTransport::new(vec![ok_response(
317            1,
318            serde_json::json!({
319                "tools": [
320                    {"name": "tool_a", "description": "Tool A", "inputSchema": {"type": "object"}},
321                    {"name": "tool_b", "description": "Tool B", "inputSchema": {"type": "object"}}
322                ]
323            }),
324        )]);
325
326        let client = McpClient::from_transport(Box::new(transport));
327        let client = Arc::new(Mutex::new(client));
328
329        let adapters = McpToolAdapter::from_client(client).await.unwrap();
330        assert_eq!(adapters.len(), 2);
331        assert_eq!(adapters[0].name(), "tool_a");
332        assert_eq!(adapters[1].name(), "tool_b");
333    }
334}