agentic_core/tool/
executors.rs1use 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#[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 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 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(¶m).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(¶m).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(¶m).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(¶m).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(¶m).await.unwrap();
281 let first_output_id = first.list_tools_item.id.clone();
282 let second = executors.mcp_server_tools(¶m).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(¶m).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}