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 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#[derive(Clone)]
60pub struct ToolEntry {
61 pub tool_type: ToolType,
62 pub config: Value,
64 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
109fn 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
134fn 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#[derive(Debug, Default)]
162pub struct ToolRegistry {
163 entries: HashMap<String, ToolEntry>,
164
165 namespace_map: Option<NamespaceMap>,
168
169 custom_tool_map: Option<CustomToolMap>,
172
173 mcp_tool_map: McpToolMap,
176
177 mcp_list_tools_items: Vec<McpListTools>,
179}
180
181impl ToolRegistry {
182 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 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 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 #[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 #[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 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(®istry);
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(®istry);
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}