agent_sdk/mcp/
tool_bridge.rs

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