Skip to main content

agentic_core/tool/
executors.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use super::mcp::handler::McpServerToolSet;
5use super::mcp::{McpClientPool, McpDiscoveredHandler, McpHandler};
6use super::registry::ToolType;
7use super::web_search::WebSearchHandler;
8use super::{GatewayExecutor, ToolError};
9use crate::types::tools::McpToolParam;
10
11pub enum GatewayExecutorRegistration {
12    Shared(Arc<dyn GatewayExecutor>),
13    Mcp {
14        server_label: String,
15        handlers: Vec<McpDiscoveredHandler>,
16    },
17}
18
19impl<T> From<Arc<T>> for GatewayExecutorRegistration
20where
21    T: GatewayExecutor,
22{
23    fn from(executor: Arc<T>) -> Self {
24        Self::Shared(executor)
25    }
26}
27
28impl From<Arc<dyn GatewayExecutor>> for GatewayExecutorRegistration {
29    fn from(executor: Arc<dyn GatewayExecutor>) -> Self {
30        Self::Shared(executor)
31    }
32}
33
34/// Shared, per-server registry of gateway-owned tool executors.
35///
36/// Built once at startup ([`GatewayExecutors::from_env`]) and reused across
37/// every request. MCP tools are the exception: their handler depends on the
38/// per-request `McpToolParam`, so discovery builds them lazily unless a handler
39/// has been pre-registered via [`GatewayExecutors::insert`].
40#[derive(Clone, Default)]
41pub struct GatewayExecutors {
42    mcp: HashMap<String, Vec<McpDiscoveredHandler>>,
43    web_search: Option<Arc<dyn GatewayExecutor>>,
44}
45
46impl GatewayExecutors {
47    #[must_use]
48    pub fn from_env(client: Arc<reqwest::Client>) -> Self {
49        Self {
50            mcp: HashMap::new(),
51            web_search: Some(Arc::new(WebSearchHandler::from_env(client))),
52        }
53    }
54
55    pub fn insert(&mut self, registration: impl Into<GatewayExecutorRegistration>) {
56        match registration.into() {
57            GatewayExecutorRegistration::Shared(executor) => match executor.tool_type() {
58                ToolType::WebSearch => self.web_search = Some(executor),
59                ToolType::Mcp => {
60                    tracing::debug!("MCP executors must be registered with a server_label and discovered handlers");
61                }
62                other => tracing::debug!(tool_type = ?other, "gateway executor type has no executor slot"),
63            },
64            GatewayExecutorRegistration::Mcp { server_label, handlers } => {
65                if handlers.is_empty() {
66                    tracing::debug!(server_label, "empty MCP discovered handler registration skipped");
67                    return;
68                }
69                if self.mcp.insert(server_label.clone(), handlers).is_some() {
70                    tracing::debug!(server_label, "replaced MCP discovered handler registration");
71                }
72            }
73        }
74    }
75
76    #[must_use]
77    pub fn web_search_handler(&self) -> Option<Arc<dyn GatewayExecutor>> {
78        self.web_search.clone()
79    }
80
81    #[must_use]
82    pub(crate) fn request_scoped(&self) -> Self {
83        self.clone()
84    }
85
86    /// Returns the discovered handlers for one request-declared MCP server.
87    ///
88    /// # Errors
89    ///
90    /// Returns a configuration error for an invalid declaration or an empty
91    /// allowed tool set, and an execution error when the server cannot connect.
92    pub async fn mcp_handler(&mut self, param: &McpToolParam) -> Result<Vec<McpDiscoveredHandler>, ToolError> {
93        Ok(self.mcp_server_tools(param).await?.discovered_handlers)
94    }
95
96    /// Returns the request-scoped tools and public discovery item for one MCP server.
97    ///
98    /// # Errors
99    ///
100    /// Returns a configuration error for an invalid declaration or an empty
101    /// allowed tool set, and an execution error when the server cannot connect.
102    pub(crate) async fn mcp_server_tools(&mut self, param: &McpToolParam) -> Result<McpServerToolSet, ToolError> {
103        validate_mcp_execution_options(param)?;
104
105        let server_label = param.server_label.trim();
106        if server_label.is_empty() {
107            return Err(ToolError::Config(
108                "MCP declaration requires a non-empty server_label".to_owned(),
109            ));
110        }
111        if let Some(cached) = self.mcp.get(server_label) {
112            let discovered_handlers = require_non_empty_mcp_handlers(
113                server_label,
114                filter_allowed_mcp_handlers(cached, param.allowed_tools.as_deref()),
115            )?;
116            return Ok(McpHandler::server_tool_set_from_handlers(
117                server_label,
118                discovered_handlers,
119            ));
120        }
121
122        let pool = McpClientPool::from_params(std::slice::from_ref(param)).await;
123        let Some(client) = pool.get(server_label).cloned() else {
124            return Err(pool.connection_error(server_label).map_or_else(
125                || {
126                    ToolError::Config(format!(
127                        "MCP server '{server_label}' has no valid request-declared configuration"
128                    ))
129                },
130                |error| ToolError::Execution(format!("MCP server '{server_label}' failed to connect: {error}")),
131            ));
132        };
133        let McpServerToolSet {
134            discovered_handlers,
135            list_tools_item,
136        } = McpHandler::discover_tools(server_label, client, param.allowed_tools.as_deref()).await?;
137        let discovered_handlers = require_non_empty_mcp_handlers(server_label, discovered_handlers)?;
138        self.mcp.insert(server_label.to_owned(), discovered_handlers.clone());
139        Ok(McpServerToolSet {
140            discovered_handlers,
141            list_tools_item,
142        })
143    }
144}
145
146fn filter_allowed_mcp_handlers(
147    handlers: &[McpDiscoveredHandler],
148    allowed_tools: Option<&[String]>,
149) -> Vec<McpDiscoveredHandler> {
150    handlers
151        .iter()
152        .filter(|handler| {
153            allowed_tools.is_none_or(|allowed| allowed.iter().any(|name| name == &handler.param.tool_name))
154        })
155        .cloned()
156        .collect()
157}
158
159fn require_non_empty_mcp_handlers(
160    server_label: &str,
161    handlers: Vec<McpDiscoveredHandler>,
162) -> Result<Vec<McpDiscoveredHandler>, ToolError> {
163    if handlers.is_empty() {
164        return Err(ToolError::Config(format!(
165            "MCP server '{server_label}' has an empty final allowed tool set"
166        )));
167    }
168    Ok(handlers)
169}
170
171fn validate_mcp_execution_options(param: &McpToolParam) -> Result<(), ToolError> {
172    if param.connector_id.is_some() {
173        return Err(ToolError::Config(
174            "MCP connector_id is not supported; configure server_url instead".to_owned(),
175        ));
176    }
177    if param.require_approval.as_deref() != Some("never") {
178        return Err(ToolError::Config(
179            "MCP require_approval must be explicitly set to 'never'; approval gating is not yet supported".to_owned(),
180        ));
181    }
182    Ok(())
183}
184
185impl std::fmt::Debug for GatewayExecutors {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        f.debug_struct("GatewayExecutors")
188            .field("mcp_server_handlers", &self.mcp.len())
189            .field("web_search", &self.web_search.is_some())
190            .finish()
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use std::sync::Arc;
197
198    use super::{GatewayExecutorRegistration, GatewayExecutors, validate_mcp_execution_options};
199    use crate::tool::mcp::{McpDiscoveredHandler, McpHandler};
200    use crate::types::tools::{McpDiscoveredToolParam, McpToolParam};
201
202    fn mcp_param(value: serde_json::Value) -> McpToolParam {
203        serde_json::from_value(value).unwrap()
204    }
205
206    fn discovered_handler(tool_name: &str) -> McpDiscoveredHandler {
207        McpDiscoveredHandler {
208            param: McpDiscoveredToolParam {
209                server_label: "counter".to_owned(),
210                tool_name: tool_name.to_owned(),
211                internal_name: format!("mcp__counter__{tool_name}"),
212                tool: serde_json::from_value(serde_json::json!({
213                    "name": tool_name,
214                    "inputSchema": {"type": "object"}
215                }))
216                .unwrap(),
217            },
218            handler: Arc::new(McpHandler::discovered_tool_spec_only()),
219        }
220    }
221
222    #[test]
223    fn mcp_execution_allows_explicit_never_approval_policy() {
224        let param = mcp_param(serde_json::json!({
225            "server_label": "counter",
226            "server_url": "http://localhost:8000/mcp",
227            "require_approval": "never"
228        }));
229
230        validate_mcp_execution_options(&param).unwrap();
231    }
232
233    #[test]
234    fn mcp_execution_rejects_omitted_approval_policy() {
235        let param = mcp_param(serde_json::json!({
236            "server_label": "counter",
237            "server_url": "http://localhost:8000/mcp"
238        }));
239
240        let error = validate_mcp_execution_options(&param).unwrap_err();
241        assert!(error.to_string().contains("must be explicitly set to 'never'"));
242    }
243
244    #[test]
245    fn mcp_execution_rejects_unsupported_approval_policy() {
246        let param = mcp_param(serde_json::json!({
247            "server_label": "counter",
248            "server_url": "http://localhost:8000/mcp",
249            "require_approval": "always"
250        }));
251
252        let error = validate_mcp_execution_options(&param).unwrap_err();
253        assert!(error.to_string().contains("approval gating is not yet supported"));
254    }
255
256    #[test]
257    fn mcp_execution_rejects_connector_id() {
258        let param = mcp_param(serde_json::json!({
259            "server_label": "counter",
260            "connector_id": "connector_dropbox"
261        }));
262
263        let error = validate_mcp_execution_options(&param).unwrap_err();
264        assert!(error.to_string().contains("connector_id is not supported"));
265    }
266
267    #[tokio::test]
268    async fn cached_mcp_server_tools_apply_request_allowed_tools_with_fresh_output_id() {
269        let mut executors = GatewayExecutors::default();
270        executors.insert(GatewayExecutorRegistration::Mcp {
271            server_label: "counter".to_owned(),
272            handlers: vec![discovered_handler("read"), discovered_handler("delete")],
273        });
274        let param = mcp_param(serde_json::json!({
275            "server_label": "counter",
276            "allowed_tools": ["read"],
277            "require_approval": "never"
278        }));
279
280        let first = executors.mcp_server_tools(&param).await.unwrap();
281        let first_output_id = first.list_tools_item.id.clone();
282        let second = executors.mcp_server_tools(&param).await.unwrap();
283
284        assert_eq!(first.discovered_handlers.len(), 1);
285        assert_eq!(first.discovered_handlers[0].param.tool_name, "read");
286        assert_eq!(first.list_tools_item.tools.len(), 1);
287        assert_eq!(first.list_tools_item.tools[0].name, "read");
288        assert_ne!(first_output_id, second.list_tools_item.id);
289    }
290
291    #[tokio::test]
292    async fn cached_mcp_handlers_reject_empty_final_allowed_set() {
293        let mut executors = GatewayExecutors::default();
294        executors.insert(GatewayExecutorRegistration::Mcp {
295            server_label: "counter".to_owned(),
296            handlers: vec![discovered_handler("delete")],
297        });
298        let param = mcp_param(serde_json::json!({
299            "server_label": "counter",
300            "allowed_tools": ["read"],
301            "require_approval": "never"
302        }));
303
304        let Err(error) = executors.mcp_server_tools(&param).await else {
305            panic!("expected empty allowed set to be rejected");
306        };
307
308        assert!(error.to_string().contains("empty final allowed tool set"));
309    }
310}