Skip to main content

oxi_agent/mcp/
direct_tool.rs

1//! Direct MCP tool → `AgentTool` bridge (Phase 3).
2//!
3//! Wraps a single MCP tool as a standalone `AgentTool` so the LLM can call
4//! it directly (without going through the `mcp` proxy gateway). The
5//! prefixed name is precomputed at construction time and stored in a
6//! `String` field, because `AgentTool::name()` returns `&str` whose lifetime
7//! is tied to `&self`.
8
9use super::McpManager;
10use super::content;
11use super::types::{ConsentState, DirectToolDef};
12use crate::tools::{AgentTool, AgentToolResult, ToolContext};
13use async_trait::async_trait;
14use serde_json::Value;
15use std::sync::Arc;
16use tokio::sync::oneshot;
17
18/// An individual MCP tool registered as a first-class `AgentTool`.
19pub struct McpDirectTool {
20    /// Prefixed tool name (e.g. `"chrome_take_screenshot"`).
21    prefixed_name: String,
22    /// Original (unprefixed) MCP tool name.
23    original_name: String,
24    /// Server that provides this tool.
25    server_name: String,
26    /// Tool description.
27    description: String,
28    /// JSON Schema for parameters.
29    schema: Value,
30    /// Shared `McpManager` for execute + lifecycle.
31    manager: Arc<McpManager>,
32}
33
34impl std::fmt::Debug for McpDirectTool {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.debug_struct("McpDirectTool")
37            .field("name", &self.prefixed_name)
38            .field("server", &self.server_name)
39            .finish()
40    }
41}
42
43impl McpDirectTool {
44    /// Create a new direct tool from a `DirectToolDef` and a shared manager.
45    pub fn new(manager: Arc<McpManager>, def: DirectToolDef) -> Self {
46        Self {
47            prefixed_name: def.prefixed_name,
48            original_name: def.original_name,
49            server_name: def.server_name,
50            description: def.description,
51            schema: def
52                .input_schema
53                .unwrap_or_else(|| serde_json::json!({"type": "object", "properties": {}})),
54            manager,
55        }
56    }
57}
58
59#[async_trait]
60impl AgentTool for McpDirectTool {
61    fn name(&self) -> &str {
62        &self.prefixed_name
63    }
64
65    fn label(&self) -> &str {
66        &self.original_name
67    }
68
69    fn description(&self) -> &str {
70        &self.description
71    }
72
73    fn parameters_schema(&self) -> Value {
74        self.schema.clone()
75    }
76
77    fn essential(&self) -> bool {
78        false
79    }
80
81    async fn execute(
82        &self,
83        _tool_call_id: &str,
84        params: Value,
85        _signal: Option<oneshot::Receiver<()>>,
86        _ctx: &ToolContext,
87    ) -> Result<AgentToolResult, String> {
88        // 1. Consent gate (Phase 3)
89        if self.manager.consent().check(&self.original_name) == ConsentState::Deny {
90            return Ok(AgentToolResult::error(format!(
91                "Tool '{}' is denied by consent policy",
92                self.original_name
93            )));
94        }
95
96        // 2. Ensure the server is connected, then call the tool.
97        match self
98            .manager
99            .call_tool(&self.original_name, params, Some(&self.server_name))
100            .await
101        {
102            Ok(result) => {
103                // 3. Reset idle timer so the server doesn't disconnect
104                //    immediately after a direct-tool call.
105                self.manager.reset_idle_timer(&self.server_name);
106
107                if result.is_error {
108                    let text = content::transform_mcp_content(&result.content);
109                    Ok(AgentToolResult::error(format!("Error: {}", text)))
110                } else {
111                    let text = content::transform_mcp_content(&result.content);
112                    Ok(AgentToolResult::success(text))
113                }
114            }
115            Err(e) => Err(e.to_string()),
116        }
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn name_returns_prefixed_name_reference() {
126        // The critical compile-time guarantee: `name()` returns `&str`
127        // borrowed from a `String` field owned by `self`.
128        let manager: Arc<McpManager> = Arc::new(McpManager::new_no_spawn());
129        let def = DirectToolDef {
130            prefixed_name: "chrome_take_screenshot".to_string(),
131            original_name: "take_screenshot".to_string(),
132            server_name: "chrome".to_string(),
133            description: "Take a screenshot".to_string(),
134            input_schema: Some(serde_json::json!({"type": "object"})),
135        };
136        let tool = McpDirectTool::new(manager, def);
137        assert_eq!(tool.name(), "chrome_take_screenshot");
138        assert_eq!(tool.label(), "take_screenshot");
139    }
140}