Skip to main content

agent_sdk/mcp/
tool_bridge.rs

1//! Bridge MCP tools to SDK Tool trait.
2
3use crate::tools::{DynamicToolName, Tool, ToolContext, ToolRegistry};
4use crate::types::{ToolResult, ToolTier};
5use anyhow::{Context, Result};
6use serde_json::Value;
7use std::fmt::Write;
8use std::sync::Arc;
9
10use super::client::McpClient;
11use super::protocol::{McpContent, McpToolDefinition};
12use super::transport::McpTransport;
13
14/// Bridge an MCP tool to the SDK Tool trait.
15///
16/// This wrapper allows MCP tools to be used as regular SDK tools.
17///
18/// # Example
19///
20/// ```ignore
21/// use agent_sdk::mcp::{McpClient, McpToolBridge, StdioTransport};
22///
23/// let transport = StdioTransport::spawn("npx", &["-y", "mcp-server"]).await?;
24/// let client = Arc::new(McpClient::new(transport, "server".to_string()).await?);
25///
26/// let tools = client.list_tools().await?;
27/// for tool_def in tools {
28///     let tool = McpToolBridge::new(Arc::clone(&client), tool_def);
29///     registry.register(tool);
30/// }
31/// ```
32pub struct McpToolBridge<T: McpTransport> {
33    client: Arc<McpClient<T>>,
34    definition: McpToolDefinition,
35    tier: ToolTier,
36}
37
38impl<T: McpTransport> McpToolBridge<T> {
39    /// Create a new MCP tool bridge.
40    #[must_use]
41    pub const fn new(client: Arc<McpClient<T>>, definition: McpToolDefinition) -> Self {
42        Self {
43            client,
44            definition,
45            tier: ToolTier::Confirm, // Default to Confirm for safety
46        }
47    }
48
49    /// Set the tool tier.
50    #[must_use]
51    pub const fn with_tier(mut self, tier: ToolTier) -> Self {
52        self.tier = tier;
53        self
54    }
55
56    /// Get the tool name.
57    #[must_use]
58    pub fn tool_name(&self) -> &str {
59        &self.definition.name
60    }
61
62    /// Get the tool definition.
63    #[must_use]
64    pub const fn definition(&self) -> &McpToolDefinition {
65        &self.definition
66    }
67}
68
69impl<T: McpTransport + 'static> Tool<()> for McpToolBridge<T> {
70    type Name = DynamicToolName;
71
72    fn name(&self) -> DynamicToolName {
73        DynamicToolName::new(&self.definition.name)
74    }
75
76    fn display_name(&self) -> &'static str {
77        // We need to leak the string to get a 'static lifetime
78        // This is acceptable since tool definitions are typically long-lived
79        Box::leak(self.definition.name.clone().into_boxed_str())
80    }
81
82    fn description(&self) -> &'static str {
83        let desc = self.definition.description.clone().unwrap_or_default();
84        Box::leak(desc.into_boxed_str())
85    }
86
87    fn input_schema(&self) -> Value {
88        self.definition.input_schema.clone()
89    }
90
91    fn tier(&self) -> ToolTier {
92        self.tier
93    }
94
95    async fn execute(&self, _ctx: &ToolContext<()>, input: Value) -> Result<ToolResult> {
96        let result = self.client.call_tool(&self.definition.name, input).await?;
97
98        // Convert MCP content to output string
99        let output = format_mcp_content(&result.content);
100
101        Ok(ToolResult {
102            success: !result.is_error,
103            output,
104            data: Some(serde_json::to_value(&result).unwrap_or_default()),
105            documents: Vec::new(),
106            duration_ms: None,
107        })
108    }
109}
110
111/// Format MCP content items as a string.
112fn format_mcp_content(content: &[McpContent]) -> String {
113    let mut output = String::new();
114
115    for item in content {
116        match item {
117            McpContent::Text { text } => {
118                output.push_str(text);
119                output.push('\n');
120            }
121            McpContent::Image { mime_type, .. } => {
122                let _ = writeln!(output, "[Image: {mime_type}]");
123            }
124            McpContent::Resource { uri, text, .. } => {
125                if let Some(text) = text {
126                    output.push_str(text);
127                    output.push('\n');
128                } else {
129                    let _ = writeln!(output, "[Resource: {uri}]");
130                }
131            }
132        }
133    }
134
135    output.trim_end().to_string()
136}
137
138/// Register all tools from an MCP client into a tool registry.
139///
140/// # Arguments
141///
142/// * `registry` - The tool registry to add tools to
143/// * `client` - The MCP client to get tools from
144///
145/// # Errors
146///
147/// Returns an error if listing tools fails.
148///
149/// # Example
150///
151/// ```ignore
152/// use agent_sdk::mcp::{register_mcp_tools, McpClient, StdioTransport};
153/// use agent_sdk::ToolRegistry;
154///
155/// let transport = StdioTransport::spawn("npx", &["-y", "mcp-server"]).await?;
156/// let client = Arc::new(McpClient::new(transport, "server".to_string()).await?);
157///
158/// let mut registry = ToolRegistry::new();
159/// register_mcp_tools(&mut registry, client).await?;
160/// ```
161pub async fn register_mcp_tools<T: McpTransport + 'static>(
162    registry: &mut ToolRegistry<()>,
163    client: Arc<McpClient<T>>,
164) -> Result<()> {
165    let tools = client
166        .list_tools()
167        .await
168        .context("Failed to list MCP tools")?;
169
170    for definition in tools {
171        let bridge = McpToolBridge::new(Arc::clone(&client), definition);
172        registry.register(bridge);
173    }
174
175    Ok(())
176}
177
178/// Register MCP tools with custom tier assignment.
179///
180/// # Arguments
181///
182/// * `registry` - The tool registry to add tools to
183/// * `client` - The MCP client to get tools from
184/// * `tier_fn` - Function to determine tier for each tool
185///
186/// # Errors
187///
188/// Returns an error if listing tools fails.
189pub async fn register_mcp_tools_with_tiers<T, F>(
190    registry: &mut ToolRegistry<()>,
191    client: Arc<McpClient<T>>,
192    tier_fn: F,
193) -> Result<()>
194where
195    T: McpTransport + 'static,
196    F: Fn(&McpToolDefinition) -> ToolTier,
197{
198    let tools = client
199        .list_tools()
200        .await
201        .context("Failed to list MCP tools")?;
202
203    for definition in tools {
204        let tier = tier_fn(&definition);
205        let bridge = McpToolBridge::new(Arc::clone(&client), definition).with_tier(tier);
206        registry.register(bridge);
207    }
208
209    Ok(())
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn test_format_mcp_content_text() {
218        let content = vec![McpContent::Text {
219            text: "Hello, world!".to_string(),
220        }];
221
222        let output = format_mcp_content(&content);
223        assert_eq!(output, "Hello, world!");
224    }
225
226    #[test]
227    fn test_format_mcp_content_multiple() {
228        let content = vec![
229            McpContent::Text {
230                text: "First line".to_string(),
231            },
232            McpContent::Text {
233                text: "Second line".to_string(),
234            },
235        ];
236
237        let output = format_mcp_content(&content);
238        assert_eq!(output, "First line\nSecond line");
239    }
240
241    #[test]
242    fn test_format_mcp_content_image() {
243        let content = vec![McpContent::Image {
244            data: "base64data".to_string(),
245            mime_type: "image/png".to_string(),
246        }];
247
248        let output = format_mcp_content(&content);
249        assert_eq!(output, "[Image: image/png]");
250    }
251
252    #[test]
253    fn test_format_mcp_content_resource() {
254        let content = vec![McpContent::Resource {
255            uri: "file:///path/to/file".to_string(),
256            mime_type: Some("text/plain".to_string()),
257            text: None,
258        }];
259
260        let output = format_mcp_content(&content);
261        assert!(output.contains("file:///path/to/file"));
262    }
263
264    #[test]
265    fn test_format_mcp_content_resource_with_text() {
266        let content = vec![McpContent::Resource {
267            uri: "file:///path/to/file".to_string(),
268            mime_type: Some("text/plain".to_string()),
269            text: Some("File contents".to_string()),
270        }];
271
272        let output = format_mcp_content(&content);
273        assert_eq!(output, "File contents");
274    }
275
276    #[test]
277    fn test_format_mcp_content_empty() {
278        let content: Vec<McpContent> = vec![];
279        let output = format_mcp_content(&content);
280        assert!(output.is_empty());
281    }
282}