Skip to main content

opendev_tools_impl/
mcp_tool.rs

1//! MCP tool bridge: wraps an MCP server tool as a `BaseTool`.
2//!
3//! Each `McpBridgeTool` instance represents a single tool from a connected
4//! MCP server. It stores the tool's metadata (name, description, schema)
5//! and holds an `Arc<McpManager>` to dispatch `call_tool` requests.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use opendev_mcp::McpManager;
12use opendev_mcp::models::{McpContent, McpToolSchema};
13use opendev_tools_core::traits::{BaseTool, ToolContext, ToolResult};
14
15/// A `BaseTool` wrapper around a single MCP server tool.
16///
17/// The tool name is the namespaced MCP name (e.g., `sqlite__query`),
18/// prefixed with `mcp__` for the agent's tool registry.
19pub struct McpBridgeTool {
20    /// Fully qualified tool name for the registry (e.g., `mcp__sqlite__query`).
21    tool_name: String,
22    /// Human-readable description.
23    tool_description: String,
24    /// JSON Schema for the tool's parameters.
25    schema: serde_json::Value,
26    /// Server name for routing the call.
27    server_name: String,
28    /// Original tool name on the MCP server.
29    original_name: String,
30    /// Shared MCP manager for dispatching calls.
31    manager: Arc<McpManager>,
32}
33
34impl std::fmt::Debug for McpBridgeTool {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.debug_struct("McpBridgeTool")
37            .field("tool_name", &self.tool_name)
38            .field("server_name", &self.server_name)
39            .field("original_name", &self.original_name)
40            .finish()
41    }
42}
43
44impl McpBridgeTool {
45    /// Create a bridge tool from an MCP tool schema and a shared manager.
46    pub fn from_schema(schema: &McpToolSchema, manager: Arc<McpManager>) -> Self {
47        Self {
48            tool_name: format!("mcp__{}", schema.name),
49            tool_description: schema.description.clone(),
50            schema: schema.parameters.clone(),
51            server_name: schema.server_name.clone(),
52            original_name: schema.original_name.clone(),
53            manager,
54        }
55    }
56}
57
58#[async_trait]
59impl BaseTool for McpBridgeTool {
60    fn name(&self) -> &str {
61        &self.tool_name
62    }
63
64    fn description(&self) -> &str {
65        &self.tool_description
66    }
67
68    fn parameter_schema(&self) -> serde_json::Value {
69        // Return the schema as-is; it's already a JSON Schema object from the MCP server
70        if self.schema.is_object() {
71            self.schema.clone()
72        } else {
73            // Fallback: wrap in a minimal object schema
74            serde_json::json!({
75                "type": "object",
76                "properties": {},
77                "required": []
78            })
79        }
80    }
81
82    async fn execute(
83        &self,
84        args: HashMap<String, serde_json::Value>,
85        _ctx: &ToolContext,
86    ) -> ToolResult {
87        let arguments = serde_json::Value::Object(args.into_iter().collect());
88
89        match self
90            .manager
91            .call_tool(&self.server_name, &self.original_name, arguments)
92            .await
93        {
94            Ok(result) => {
95                // Convert MCP content blocks to a single output string
96                let output = result
97                    .content
98                    .iter()
99                    .filter_map(|c| match c {
100                        McpContent::Text { text } => Some(text.as_str()),
101                        _ => None,
102                    })
103                    .collect::<Vec<_>>()
104                    .join("\n");
105
106                if result.is_error {
107                    ToolResult::fail(if output.is_empty() {
108                        "MCP tool returned an error".to_string()
109                    } else {
110                        output
111                    })
112                } else {
113                    ToolResult::ok(if output.is_empty() {
114                        "(no output)".to_string()
115                    } else {
116                        output
117                    })
118                }
119            }
120            Err(e) => ToolResult::fail(format!("MCP call failed: {e}")),
121        }
122    }
123}
124
125#[cfg(test)]
126#[path = "mcp_tool_tests.rs"]
127mod tests;