Skip to main content

crabtalk_runtime/mcp/
tool.rs

1//! Tool dispatch and schema registration for the MCP tool.
2
3use crate::{Env, host::Host};
4use schemars::JsonSchema;
5use serde::Deserialize;
6use wcore::agent::ToolDescription;
7
8#[derive(Deserialize, JsonSchema)]
9pub struct Mcp {
10    /// Tool name to call. If no exact match, returns fuzzy matches.
11    /// Leave empty to list all available MCP tools.
12    pub name: String,
13    /// JSON-encoded arguments string (only used when calling a tool).
14    #[serde(default)]
15    pub args: Option<String>,
16}
17
18impl ToolDescription for Mcp {
19    const DESCRIPTION: &'static str =
20        "Call an MCP tool by name, or list available tools if no exact match.";
21}
22
23impl<H: Host> Env<H> {
24    pub async fn dispatch_mcp(&self, args: &str, agent: &str) -> Result<String, String> {
25        let input: Mcp =
26            serde_json::from_str(args).map_err(|e| format!("invalid arguments: {e}"))?;
27
28        let bridge = self.mcp.bridge().await;
29
30        // Resolve allowed tools from agent's MCP scope.
31        let allowed_tools: Option<Vec<String>> = if let Some(scope) = self.scopes.get(agent)
32            && !scope.mcps.is_empty()
33        {
34            let servers = bridge.list_servers().await;
35            Some(
36                servers
37                    .into_iter()
38                    .filter(|(name, _)| scope.mcps.iter().any(|m| m == name.as_str()))
39                    .flat_map(|(_, tools)| tools)
40                    .collect(),
41            )
42        } else {
43            None
44        };
45
46        // Try exact call first.
47        if !input.name.is_empty() {
48            // Enforce scope.
49            if let Some(ref allowed) = allowed_tools
50                && !allowed.iter().any(|t| t.as_str() == input.name)
51            {
52                return Err(format!("tool not available: {}", input.name));
53            }
54
55            let tools = bridge.tools().await;
56            if tools.iter().any(|t| t.function.name == input.name) {
57                let tool_args = input.args.unwrap_or_default();
58                return bridge.call(&input.name, &tool_args).await;
59            }
60        }
61
62        // No exact match — fuzzy search / list all.
63        let query = input.name.to_lowercase();
64        let tools = bridge.tools().await;
65        let matches: Vec<String> = tools
66            .iter()
67            .filter(|t| {
68                if let Some(ref allowed) = allowed_tools
69                    && !allowed
70                        .iter()
71                        .any(|a| a.as_str() == t.function.name.as_str())
72                {
73                    return false;
74                }
75                query.is_empty()
76                    || t.function.name.to_lowercase().contains(&query)
77                    || t.function
78                        .description
79                        .as_deref()
80                        .is_some_and(|d| d.to_lowercase().contains(&query))
81            })
82            .map(|t| {
83                format!(
84                    "{}: {}",
85                    t.function.name,
86                    t.function.description.as_deref().unwrap_or(""),
87                )
88            })
89            .collect();
90
91        // Empty discovery is not a failure — the caller asked "what matches?"
92        // and got "nothing". Return Ok so the UI doesn't flag it as an error.
93        if matches.is_empty() {
94            Ok("no tools found".to_owned())
95        } else {
96            Ok(matches.join("\n"))
97        }
98    }
99}