Skip to main content

agentic_core/tool/
registry.rs

1use std::collections::HashMap;
2use std::collections::hash_map::Entry;
3use std::sync::Arc;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use super::codex::insert_namespace_entries;
9use super::custom::{CustomHandler, CustomToolMap, insert_custom_entry};
10use super::executors::GatewayExecutors;
11use super::function::insert_function_entry;
12use super::mcp::handler::{McpToolMap, McpToolRef};
13use super::mcp::registry::insert_discovered_mcp_entry;
14use super::web_search::insert_web_search_entry;
15use super::{CodexNamespaceHandler, GatewayExecutor, McpHandler, NamespaceMap, ToolError, ToolOutput};
16use crate::events::WireEvent;
17
18use crate::types::io::OutputItem;
19use crate::types::io::output::{FunctionToolCall, McpListTools};
20use crate::types::tools::{CodeInterpreterToolParam, FileSearchToolParam, ResponsesTool};
21use crate::utils::common::serialize_to_value_or_custom_default;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ToolType {
26    Function,
27    Custom,
28    CodexNamespace,
29    Mcp,
30    /// Internal routing discriminant. Serializes as `"web_search"`.
31    /// Note: the corresponding `ResponsesTool` wire tag is `"web_search_preview"`.
32    /// `ToolType` is not used in wire-facing types so the names differ intentionally.
33    WebSearch,
34    FileSearch,
35    CodeInterpreter,
36}
37
38impl ToolType {
39    #[must_use]
40    pub(crate) const fn description(self) -> &'static str {
41        match self {
42            Self::Function => "function tool",
43            Self::Custom => "custom tool",
44            Self::CodexNamespace => "Codex namespace tool",
45            Self::Mcp => "MCP tool",
46            Self::WebSearch => "web search tool",
47            Self::FileSearch => "file search tool",
48            Self::CodeInterpreter => "code interpreter tool",
49        }
50    }
51
52    #[must_use]
53    pub const fn is_gateway_owned(self) -> bool {
54        !matches!(self, Self::Function | Self::Custom | Self::CodexNamespace)
55    }
56}
57
58/// Per-request routing entry keyed by the tool name the model will call.
59#[derive(Clone)]
60pub struct ToolEntry {
61    pub tool_type: ToolType,
62    /// Full serialised tool param for the executor (used during dispatch).
63    pub config: Value,
64    /// For MCP tools: which server this tool belongs to.
65    pub server_label: Option<String>,
66    pub handler: Option<Arc<dyn GatewayExecutor>>,
67}
68
69impl std::fmt::Debug for ToolEntry {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.debug_struct("ToolEntry")
72            .field("tool_type", &self.tool_type)
73            .field("config", &self.config)
74            .field("server_label", &self.server_label)
75            .field("handler", &self.handler.is_some())
76            .finish()
77    }
78}
79
80fn insert_unique_tool_entries(
81    entries: &mut HashMap<String, ToolEntry>,
82    insert: impl FnOnce(&mut HashMap<String, ToolEntry>),
83) -> Result<(), ToolError> {
84    let mut resolved = HashMap::new();
85    insert(&mut resolved);
86    for (name, entry) in resolved {
87        match entries.entry(name) {
88            Entry::Occupied(existing) => {
89                return Err(ToolError::Config(format!(
90                    "{} registry name '{}' conflicts with existing {}",
91                    entry.tool_type.description(),
92                    existing.key(),
93                    existing.get().tool_type.description()
94                )));
95            }
96            Entry::Vacant(vacant) => {
97                vacant.insert(entry);
98            }
99        }
100    }
101    Ok(())
102}
103
104pub struct GatewayDispatchResult {
105    pub tool_type: ToolType,
106    pub output: Result<ToolOutput, ToolError>,
107}
108
109// TODO: move to a dedicated file_search module alongside its `ToolHandler`
110// once file_search execution is implemented.
111fn insert_file_search_entry(
112    entries: &mut HashMap<String, ToolEntry>,
113    p: &FileSearchToolParam,
114    handler: Option<Arc<dyn GatewayExecutor>>,
115) {
116    serialize_to_value_or_custom_default(
117        p,
118        "file_search tool config serialization failed",
119        |config| {
120            entries.insert(
121                "file_search".to_owned(),
122                ToolEntry {
123                    tool_type: ToolType::FileSearch,
124                    config,
125                    server_label: None,
126                    handler,
127                },
128            );
129        },
130        (),
131    );
132}
133
134// TODO: move to a dedicated code_interpreter module alongside its `ToolHandler`
135// once code_interpreter execution is implemented.
136fn insert_code_interpreter_entry(
137    entries: &mut HashMap<String, ToolEntry>,
138    p: &CodeInterpreterToolParam,
139    handler: Option<Arc<dyn GatewayExecutor>>,
140) {
141    serialize_to_value_or_custom_default(
142        p,
143        "code_interpreter tool config serialization failed",
144        |config| {
145            entries.insert(
146                "code_interpreter".to_owned(),
147                ToolEntry {
148                    tool_type: ToolType::CodeInterpreter,
149                    config,
150                    server_label: None,
151                    handler,
152                },
153            );
154        },
155        (),
156    );
157}
158
159/// Request-scoped registry built from `RequestPayload.tools`.
160/// Maps the name the LLM sees → routing metadata.
161#[derive(Debug, Default)]
162pub struct ToolRegistry {
163    entries: HashMap<String, ToolEntry>,
164
165    /// Built once from the declared tools, so final payload and streaming event
166    /// restoration don't rebuild it on every call.
167    namespace_map: Option<NamespaceMap>,
168
169    /// Maps normalized custom function names back to their public declarations
170    /// for response lifecycle metadata restoration.
171    custom_tool_map: Option<CustomToolMap>,
172
173    /// Maps model-visible MCP function names back to their public server and
174    /// tool identities without reparsing executor configuration.
175    mcp_tool_map: McpToolMap,
176
177    /// Request-scoped MCP discovery output items retained in declaration order.
178    mcp_list_tools_items: Vec<McpListTools>,
179}
180
181impl ToolRegistry {
182    /// Build a registry from declared tools and attach gateway handlers for dispatchable tool types.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`ToolError::Config`] when Codex namespace member flattening
187    /// would collide with another declared tool name, when discovered MCP
188    /// tools derive the same internal model-visible name, or when an MCP
189    /// declaration is itself invalid (for example, a request tries to
190    /// override a gateway-configured server's connection). Transient MCP
191    /// discovery failures (the server could not be reached) are not
192    /// returned as errors; they are recorded as failed [`McpListTools`]
193    /// metadata so the rest of the request can proceed.
194    ///
195    /// # Panics
196    ///
197    /// Panics if serialization of a tool param struct fails, which cannot happen
198    /// for the types defined in this module (`#[derive(Serialize)]` on plain structs).
199    pub async fn build_with_handlers(
200        tools: &mut [ResponsesTool],
201        executors: &mut GatewayExecutors,
202    ) -> Result<Self, ToolError> {
203        let mut entries = HashMap::with_capacity(tools.len());
204        let mut mcp_tool_map = McpToolMap::default();
205        let mut mcp_list_tools_items = Vec::new();
206        // Namespace members must be keyed by the same flat, model-visible name
207        // the model will call, so resolve them first — the same pure pass used
208        // to build the upstream request.
209        let resolved_tools = CodexNamespaceHandler.resolve_namespace_members(tools)?;
210        McpHandler::validate_server_labels(&resolved_tools)?;
211
212        for (index, tool) in resolved_tools.iter().enumerate() {
213            match tool {
214                ResponsesTool::Function(p) => {
215                    insert_unique_tool_entries(&mut entries, |resolved| insert_function_entry(resolved, p))?;
216                }
217                ResponsesTool::Mcp(p) => {
218                    let tool_set = match executors.mcp_server_tools(p).await {
219                        Ok(tool_set) => tool_set,
220                        // Config errors mean the declaration is invalid; the client can fix it.
221                        Err(error @ ToolError::Config(_)) => return Err(error),
222                        Err(error) => {
223                            mcp_list_tools_items.push(McpHandler::failed_list_tools_item(&p.server_label, &error));
224                            continue;
225                        }
226                    };
227                    let handlers = tool_set.discovered_handlers;
228                    mcp_list_tools_items.push(tool_set.list_tools_item);
229                    if let ResponsesTool::Mcp(declaration) = &mut tools[index] {
230                        declaration.discovered_tools = handlers.iter().map(|item| item.param.clone()).collect();
231                    }
232                    for discovered in handlers {
233                        let internal_name = discovered.param.internal_name.clone();
234                        let tool_ref = McpToolRef::from(&discovered.param);
235                        insert_unique_tool_entries(&mut entries, |resolved| {
236                            insert_discovered_mcp_entry(resolved, discovered);
237                        })?;
238                        mcp_tool_map.record(internal_name, tool_ref);
239                    }
240                }
241                ResponsesTool::WebSearch(p) => {
242                    insert_unique_tool_entries(&mut entries, |resolved| {
243                        insert_web_search_entry(resolved, p, executors.web_search_handler());
244                    })?;
245                }
246                ResponsesTool::FileSearch(p) => {
247                    insert_unique_tool_entries(&mut entries, |resolved| insert_file_search_entry(resolved, p, None))?;
248                }
249                ResponsesTool::CodeInterpreter(p) => {
250                    insert_unique_tool_entries(&mut entries, |resolved| {
251                        insert_code_interpreter_entry(resolved, p, None);
252                    })?;
253                }
254                ResponsesTool::Namespace(p) => {
255                    insert_unique_tool_entries(&mut entries, |resolved| insert_namespace_entries(resolved, p))?;
256                }
257                ResponsesTool::Custom(p) => {
258                    insert_unique_tool_entries(&mut entries, |resolved| insert_custom_entry(resolved, p))?;
259                }
260                ResponsesTool::Unknown => {
261                    tracing::debug!("unknown tool declared but skipped in registry");
262                }
263            }
264        }
265
266        let namespace_map = CodexNamespaceHandler.build_namespace_map((!tools.is_empty()).then_some(tools))?;
267        let custom_tool_map = CustomHandler::build_tool_map(tools);
268
269        Ok(Self {
270            entries,
271            namespace_map,
272            custom_tool_map,
273            mcp_tool_map,
274            mcp_list_tools_items,
275        })
276    }
277
278    #[must_use]
279    pub fn lookup(&self, tool_name: &str) -> Option<&ToolEntry> {
280        self.entries.get(tool_name)
281    }
282
283    pub(crate) fn tool_type_map(&self) -> HashMap<String, ToolType> {
284        self.entries
285            .iter()
286            .map(|(name, entry)| (name.clone(), entry.tool_type))
287            .collect()
288    }
289
290    #[must_use]
291    pub fn is_empty(&self) -> bool {
292        self.entries.is_empty()
293    }
294
295    #[must_use]
296    pub fn len(&self) -> usize {
297        self.entries.len()
298    }
299
300    #[must_use]
301    pub fn contains_mcp_server_label(&self, server_label: &str) -> bool {
302        self.mcp_tool_map.contains_server_label(server_label)
303    }
304
305    pub(crate) fn mcp_tool_ref(&self, internal_name: &str) -> Option<&McpToolRef> {
306        self.mcp_tool_map.tool_ref(internal_name)
307    }
308
309    #[must_use]
310    pub(crate) fn mcp_list_tools_items(&self) -> &[McpListTools] {
311        &self.mcp_list_tools_items
312    }
313
314    pub fn restore_final_payload_output(&self, output: &mut [OutputItem]) {
315        CodexNamespaceHandler.restore_output_items(output, self.namespace_map.as_ref());
316    }
317
318    pub fn restore_stream_event_wire(&self, wire: &mut WireEvent) -> bool {
319        let custom_restored = CustomHandler::restore_response_wire(wire, self.custom_tool_map.as_ref());
320        CodexNamespaceHandler.restore_response_wire(wire, self.namespace_map.as_ref()) | custom_restored
321    }
322
323    /// Returns the subset of `calls` whose names map to gateway-owned tools.
324    #[must_use]
325    pub fn gateway_owned<'a>(&self, calls: &'a [FunctionToolCall]) -> Vec<&'a FunctionToolCall> {
326        calls
327            .iter()
328            .filter(|c| {
329                self.entries
330                    .get(&c.name)
331                    .is_some_and(|e| e.tool_type.is_gateway_owned())
332            })
333            .collect()
334    }
335
336    #[must_use]
337    pub fn is_gateway_owned_name(&self, name: &str) -> bool {
338        self.entries
339            .get(name)
340            .is_some_and(|entry| entry.tool_type.is_gateway_owned())
341    }
342
343    /// Returns the subset of `calls` whose names map to client-owned tools
344    /// (`Function`, Codex namespace members, or unknown names).
345    #[must_use]
346    pub fn client_owned<'a>(&self, calls: &'a [FunctionToolCall]) -> Vec<&'a FunctionToolCall> {
347        calls
348            .iter()
349            .filter(|c| {
350                self.entries
351                    .get(&c.name)
352                    .is_none_or(|e| !e.tool_type.is_gateway_owned())
353            })
354            .collect()
355    }
356
357    pub async fn dispatch(&self, call: &FunctionToolCall) -> Option<GatewayDispatchResult> {
358        let entry = self.entries.get(&call.name)?;
359        let handler = entry.handler.clone()?;
360        let tool_type = entry.tool_type;
361        let config = entry.config.clone();
362        Some(GatewayDispatchResult {
363            tool_type,
364            output: handler
365                .execute(&call.call_id, &call.name, &call.arguments, &config)
366                .await,
367        })
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use crate::tool::executors::GatewayExecutorRegistration;
375    use crate::tool::mcp::{McpDiscoveredHandler, McpHandler};
376    use crate::types::event::MessageStatus;
377    use crate::types::tools::McpDiscoveredToolParam;
378
379    fn declaration(server_label: &str) -> ResponsesTool {
380        serde_json::from_value(serde_json::json!({
381            "type": "mcp",
382            "server_label": server_label,
383            "server_url": "http://127.0.0.1:8000/mcp",
384            "require_approval": "never"
385        }))
386        .expect("MCP declaration")
387    }
388
389    /// A request-declared MCP tool for a server the gateway already has
390    /// configured. Configured servers reject a request-supplied `server_url`,
391    /// so declarations for them must omit it.
392    fn configured_declaration(server_label: &str) -> ResponsesTool {
393        serde_json::from_value(serde_json::json!({
394            "type": "mcp",
395            "server_label": server_label
396        }))
397        .expect("MCP declaration")
398    }
399
400    fn discovered_handler(server_label: &str, tool_name: &str, internal_name: &str) -> McpDiscoveredHandler {
401        let param = McpDiscoveredToolParam {
402            server_label: server_label.to_owned(),
403            tool_name: tool_name.to_owned(),
404            internal_name: internal_name.to_owned(),
405            tool: serde_json::from_value(serde_json::json!({
406                "name": tool_name,
407                "description": "Discovered test tool",
408                "inputSchema": {"type": "object"}
409            }))
410            .expect("discovered MCP tool"),
411        };
412        McpDiscoveredHandler {
413            param,
414            handler: Arc::new(McpHandler::discovered_tool_spec_only()),
415        }
416    }
417
418    fn mixed_tool_declarations() -> Vec<ResponsesTool> {
419        serde_json::from_value(serde_json::json!([
420            {
421                "type": "function",
422                "name": "echo",
423                "parameters": {"type": "object"}
424            },
425            {
426                "type": "mcp",
427                "server_label": "counter"
428            },
429            {"type": "web_search_preview", "search_context_size": "low"},
430            {"type": "file_search", "vector_store_ids": ["vs_test"]},
431            {"type": "code_interpreter"},
432            {
433                "type": "namespace",
434                "name": "mcp__shell",
435                "tools": [{"type": "function", "name": "run"}]
436            },
437            {"type": "custom", "name": "freeform"},
438            {"type": "future_tool", "opaque": true}
439        ]))
440        .expect("mixed tool declarations")
441    }
442
443    fn assert_namespace_call_restoration(registry: &ToolRegistry) {
444        let mut output = vec![OutputItem::FunctionCall(FunctionToolCall {
445            id: "fc_1".to_owned(),
446            call_id: "call_1".to_owned(),
447            name: "agentic_ns__mcp__shell__run".to_owned(),
448            namespace: None,
449            arguments: "{}".to_owned(),
450            status: MessageStatus::Completed,
451        })];
452        registry.restore_final_payload_output(&mut output);
453        let OutputItem::FunctionCall(call) = &output[0] else {
454            panic!("expected restored function call");
455        };
456        assert_eq!(call.namespace.as_deref(), Some("mcp__shell"));
457        assert_eq!(call.name, "run");
458    }
459
460    fn assert_mcp_list_tools_metadata(registry: &ToolRegistry) {
461        let [list_tools] = registry.mcp_list_tools_items() else {
462            panic!("expected one MCP list-tools item");
463        };
464        assert!(list_tools.id.starts_with("mcpl_"));
465        assert_eq!(list_tools.server_label, "counter");
466        assert_eq!(
467            list_tools
468                .tools
469                .iter()
470                .map(|tool| tool.name.as_str())
471                .collect::<Vec<_>>(),
472            ["increment", "get_value"]
473        );
474        assert_eq!(list_tools.tools[0].description.as_deref(), Some("Discovered test tool"));
475        assert_eq!(list_tools.tools[0].input_schema, serde_json::json!({"type": "object"}));
476        assert_eq!(
477            list_tools.tools[0].annotations,
478            Some(serde_json::json!({"read_only": false}))
479        );
480    }
481
482    #[tokio::test]
483    async fn build_with_handlers_registers_mixed_tools_and_runtime_metadata() {
484        let mut executors = GatewayExecutors::from_env(Arc::new(reqwest::Client::new()));
485        executors.insert(GatewayExecutorRegistration::Mcp {
486            server_label: "counter".to_owned(),
487            handlers: vec![
488                discovered_handler("counter", "increment", "mcp__counter__increment"),
489                discovered_handler("counter", "get_value", "mcp__counter__get_value"),
490            ],
491        });
492        let mut tools = mixed_tool_declarations();
493
494        let registry = ToolRegistry::build_with_handlers(&mut tools, &mut executors)
495            .await
496            .expect("mixed registry");
497
498        assert_eq!(registry.len(), 8);
499        assert!(registry.contains_mcp_server_label("counter"));
500        assert!(!registry.contains_mcp_server_label("missing"));
501        assert_mcp_list_tools_metadata(&registry);
502
503        let expected_entries = [
504            ("echo", ToolType::Function, None, false),
505            ("freeform", ToolType::Custom, None, false),
506            ("mcp__counter__increment", ToolType::Mcp, Some("counter"), true),
507            ("mcp__counter__get_value", ToolType::Mcp, Some("counter"), true),
508            ("web_search", ToolType::WebSearch, None, true),
509            ("file_search", ToolType::FileSearch, None, false),
510            ("code_interpreter", ToolType::CodeInterpreter, None, false),
511            (
512                "agentic_ns__mcp__shell__run",
513                ToolType::CodexNamespace,
514                Some("mcp__shell"),
515                false,
516            ),
517        ];
518        for (name, tool_type, server_label, has_handler) in expected_entries {
519            let entry = registry
520                .lookup(name)
521                .unwrap_or_else(|| panic!("missing registry entry '{name}'"));
522            assert_eq!(entry.tool_type, tool_type, "unexpected type for '{name}'");
523            assert_eq!(
524                entry.server_label.as_deref(),
525                server_label,
526                "unexpected server label for '{name}'"
527            );
528            assert_eq!(entry.handler.is_some(), has_handler, "unexpected handler for '{name}'");
529        }
530        assert_eq!(registry.lookup("freeform").unwrap().config["name"], "freeform");
531        assert_eq!(registry.lookup("echo").unwrap().config["name"], "echo");
532        assert_eq!(
533            registry.lookup("mcp__counter__increment").unwrap().config["tool_name"],
534            "increment"
535        );
536        assert_eq!(
537            registry.lookup("web_search").unwrap().config["search_context_size"],
538            "low"
539        );
540        assert_eq!(
541            registry.lookup("file_search").unwrap().config["vector_store_ids"][0],
542            "vs_test"
543        );
544        assert_eq!(
545            registry.lookup("agentic_ns__mcp__shell__run").unwrap().config["tools"][0]["name"],
546            "agentic_ns__mcp__shell__run"
547        );
548        for name in [
549            "mcp__counter__increment",
550            "mcp__counter__get_value",
551            "web_search",
552            "file_search",
553            "code_interpreter",
554        ] {
555            assert!(registry.is_gateway_owned_name(name), "'{name}' should be gateway-owned");
556        }
557        for name in ["echo", "freeform", "agentic_ns__mcp__shell__run"] {
558            assert!(!registry.is_gateway_owned_name(name), "'{name}' should be client-owned");
559        }
560
561        let ResponsesTool::Mcp(declared) = &tools[1] else {
562            panic!("expected MCP declaration");
563        };
564        assert_eq!(declared.discovered_tools.len(), 2);
565        assert_eq!(
566            tools[1]
567                .to_function_tools()
568                .into_iter()
569                .map(|tool| tool.name)
570                .collect::<Vec<_>>(),
571            ["mcp__counter__increment", "mcp__counter__get_value"]
572        );
573
574        let ResponsesTool::Namespace(namespace) = &tools[5] else {
575            panic!("expected namespace declaration");
576        };
577        assert!(matches!(
578            namespace.tools.as_slice(),
579            [crate::types::tools::CodexNamespaceMember::Function(function)] if function.name.as_str() == "run"
580        ));
581        assert_namespace_call_restoration(&registry);
582    }
583
584    #[tokio::test]
585    async fn build_with_handlers_retains_mcp_discovery_failure_output() {
586        let mut tools = vec![declaration("unreachable")];
587        let mut executors = GatewayExecutors::default();
588
589        let registry = ToolRegistry::build_with_handlers(&mut tools, &mut executors)
590            .await
591            .expect("discovery failures should become response metadata");
592
593        let [list_tools] = registry.mcp_list_tools_items() else {
594            panic!("expected one MCP list-tools item");
595        };
596        assert_eq!(list_tools.server_label, "unreachable");
597        assert!(list_tools.tools.is_empty());
598        assert!(
599            list_tools
600                .error
601                .as_deref()
602                .is_some_and(|error| error.contains("failed"))
603        );
604        assert!(registry.is_empty());
605    }
606
607    #[tokio::test]
608    async fn duplicate_mcp_server_labels_are_rejected() {
609        let mut tools = vec![declaration("counter"), declaration("counter")];
610        let mut executors = GatewayExecutors::default();
611
612        let error = ToolRegistry::build_with_handlers(&mut tools, &mut executors)
613            .await
614            .expect_err("duplicate server_label must fail");
615
616        assert!(
617            matches!(error, ToolError::Config(message) if message.contains("duplicate MCP declarations") && message.contains("counter"))
618        );
619    }
620
621    #[tokio::test]
622    async fn cross_server_internal_name_collisions_are_rejected() {
623        let internal_name = "mcp__foo__bar__baz";
624        let mut executors = GatewayExecutors::default();
625        executors.insert(GatewayExecutorRegistration::Mcp {
626            server_label: "foo".to_owned(),
627            handlers: vec![discovered_handler("foo", "bar__baz", internal_name)],
628        });
629        executors.insert(GatewayExecutorRegistration::Mcp {
630            server_label: "foo__bar".to_owned(),
631            handlers: vec![discovered_handler("foo__bar", "baz", internal_name)],
632        });
633        let mut tools = vec![configured_declaration("foo"), configured_declaration("foo__bar")];
634
635        let error = ToolRegistry::build_with_handlers(&mut tools, &mut executors)
636            .await
637            .expect_err("colliding derived MCP names must fail");
638
639        assert!(matches!(
640            error,
641            ToolError::Config(message)
642                if message.contains(internal_name) && message.matches("MCP tool").count() == 2
643        ));
644    }
645
646    #[tokio::test]
647    async fn discovered_mcp_name_collision_with_function_is_rejected_in_any_order() {
648        let internal_name = "mcp__counter__increment";
649
650        for mcp_first in [false, true] {
651            let function = serde_json::from_value(serde_json::json!({
652                "type": "function",
653                "name": internal_name
654            }))
655            .expect("function declaration");
656            let mcp = configured_declaration("counter");
657            let mut tools = if mcp_first {
658                vec![mcp, function]
659            } else {
660                vec![function, mcp]
661            };
662            let mut executors = GatewayExecutors::default();
663            executors.insert(GatewayExecutorRegistration::Mcp {
664                server_label: "counter".to_owned(),
665                handlers: vec![discovered_handler("counter", "increment", internal_name)],
666            });
667
668            let error = ToolRegistry::build_with_handlers(&mut tools, &mut executors)
669                .await
670                .expect_err("MCP internal name must not overwrite a function");
671
672            assert!(matches!(
673                error,
674                ToolError::Config(message)
675                    if message.contains(internal_name)
676                        && message.contains("MCP tool")
677                        && message.contains("function tool")
678            ));
679        }
680    }
681}