Skip to main content

agent_engine/tools/
registry.rs

1//! Tool registry — maintains name→tool map and cached JSON schema for the API.
2use crate::tools::catalog::{tool_id_for, CapabilityRecord, CatalogError, ToolCatalog};
3use crate::tools::Tool;
4use serde_json::Value;
5use std::collections::{HashMap, HashSet};
6use std::sync::Arc;
7
8/// Registry of available tools. Maintains a name→tool map and a cached JSON schema
9/// array that gets sent to the API. Thread-safe via `Arc<RwLock<ToolRegistry>>`.
10#[derive(Clone)]
11pub struct ToolRegistry {
12    tools: HashMap<String, Arc<dyn Tool>>,
13    /// Cached Anthropic-compatible schema — rebuilt on register(), shared via Arc for zero-copy reads.
14    cached_schema: Arc<Vec<Value>>,
15    /// Mapping from API-safe tool names back to their runtime names.
16    api_to_runtime_names: HashMap<String, String>,
17    /// Per-tool mapping from API-safe input property names back to runtime names.
18    input_name_maps: HashMap<String, SchemaNameMap>,
19    /// Passive capability inventory kept truthful across every construction
20    /// and mutation path. Reads never change the exposed schema; mutations
21    /// advance the catalog generation fail-closed (Task 14).
22    catalog: ToolCatalog,
23}
24
25#[derive(Clone, Debug, Default)]
26struct SchemaNameMap {
27    api_to_runtime: HashMap<String, String>,
28    children: HashMap<String, SchemaNameMap>,
29    /// Name map for array `items` schemas (objects inside arrays).
30    items: Option<Box<SchemaNameMap>>,
31}
32
33impl Default for ToolRegistry {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl ToolRegistry {
40    pub fn new() -> Self {
41        let tools: Vec<Arc<dyn Tool>> = vec![
42            Arc::new(crate::tools::bash::BashTool),
43            Arc::new(crate::tools::read::ReadTool),
44            Arc::new(crate::tools::write::WriteTool),
45            Arc::new(crate::tools::edit::EditTool),
46            Arc::new(crate::tools::grep::GrepTool),
47            Arc::new(crate::tools::find::FindTool),
48            Arc::new(crate::tools::ls::LsTool),
49            Arc::new(crate::tools::subagent::SubagentTool),
50            Arc::new(crate::tools::subagent::authorize_model::SubagentModelAuthorizeTool),
51            Arc::new(crate::tools::subagent::models::SubagentModelsTool),
52            Arc::new(crate::tools::subagent::start::SubagentStartTool),
53            Arc::new(crate::tools::subagent::status::SubagentStatusTool),
54            Arc::new(crate::tools::subagent::steer::SubagentSteerTool),
55            Arc::new(crate::tools::subagent::collect::SubagentCollectTool),
56            Arc::new(crate::tools::subagent::resume::SubagentResumeTool),
57            // Task A4 continuous-memory control tool (spec §7.2). Like the
58            // authorization gateways above, primary-registry only: recursive
59            // subagent registries exclude it so a delegated model can never
60            // steer the session's memory-context leases.
61            Arc::new(crate::tools::memory_context::MemoryContextTool),
62            Arc::new(crate::tools::shell::ShellStartTool),
63            Arc::new(crate::tools::shell::ShellSendTool),
64            Arc::new(crate::tools::shell::ShellEndTool),
65            // Task 17 discovery/authorization gateways (spec §7.2). Present
66            // in the primary runtime registry only; subagent registries
67            // (`without_subagent*`) deliberately exclude them so recursive
68            // runtimes cannot gain broader authorization surfaces.
69            Arc::new(crate::tools::discovery::SearchToolsTool),
70            Arc::new(crate::tools::discovery::ActivateToolsTool),
71            // Task 32 project-scoped memory primitives (spec §9.5). Not in
72            // the progressive essential core — deferred until exact
73            // activation under progressive disclosure.
74            Arc::new(crate::tools::memory::MemorySearchTool),
75            Arc::new(crate::tools::memory::MemoryFetchTool),
76            Arc::new(crate::tools::memory::MemoryStoreTool),
77            Arc::new(crate::tools::memory::MemoryForgetTool),
78        ];
79        Self::from_tools(tools)
80    }
81
82    /// Empty registry for tests and narrow embedded runtimes that want to opt in
83    /// to specific tools explicitly.
84    pub fn empty() -> Self {
85        Self::from_tools(Vec::new())
86    }
87
88    /// Remove built-in tools by runtime name (config `disabled_tools`).
89    ///
90    /// Compatibility wrapper over [`Self::try_disable`]; the only failure
91    /// mode is catalog generation exhaustion, on which this fails closed by
92    /// panicking with the registry left fully unchanged.
93    pub fn disable(&mut self, names: &[String]) {
94        self.try_disable(names).expect(CATALOG_REJECTED_MSG);
95    }
96
97    /// Fallible disable: remove built-in tools by runtime name, then rebuild
98    /// the cached schema, name maps, and capability catalog. Unknown names
99    /// are ignored; a call that removes nothing is a no-op and does not
100    /// advance the catalog generation. A call that removes tools advances
101    /// the catalog generation strictly past its prior value, so grants
102    /// issued against removed capabilities cannot stay valid.
103    ///
104    /// The rebuilt candidate is fully constructed and generation-rebased
105    /// BEFORE it is committed to `self`: on any failure the live registry
106    /// (tools, schema, catalog entries, generation) is left untouched, so a
107    /// generation-exhausted disable can never leave a generation-rewound,
108    /// partially changed registry behind a non-poisoning lock.
109    pub fn try_disable(&mut self, names: &[String]) -> Result<(), CatalogError> {
110        if names.is_empty() {
111            return Ok(());
112        }
113        let remaining: Vec<Arc<dyn Tool>> = self
114            .tools
115            .values()
116            .filter(|t| !names.iter().any(|n| n == t.name()))
117            .cloned()
118            .collect();
119        if remaining.len() == self.tools.len() {
120            return Ok(());
121        }
122        let prior = self.catalog.generation();
123        let mut candidate = Self::try_from_tools(remaining)?;
124        candidate.catalog.rebase_past(prior)?;
125        *self = candidate;
126        Ok(())
127    }
128
129    /// Registry without subagent tool — used for subagent runtimes to prevent recursion.
130    pub fn without_subagent() -> Self {
131        let tools: Vec<Arc<dyn Tool>> = vec![
132            Arc::new(crate::tools::bash::BashTool),
133            Arc::new(crate::tools::read::ReadTool),
134            Arc::new(crate::tools::write::WriteTool),
135            Arc::new(crate::tools::edit::EditTool),
136            Arc::new(crate::tools::grep::GrepTool),
137            Arc::new(crate::tools::find::FindTool),
138            Arc::new(crate::tools::ls::LsTool),
139            Arc::new(crate::tools::shell::ShellStartTool),
140            Arc::new(crate::tools::shell::ShellSendTool),
141            Arc::new(crate::tools::shell::ShellEndTool),
142        ];
143        Self::from_tools(tools)
144    }
145
146    /// Built-in subagent registry plus extension tools merged in from a shared
147    /// registry. Used by subagents that need to invoke extension-provided
148    /// tools while still excluding the recursive `subagent_*` tools.
149    ///
150    /// Only tools whose `Tool::extension_id()` returns `Some(_)` are merged;
151    /// built-ins inside `extension_tools` are ignored to avoid duplicating or
152    /// shadowing the canonical built-in instances.
153    pub fn without_subagent_with_extensions(extension_tools: &ToolRegistry) -> Self {
154        let mut combined = Self::without_subagent();
155        for tool in extension_tools.tools.values() {
156            if tool.extension_id().is_some() && !is_recursive_subagent_tool_name(tool.name()) {
157                combined
158                    .insert_tool_with_catalog(tool.clone())
159                    .expect(CATALOG_REJECTED_MSG);
160            }
161        }
162        combined.rebuild_schema();
163        combined
164    }
165
166    /// Infallible construction wrapper over [`Self::try_from_tools`] for
167    /// compiled-in tool lists. A typed failure here (duplicate capability
168    /// identity or generation exhaustion) means the constructed set itself
169    /// is inconsistent, so this fails closed by panicking rather than
170    /// exposing live tools the catalog could not truthfully record.
171    fn from_tools(tool_list: Vec<Arc<dyn Tool>>) -> Self {
172        Self::try_from_tools(tool_list).expect(CATALOG_REJECTED_MSG)
173    }
174
175    /// Integration-test construction seam: build one registry in a single
176    /// deterministic pass so dormant-catalog scaling tests do not exercise
177    /// the intentionally compatibility-oriented per-registration rebuild.
178    #[doc(hidden)]
179    pub fn from_tools_for_tests(tool_list: Vec<Arc<dyn Tool>>) -> Self {
180        Self::from_tools(tool_list)
181    }
182
183    fn try_from_tools(tool_list: Vec<Arc<dyn Tool>>) -> Result<Self, CatalogError> {
184        let mut registry = ToolRegistry {
185            tools: HashMap::new(),
186            cached_schema: Arc::new(Vec::new()),
187            api_to_runtime_names: HashMap::new(),
188            input_name_maps: HashMap::new(),
189            catalog: ToolCatalog::empty(),
190        };
191        // Insert all tools first, then rebuild schema once.
192        // Calling register() in a loop would rebuild_schema() on every
193        // iteration, making initialization O(n²) with MCP tool counts.
194        for tool in tool_list {
195            let name = tool.name().to_string();
196            registry.tools.insert(name, tool);
197        }
198        // Catalog the constructed set in deterministic name order. Purely
199        // passive: reads in-process metadata only, invokes no factory,
200        // starts no process, exposes no schema, issues no grant. Distinct
201        // runtime names collapsing onto one capability identity fail typed —
202        // construction must never hide an uncataloged live tool.
203        let sorted: Vec<Arc<dyn Tool>> =
204            registry.iter_tools_sorted().into_iter().cloned().collect();
205        for tool in sorted {
206            let record = CapabilityRecord::for_registered_tool(&tool);
207            registry.catalog.upsert(None, record)?;
208        }
209        registry.rebuild_schema();
210        Ok(registry)
211    }
212
213    fn api_safe_name(name: &str, used: &HashSet<String>) -> String {
214        Self::api_safe_identifier(name, used, 128, false)
215    }
216
217    fn api_safe_property_name(name: &str, used: &HashSet<String>) -> String {
218        Self::api_safe_identifier(name, used, 64, true)
219    }
220
221    fn api_safe_identifier(
222        name: &str,
223        used: &HashSet<String>,
224        max_len: usize,
225        allow_dot: bool,
226    ) -> String {
227        let mut sanitized = String::with_capacity(name.len());
228        for ch in name.chars() {
229            if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || (allow_dot && ch == '.') {
230                sanitized.push(ch);
231            } else {
232                sanitized.push('_');
233            }
234        }
235        if sanitized.is_empty() {
236            sanitized.push_str("field");
237        }
238        if sanitized.len() > max_len {
239            sanitized.truncate(max_len);
240        }
241
242        let base = sanitized.clone();
243        let mut suffix = 2;
244        while used.contains(&sanitized) {
245            let suffix_str = format!("_{suffix}");
246            let keep = max_len.saturating_sub(suffix_str.len());
247            sanitized = format!("{}{}", &base[..base.len().min(keep)], suffix_str);
248            suffix += 1;
249        }
250        sanitized
251    }
252
253    fn sanitize_schema(mut schema: Value) -> (Value, SchemaNameMap) {
254        let mut map = SchemaNameMap::default();
255        let Some(obj) = schema.as_object_mut() else {
256            return (schema, map);
257        };
258
259        let mut required_name_map = HashMap::new();
260        if let Some(props_value) = obj.get_mut("properties") {
261            if let Some(props) = props_value.as_object_mut() {
262                let original = std::mem::take(props);
263                let mut used = HashSet::new();
264                for (runtime_name, child_schema) in original {
265                    let api_name = Self::api_safe_property_name(&runtime_name, &used);
266                    used.insert(api_name.clone());
267                    required_name_map.insert(runtime_name.clone(), api_name.clone());
268                    map.api_to_runtime.insert(api_name.clone(), runtime_name);
269
270                    let (sanitized_child, child_map) = Self::sanitize_schema(child_schema);
271                    if !child_map.api_to_runtime.is_empty() || !child_map.children.is_empty() {
272                        map.children.insert(api_name.clone(), child_map);
273                    }
274                    props.insert(api_name, sanitized_child);
275                }
276            }
277        }
278
279        if let Some(required) = obj.get_mut("required").and_then(Value::as_array_mut) {
280            for item in required.iter_mut() {
281                if let Some(name) = item.as_str() {
282                    if let Some(api_name) = required_name_map.get(name) {
283                        *item = Value::String(api_name.clone());
284                    }
285                }
286            }
287        }
288
289        // Recurse into array item schemas; store the child map so
290        // translate_input_names can reverse-map property names inside array elements.
291        if let Some(items) = obj.get_mut("items") {
292            let (sanitized_items, items_map) = Self::sanitize_schema(std::mem::take(items));
293            if !items_map.api_to_runtime.is_empty()
294                || !items_map.children.is_empty()
295                || items_map.items.is_some()
296            {
297                map.items = Some(Box::new(items_map));
298            }
299            *items = sanitized_items;
300        }
301
302        (schema, map)
303    }
304
305    fn translate_input_names(input: Value, map: &SchemaNameMap) -> Value {
306        match input {
307            Value::Object(obj) => {
308                let mut out = serde_json::Map::new();
309                for (api_name, value) in obj {
310                    let runtime_name = map
311                        .api_to_runtime
312                        .get(&api_name)
313                        .cloned()
314                        .unwrap_or_else(|| api_name.clone());
315                    let value = if let Some(child) = map.children.get(&api_name) {
316                        Self::translate_input_names(value, child)
317                    } else {
318                        value
319                    };
320                    out.insert(runtime_name, value);
321                }
322                Value::Object(out)
323            }
324            Value::Array(arr) => {
325                // If the schema had an items map, apply it to each array element.
326                if let Some(items_map) = &map.items {
327                    Value::Array(
328                        arr.into_iter()
329                            .map(|v| Self::translate_input_names(v, items_map))
330                            .collect(),
331                    )
332                } else {
333                    Value::Array(arr)
334                }
335            }
336            other => other,
337        }
338    }
339
340    fn rebuild_schema(&mut self) {
341        let mut used = HashSet::new();
342        let mut api_to_runtime_names = HashMap::new();
343        let mut input_name_maps = HashMap::new();
344        let mut schema = Vec::with_capacity(self.tools.len());
345
346        // Sort by (origin class, runtime name) for deterministic API name
347        // assignment. HashMap iteration is random, so without sorting,
348        // collision suffixes (_2, _3) could change between rebuilds,
349        // breaking in-flight conversations.
350        //
351        // Built-ins claim their API names FIRST (memory-fetch schema
352        // collision bugfix): an extension/MCP runtime name like
353        // `memory:fetch` sanitizes to `memory_fetch`, and pure
354        // lexicographic order let it win the built-in's exact wire name
355        // (':' sorts before '_'), demoting the built-in to `memory_fetch_2`
356        // and exposing the extension's schema under the built-in's name.
357        // With class priority the built-in keeps its name and the colliding
358        // non-built-in gets the deterministic suffix. This only reorders
359        // NAME ASSIGNMENT; execution-gate resolution is unchanged.
360        let mut sorted_tools: Vec<_> = self.tools.values().collect();
361        sorted_tools.sort_by_key(|t| {
362            let builtin_first = match t.origin() {
363                crate::tools::ToolOrigin::Builtin => 0u8,
364                _ => 1u8,
365            };
366            (builtin_first, t.name().to_string())
367        });
368
369        for tool in sorted_tools {
370            let runtime_name = tool.name();
371            let api_name = Self::api_safe_name(runtime_name, &used);
372            used.insert(api_name.clone());
373            api_to_runtime_names.insert(api_name.clone(), runtime_name.to_string());
374            let (input_schema, input_map) = Self::sanitize_schema(tool.parameters());
375            input_name_maps.insert(api_name.clone(), input_map);
376            schema.push(serde_json::json!({
377                "name": api_name,
378                "description": tool.description(),
379                "input_schema": input_schema
380            }));
381        }
382
383        self.api_to_runtime_names = api_to_runtime_names;
384        self.input_name_maps = input_name_maps;
385        self.cached_schema = Arc::new(schema);
386    }
387
388    /// Register an additional tool at runtime (e.g. MCP tools, custom tools).
389    /// If a tool with the same name exists, it is replaced.
390    ///
391    /// Compatibility wrapper over [`Self::try_register`]. The only failure
392    /// mode is catalog generation exhaustion (a `u64` mutation counter),
393    /// which is unreachable in practice; if it ever occurs this wrapper
394    /// fails closed by panicking rather than exposing a tool the catalog
395    /// could not record. Callers that can surface errors should prefer
396    /// [`Self::try_register`].
397    pub fn register(&mut self, tool: Arc<dyn Tool>) {
398        self.try_register(tool).expect(CATALOG_REJECTED_MSG);
399    }
400
401    /// Fallible registration: catalog the tool first (fail-closed — on error
402    /// nothing is mutated and no schema is exposed), then insert it into the
403    /// live map and rebuild the exposed schema. Replacing a tool whose
404    /// capability identity changes drops the stale catalog entry in the same
405    /// generation advance.
406    pub fn try_register(&mut self, tool: Arc<dyn Tool>) -> Result<(), CatalogError> {
407        self.insert_tool_with_catalog(tool)?;
408        self.rebuild_schema();
409        Ok(())
410    }
411
412    /// Atomically register a whole batch (Task 19 boot-time dormant MCP
413    /// entries). STRICTLY ADDITIVE: before any mutation, every member's
414    /// runtime name AND capability identity is preflighted against both the
415    /// live registry and the rest of the batch — an existing tool can never
416    /// be replaced through a batch, and two members whose distinct
417    /// identities collapse onto one runtime name (e.g. `ext__` separator
418    /// collisions) are rejected typed. Only after preflight is the FULL
419    /// candidate registry built (per-tool catalog mutations plus a single
420    /// schema rebuild) and committed on complete success. Any failure
421    /// rejects the ENTIRE batch and leaves the live registry — tools,
422    /// schema, catalog entries, and generation — byte-identically
423    /// unchanged; no partial batch can ever be observed. Never invokes any
424    /// tool, factory, extension handler, or MCP connection.
425    pub fn try_register_batch(
426        &mut self,
427        tool_list: Vec<Arc<dyn Tool>>,
428    ) -> Result<usize, CatalogError> {
429        if tool_list.is_empty() {
430            return Ok(0);
431        }
432        let mut batch_names: HashSet<String> = HashSet::new();
433        let mut batch_ids: HashSet<crate::tools::catalog::ToolId> = HashSet::new();
434        for tool in &tool_list {
435            let name = tool.name();
436            if self.tools.contains_key(name) || !batch_names.insert(name.to_string()) {
437                return Err(CatalogError::DuplicateRuntimeName(
438                    agent_core::BoundedText::new(name, 128).text,
439                ));
440            }
441            let id = tool_id_for(tool.as_ref());
442            if self.catalog.get(&id).is_some() || !batch_ids.insert(id.clone()) {
443                return Err(CatalogError::DuplicateToolId(id));
444            }
445        }
446        let count = tool_list.len();
447        let mut candidate = self.clone();
448        for tool in tool_list {
449            candidate.insert_tool_with_catalog(tool)?;
450        }
451        candidate.rebuild_schema();
452        *self = candidate;
453        Ok(count)
454    }
455
456    /// Shared fail-closed mutation core: catalog mutation happens before the
457    /// tools-map mutation, so a catalog failure leaves the registry (and the
458    /// exposed schema) untouched. A capability identity already owned by a
459    /// DIFFERENT runtime name fails typed (`CatalogError::DuplicateToolId`)
460    /// instead of silently diverging live tools from the catalog; only the
461    /// same-runtime-name tool being replaced may keep its identity. Never
462    /// invokes the tool, its factory, any extension handler, or any MCP
463    /// connection method.
464    fn insert_tool_with_catalog(&mut self, tool: Arc<dyn Tool>) -> Result<(), CatalogError> {
465        let record = CapabilityRecord::for_registered_tool(&tool);
466        let replaced = self
467            .tools
468            .get(tool.name())
469            .map(|old| tool_id_for(old.as_ref()));
470        self.catalog.upsert(replaced.as_ref(), record)?;
471        self.tools.insert(tool.name().to_string(), tool);
472        Ok(())
473    }
474
475    /// Read-only view of the capability catalog. Reading never mutates
476    /// registry state; the exposed `tools_schema()` stays byte-identical.
477    pub fn catalog(&self) -> &ToolCatalog {
478        &self.catalog
479    }
480
481    /// Boundary-test support: resume the catalog generation counter at an
482    /// explicit value. Grants nothing, exposes nothing, and changes no
483    /// tools, schema, or catalog entries; only the counter consulted by
484    /// fail-closed mutation checks is overwritten. Doc-hidden instead of
485    /// cfg-gated: this crate's integration tests (e.g.
486    /// `tests/execution_gate.rs`) run without the `testing` feature, so
487    /// `#[cfg(any(test, feature = "testing"))]` would break their imports.
488    #[doc(hidden)]
489    pub fn resume_catalog_generation_for_tests(
490        &mut self,
491        generation: crate::tools::catalog::CatalogGeneration,
492    ) {
493        self.catalog.set_generation_for_tests(generation);
494    }
495
496    pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
497        let runtime_name = self
498            .api_to_runtime_names
499            .get(name)
500            .map(String::as_str)
501            .unwrap_or(name);
502        self.tools.get(runtime_name)
503    }
504
505    pub fn runtime_name_for_api<'a>(&'a self, name: &'a str) -> &'a str {
506        self.api_to_runtime_names
507            .get(name)
508            .map(String::as_str)
509            .unwrap_or(name)
510    }
511
512    /// Atomic wire-name resolution for the execution gate (Task 16): map
513    /// the incoming API/wire name through the deterministic sanitized-name
514    /// reverse mapping to the exact live runtime tool, then derive its
515    /// catalog [`crate::tools::catalog::ToolId`] from the resolved tool
516    /// instance itself — never from the alias string. Because this runs on
517    /// one `&self` borrow (i.e. under the caller's registry read lock), the
518    /// resolution, the catalog reachable via [`Self::catalog`], and any
519    /// subsequent implementation acquisition observe one consistent
520    /// registry snapshot.
521    pub fn resolve_wire_call(
522        &self,
523        wire_name: &str,
524    ) -> Option<crate::tools::activation::ResolvedToolCall> {
525        let runtime_name = self
526            .api_to_runtime_names
527            .get(wire_name)
528            .map(String::as_str)
529            .unwrap_or(wire_name);
530        let tool = self.tools.get(runtime_name)?;
531        Some(crate::tools::activation::ResolvedToolCall::new(
532            wire_name,
533            runtime_name,
534            tool_id_for(tool.as_ref()),
535        ))
536    }
537
538    pub fn translate_input_for_api_tool(&self, tool_name: &str, input: Value) -> Value {
539        if let Some(map) = self.input_name_maps.get(tool_name) {
540            Self::translate_input_names(input, map)
541        } else {
542            input
543        }
544    }
545
546    pub fn tools_schema(&self) -> Arc<Vec<Value>> {
547        Arc::clone(&self.cached_schema)
548    }
549
550    /// Deterministic session schema projection (Task 17, spec §7.7): select
551    /// from the EXISTING cached exposed schema exactly the entries whose
552    /// capability identity is core or exactly-activated in `session`,
553    /// preserving cached byte ordering, API-safe names, and collision
554    /// suffixes. Pure read: no catalog insertion, no schema rebuild, no
555    /// factory invocation, no process/network. Catalog generation drift or
556    /// a changed pinned schema digest fails typed instead of serving a
557    /// stale projection. Default runtime exposure (`tools_schema`) is
558    /// unaffected until Task 18 opts in.
559    pub fn session_tools_schema(
560        &self,
561        session: &crate::tools::activation::SessionToolSet,
562    ) -> Result<Vec<Value>, SessionSchemaError> {
563        if session.is_stale(&self.catalog) {
564            return Err(SessionSchemaError::StaleSessionSet {
565                set: session.catalog_generation(),
566                catalog: self.catalog.generation(),
567            });
568        }
569        let mut projected = Vec::new();
570        for entry in self.cached_schema.iter() {
571            let Some(api_name) = entry["name"].as_str() else {
572                continue;
573            };
574            let runtime_name = self.runtime_name_for_api(api_name);
575            let Some(tool) = self.tools.get(runtime_name) else {
576                continue;
577            };
578            let id = tool_id_for(tool.as_ref());
579            let pinned = session
580                .core_schema_digest(&id)
581                .or_else(|| session.activation(&id).map(|a| a.schema_digest()));
582            let Some(pinned) = pinned else {
583                continue; // not part of this session's set — absent.
584            };
585            let record = self
586                .catalog
587                .get(&id)
588                .ok_or_else(|| SessionSchemaError::NotCataloged(id.clone()))?;
589            if pinned != record.schema_digest() {
590                return Err(SessionSchemaError::SchemaDigestMismatch(id));
591            }
592            projected.push(entry.clone());
593        }
594        Ok(projected)
595    }
596
597    /// Return runtime names of tools owned by the given extension id, sorted ascending.
598    /// Built-in tools (which return `None` from `Tool::extension_id`) are excluded.
599    pub fn tool_names_for_extension(&self, extension_id: &str) -> Vec<String> {
600        let mut names: Vec<String> = self
601            .tools
602            .values()
603            .filter(|t| t.extension_id() == Some(extension_id))
604            .map(|t| t.name().to_string())
605            .collect();
606        names.sort();
607        names
608    }
609
610    /// Iterate all registered tools, sorted by name for deterministic output.
611    /// Used by `synaps tools export` to build the static schema manifest.
612    pub fn iter_tools_sorted(&self) -> Vec<&Arc<dyn Tool>> {
613        let mut tools: Vec<_> = self.tools.values().collect();
614        tools.sort_by_key(|t| t.name());
615        tools
616    }
617}
618
619/// Typed failure for [`ToolRegistry::session_tools_schema`]. Metadata-only:
620/// bounded identities and generation counters, never schema bytes.
621#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
622pub enum SessionSchemaError {
623    #[error(
624        "session tool set generation {} is stale against catalog generation {}",
625        set.value(),
626        catalog.value()
627    )]
628    StaleSessionSet {
629        set: crate::tools::catalog::CatalogGeneration,
630        catalog: crate::tools::catalog::CatalogGeneration,
631    },
632    #[error("session member is not cataloged: {0}")]
633    NotCataloged(crate::tools::catalog::ToolId),
634    #[error("schema digest changed since the session pinned it: {0}")]
635    SchemaDigestMismatch(crate::tools::catalog::ToolId),
636}
637
638/// Fail-closed message for infallible compatibility wrappers over typed
639/// catalog mutations. Failure means either generation exhaustion (a `u64`
640/// mutation counter, unreachable in practice) or a duplicate capability
641/// identity across distinct runtime names; both panic here rather than
642/// exposing a live tool the catalog could not truthfully record.
643const CATALOG_REJECTED_MSG: &str =
644    "tool catalog rejected mutation: refusing to expose a tool the catalog could not record";
645
646fn is_recursive_subagent_tool_name(name: &str) -> bool {
647    let api_name = ToolRegistry::api_safe_name(name, &HashSet::new());
648    matches!(
649        api_name.as_str(),
650        "subagent"
651            | "subagent_start"
652            | "subagent_status"
653            | "subagent_steer"
654            | "subagent_collect"
655            | "subagent_resume"
656            | "subagent_model_authorize"
657            | "subagent_models"
658            // Task A4: the continuous-memory control tool follows the exact
659            // subagent_model_authorize precedent — recursive subagents must
660            // never see it (nor an extension tool squatting on its name).
661            | "memory_context"
662    )
663}
664#[cfg(test)]
665mod tests {
666    use super::*;
667    use crate::{Result, ToolContext};
668    use serde_json::json;
669
670    struct NamedTool(&'static str);
671
672    #[async_trait::async_trait]
673    impl Tool for NamedTool {
674        fn name(&self) -> &str {
675            self.0
676        }
677        fn description(&self) -> &str {
678            "test tool"
679        }
680        fn parameters(&self) -> Value {
681            json!({"type": "object"})
682        }
683        async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
684            Ok("ok".to_string())
685        }
686    }
687
688    struct SchemaTool;
689
690    #[async_trait::async_trait]
691    impl Tool for SchemaTool {
692        fn name(&self) -> &str {
693            "schema_tool"
694        }
695        fn description(&self) -> &str {
696            "schema tool"
697        }
698        fn parameters(&self) -> Value {
699            json!({
700                "type": "object",
701                "properties": {
702                    "bad:key/that/is/far/too/long/for/anthropic/property/names/and/keeps/going": {"type": "string"},
703                    "nested:obj": {
704                        "type": "object",
705                        "properties": {"inner/key": {"type": "string"}},
706                        "required": ["inner/key"]
707                    }
708                },
709                "required": [
710                    "bad:key/that/is/far/too/long/for/anthropic/property/names/and/keeps/going",
711                    "nested:obj"
712                ]
713            })
714        }
715        async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
716            Ok("ok".to_string())
717        }
718    }
719
720    #[test]
721    fn tool_schema_uses_api_safe_names_and_maps_back() {
722        let registry = ToolRegistry::from_tools(vec![Arc::new(NamedTool("plugin:skill.tool"))]);
723
724        assert_eq!(registry.tools_schema()[0]["name"], "plugin_skill_tool");
725        assert!(registry.get("plugin:skill.tool").is_some());
726        assert!(registry.get("plugin_skill_tool").is_some());
727        assert_eq!(
728            registry.runtime_name_for_api("plugin_skill_tool"),
729            "plugin:skill.tool"
730        );
731    }
732
733    /// Regression (memory-fetch schema collision): an extension runtime
734    /// name that SANITIZES to a built-in wire name (`memory:fetch` →
735    /// `memory_fetch`) must never claim the built-in's API name — pure
736    /// lexicographic assignment let it (':' sorts before '_'), demoting the
737    /// built-in to `memory_fetch_2` and inviting models to call the
738    /// extension `{id}` schema as if it were the built-in `{ids}` tool.
739    /// Built-ins get naming priority; extension tools keep stable
740    /// NAMESPACED API names derived from their `<plugin>:<tool>` runtime
741    /// names and never surface a bare leaf name.
742    #[test]
743    fn extension_leaf_name_never_shadows_builtin_memory_fetch_api_name() {
744        struct ExtMemoryTool {
745            runtime: &'static str,
746            ext: &'static str,
747        }
748        #[async_trait::async_trait]
749        impl Tool for ExtMemoryTool {
750            fn origin(&self) -> crate::tools::ToolOrigin {
751                crate::tools::ToolOrigin::Extension {
752                    extension_id: self.ext.to_string(),
753                }
754            }
755            fn extension_id(&self) -> Option<&str> {
756                Some(self.ext)
757            }
758            fn name(&self) -> &str {
759                self.runtime
760            }
761            fn description(&self) -> &str {
762                "extension memory tool (single-id schema)"
763            }
764            fn parameters(&self) -> Value {
765                json!({
766                    "type": "object",
767                    "properties": {"id": {"type": "string"}},
768                    "required": ["id"]
769                })
770            }
771            async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
772                Ok("ok".to_string())
773            }
774        }
775
776        let registry = ToolRegistry::from_tools(vec![
777            Arc::new(crate::tools::memory::MemoryFetchTool),
778            // Adversarial: sanitizes to exactly `memory_fetch`.
779            Arc::new(ExtMemoryTool {
780                runtime: "memory:fetch",
781                ext: "memory",
782            }),
783            // Realistic Axel shape: distinct namespaced API name.
784            Arc::new(ExtMemoryTool {
785                runtime: "axel-memory-manager:memory_fetch",
786                ext: "axel-memory-manager",
787            }),
788        ]);
789
790        // The built-in keeps its exact wire name and its `{ids}` schema.
791        assert_eq!(
792            registry.runtime_name_for_api("memory_fetch"),
793            "memory_fetch",
794            "wire name `memory_fetch` must resolve to the built-in"
795        );
796        let schema = registry.tools_schema();
797        let builtin = schema
798            .iter()
799            .find(|e| e["name"] == "memory_fetch")
800            .expect("built-in memory_fetch exposed");
801        assert_eq!(
802            builtin["input_schema"]["required"],
803            json!(["ids"]),
804            "built-in memory_fetch schema must remain {{ids: [...]}}"
805        );
806
807        // Extension tools surface only under namespaced API names.
808        let axel = schema
809            .iter()
810            .find(|e| e["name"] == "axel-memory-manager_memory_fetch")
811            .expect("extension tool exposed under namespaced API name");
812        assert_eq!(axel["input_schema"]["required"], json!(["id"]));
813        // The adversarial collision gets a deterministic suffix — never
814        // the built-in's exact name.
815        assert_eq!(
816            registry.runtime_name_for_api("memory_fetch_2"),
817            "memory:fetch"
818        );
819        // And wire-call resolution for the built-in name reaches the
820        // built-in tool identity, not the extension.
821        let resolved = registry.resolve_wire_call("memory_fetch").unwrap();
822        assert_eq!(resolved.runtime_name(), "memory_fetch");
823    }
824
825    #[test]
826    fn tool_schema_disambiguates_sanitized_name_collisions() {
827        let registry =
828            ToolRegistry::from_tools(vec![Arc::new(NamedTool("a:b")), Arc::new(NamedTool("a.b"))]);
829        let names: HashSet<String> = registry
830            .tools_schema()
831            .iter()
832            .filter_map(|s| s["name"].as_str().map(str::to_string))
833            .collect();
834
835        assert_eq!(names.len(), 2);
836        assert!(names.contains("a_b"));
837        assert!(names.contains("a_b_2"));
838        assert!(registry.get("a_b").is_some());
839        assert!(registry.get("a_b_2").is_some());
840    }
841
842    #[test]
843    fn tool_schema_truncates_long_names_to_anthropic_limit() {
844        let long = "x".repeat(140);
845        let leaked: &'static str = Box::leak(long.into_boxed_str());
846        let registry = ToolRegistry::from_tools(vec![Arc::new(NamedTool(leaked))]);
847        let schema = registry.tools_schema();
848        let name = schema[0]["name"].as_str().unwrap();
849
850        assert_eq!(name.len(), 128);
851        assert!(name
852            .chars()
853            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'));
854        assert!(registry.get(name).is_some());
855    }
856
857    #[test]
858    fn tool_schema_sanitizes_input_property_names_and_translates_inputs_back() {
859        let registry = ToolRegistry::from_tools(vec![Arc::new(SchemaTool)]);
860        let schema = registry.tools_schema();
861        let input_schema = &schema[0]["input_schema"];
862        let props = input_schema["properties"].as_object().unwrap();
863
864        assert!(props.keys().all(|k| k.len() <= 64
865            && k.chars()
866                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')));
867        assert_eq!(
868            input_schema["required"].as_array().unwrap()[0]
869                .as_str()
870                .unwrap()
871                .len(),
872            64
873        );
874        assert_eq!(input_schema["required"][1], "nested_obj");
875        assert!(props["nested_obj"]["properties"]
876            .as_object()
877            .unwrap()
878            .contains_key("inner_key"));
879        assert_eq!(props["nested_obj"]["required"][0], "inner_key");
880
881        let first_required = input_schema["required"][0].as_str().unwrap();
882        let translated = registry.translate_input_for_api_tool(
883            "schema_tool",
884            json!({
885                first_required: "value",
886                "nested_obj": {"inner_key": "nested"}
887            }),
888        );
889
890        assert_eq!(
891            translated["bad:key/that/is/far/too/long/for/anthropic/property/names/and/keeps/going"],
892            "value"
893        );
894        assert_eq!(translated["nested:obj"]["inner/key"], "nested");
895    }
896
897    #[test]
898    fn test_tool_registry_new() {
899        let registry = ToolRegistry::new();
900
901        // Includes read-only model discovery plus session authorization
902        // (Task 17 adds `search_tools` + `activate_tools`), the Task 32
903        // project-scoped memory primitives (4 tools, deferred under
904        // progressive disclosure), and the Task A4 `memory_context`
905        // control tool.
906        assert_eq!(registry.tools_schema().len(), 25);
907
908        // Should find bash tool
909        assert!(registry.get("bash").is_some());
910
911        // Should not find nonexistent tool
912        assert!(registry.get("nonexistent").is_none());
913
914        // Verify all expected tools are present
915        assert!(registry.get("bash").is_some());
916        assert!(registry.get("read").is_some());
917        assert!(registry.get("write").is_some());
918        assert!(registry.get("edit").is_some());
919        assert!(registry.get("grep").is_some());
920        assert!(registry.get("find").is_some());
921        assert!(registry.get("ls").is_some());
922        assert!(registry.get("subagent").is_some());
923        assert!(registry.get("subagent_model_authorize").is_some());
924        assert!(registry.get("subagent_models").is_some());
925    }
926
927    #[test]
928    fn test_disable_removes_tools_from_registry_and_schema() {
929        let mut registry = ToolRegistry::new();
930        let before = registry.tools_schema().len();
931
932        registry.disable(&["bash".to_string(), "ls".to_string()]);
933
934        // bash + ls gone from lookup
935        assert!(registry.get("bash").is_none());
936        assert!(registry.get("ls").is_none());
937        // others untouched
938        assert!(registry.get("read").is_some());
939        assert!(registry.get("subagent").is_some());
940        // schema rebuilt with exactly two fewer tools
941        assert_eq!(registry.tools_schema().len(), before - 2);
942    }
943
944    #[test]
945    fn test_disable_empty_and_unknown_are_noops() {
946        let mut registry = ToolRegistry::new();
947        let n = registry.tools_schema().len();
948        registry.disable(&[]); // empty → no change
949        assert_eq!(registry.tools_schema().len(), n);
950        registry.disable(&["not_a_real_tool".to_string()]); // unknown → no change
951        assert_eq!(registry.tools_schema().len(), n);
952        assert!(registry.get("bash").is_some());
953    }
954
955    #[test]
956    fn test_tool_registry_without_subagent() {
957        let registry = ToolRegistry::without_subagent();
958
959        // Should have 10 tools without subagent (7 base + 3 shell)
960        assert_eq!(registry.tools_schema().len(), 10);
961        // Should not have subagent tool
962        assert!(registry.get("subagent").is_none());
963
964        // Should still have bash tool
965        assert!(registry.get("bash").is_some());
966
967        // Verify all expected tools are present except subagent
968        assert!(registry.get("bash").is_some());
969        assert!(registry.get("read").is_some());
970        assert!(registry.get("write").is_some());
971        assert!(registry.get("edit").is_some());
972        assert!(registry.get("grep").is_some());
973        assert!(registry.get("find").is_some());
974        assert!(registry.get("ls").is_some());
975    }
976
977    #[test]
978    fn test_tool_registry_register() {
979        let mut registry = ToolRegistry::without_subagent();
980        let initial_count = registry.tools_schema().len();
981
982        // Register a new tool (using BashTool with different name for simplicity)
983        struct TestTool;
984        #[async_trait::async_trait]
985        impl Tool for TestTool {
986            fn name(&self) -> &str {
987                "test_tool"
988            }
989            fn description(&self) -> &str {
990                "A test tool"
991            }
992            fn parameters(&self) -> Value {
993                json!({"type": "object"})
994            }
995            async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
996                Ok("test result".to_string())
997            }
998        }
999
1000        registry.register(Arc::new(TestTool));
1001
1002        // Should have one more tool now
1003        assert_eq!(registry.tools_schema().len(), initial_count + 1);
1004
1005        // Should find the new tool
1006        assert!(registry.get("test_tool").is_some());
1007    }
1008
1009    #[test]
1010    fn tool_names_for_extension_filters_by_owner_and_sorts() {
1011        struct OwnedTool(&'static str, Option<&'static str>);
1012        #[async_trait::async_trait]
1013        impl Tool for OwnedTool {
1014            fn name(&self) -> &str {
1015                self.0
1016            }
1017            fn description(&self) -> &str {
1018                "owned"
1019            }
1020            fn parameters(&self) -> Value {
1021                json!({"type": "object"})
1022            }
1023            async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
1024                Ok("ok".to_string())
1025            }
1026            fn extension_id(&self) -> Option<&str> {
1027                self.1
1028            }
1029        }
1030
1031        let mut registry = ToolRegistry::without_subagent();
1032        registry.register(Arc::new(OwnedTool("alpha:zed", Some("alpha"))));
1033        registry.register(Arc::new(OwnedTool("alpha:bar", Some("alpha"))));
1034        registry.register(Arc::new(OwnedTool("beta:thing", Some("beta"))));
1035
1036        assert_eq!(
1037            registry.tool_names_for_extension("alpha"),
1038            vec!["alpha:bar".to_string(), "alpha:zed".to_string()]
1039        );
1040        assert_eq!(
1041            registry.tool_names_for_extension("beta"),
1042            vec!["beta:thing".to_string()]
1043        );
1044        assert!(registry.tool_names_for_extension("ghost").is_empty());
1045        // Built-in tools (no owner) must not leak.
1046        assert!(registry.tool_names_for_extension("bash").is_empty());
1047    }
1048
1049    struct OwnedTool(&'static str, Option<&'static str>);
1050    #[async_trait::async_trait]
1051    impl Tool for OwnedTool {
1052        fn name(&self) -> &str {
1053            self.0
1054        }
1055        fn description(&self) -> &str {
1056            "owned"
1057        }
1058        fn parameters(&self) -> Value {
1059            json!({"type": "object"})
1060        }
1061        async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
1062            Ok("ok".to_string())
1063        }
1064        fn extension_id(&self) -> Option<&str> {
1065            self.1
1066        }
1067    }
1068
1069    #[test]
1070    fn without_subagent_excludes_subagent_tools() {
1071        let registry = ToolRegistry::without_subagent();
1072        assert!(registry.get("subagent").is_none());
1073        assert!(registry.get("subagent_start").is_none());
1074        assert!(registry.get("subagent_status").is_none());
1075        assert!(registry.get("subagent_steer").is_none());
1076        assert!(registry.get("subagent_collect").is_none());
1077        assert!(registry.get("subagent_resume").is_none());
1078        // Built-ins remain.
1079        assert!(registry.get("bash").is_some());
1080        assert!(registry.get("read").is_some());
1081    }
1082
1083    #[test]
1084    fn without_subagent_with_extensions_includes_extension_tools() {
1085        let mut other = ToolRegistry::empty();
1086        other.register(Arc::new(OwnedTool("alpha:do_thing", Some("alpha"))));
1087
1088        let merged = ToolRegistry::without_subagent_with_extensions(&other);
1089
1090        // Extension tool present.
1091        assert!(merged.get("alpha:do_thing").is_some());
1092        // Built-ins still present.
1093        assert!(merged.get("bash").is_some());
1094        assert!(merged.get("read").is_some());
1095        // Subagent tools still absent.
1096        assert!(merged.get("subagent_start").is_none());
1097    }
1098
1099    #[test]
1100    fn without_subagent_with_extensions_excludes_built_ins_from_other_registry() {
1101        // `other` simulates a shared registry that already holds built-ins
1102        // (e.g. the extension manager's tools registry). Only tools with an
1103        // extension owner must be merged — built-ins must NOT be re-added or
1104        // overwritten with a foreign instance.
1105        let other = ToolRegistry::new();
1106
1107        let merged = ToolRegistry::without_subagent_with_extensions(&other);
1108
1109        // Only one instance of `bash`, and it's the built-in (no extension_id).
1110        let bash = merged.get("bash").expect("bash present");
1111        assert!(bash.extension_id().is_none());
1112        // No subagent tools leaked from `other`.
1113        assert!(merged.get("subagent_start").is_none());
1114        assert!(merged.get("subagent").is_none());
1115    }
1116
1117    #[test]
1118    fn without_subagent_with_extensions_rejects_recursive_tool_name_collisions() {
1119        let mut other = ToolRegistry::empty();
1120        for name in [
1121            "subagent",
1122            "subagent_start",
1123            "subagent_status",
1124            "subagent_steer",
1125            "subagent_collect",
1126            "subagent_resume",
1127            "subagent_model_authorize",
1128            "subagent_models",
1129            "memory_context",
1130        ] {
1131            other.register(Arc::new(OwnedTool(name, Some("malicious-extension"))));
1132        }
1133
1134        let merged = ToolRegistry::without_subagent_with_extensions(&other);
1135        for name in [
1136            "subagent",
1137            "subagent_start",
1138            "subagent_status",
1139            "subagent_steer",
1140            "subagent_collect",
1141            "subagent_resume",
1142            "subagent_model_authorize",
1143            "subagent_models",
1144            "memory_context",
1145        ] {
1146            assert!(merged.get(name).is_none(), "{name} must stay unavailable");
1147        }
1148    }
1149
1150    #[test]
1151    fn without_subagent_with_extensions_rejects_api_sanitized_recursive_collisions() {
1152        let mut other = ToolRegistry::empty();
1153        for runtime_name in [
1154            "subagent:start",
1155            "subagent:status",
1156            "subagent:steer",
1157            "subagent:collect",
1158            "subagent:resume",
1159            "subagent:model:authorize",
1160            "subagent:models",
1161            "memory:context",
1162        ] {
1163            other.register(Arc::new(OwnedTool(runtime_name, Some("subagent"))));
1164        }
1165
1166        let merged = ToolRegistry::without_subagent_with_extensions(&other);
1167        for api_name in [
1168            "subagent_start",
1169            "subagent_status",
1170            "subagent_steer",
1171            "subagent_collect",
1172            "subagent_resume",
1173            "subagent_model_authorize",
1174            "subagent_models",
1175            "memory_context",
1176        ] {
1177            assert!(
1178                merged.get(api_name).is_none(),
1179                "API-safe recursive name {api_name} must stay unavailable"
1180            );
1181        }
1182    }
1183
1184    /// Task A4: `memory_context` mirrors the `subagent_model_authorize`
1185    /// precedent — present in the primary registry, absent from every
1186    /// recursive subagent registry.
1187    #[test]
1188    fn memory_context_present_in_primary_registry_absent_in_recursive_ones() {
1189        assert!(ToolRegistry::new().get("memory_context").is_some());
1190        assert!(ToolRegistry::without_subagent()
1191            .get("memory_context")
1192            .is_none());
1193
1194        let merged = ToolRegistry::without_subagent_with_extensions(&ToolRegistry::new());
1195        assert!(
1196            merged.get("memory_context").is_none(),
1197            "recursive registries must never expose memory_context"
1198        );
1199    }
1200
1201    #[test]
1202    fn without_subagent_with_extensions_does_not_overwrite_existing_builtin() {
1203        // If `other` somehow contained a tool named like a built-in but with
1204        // an extension_id, our merge currently allows it to overwrite. We
1205        // skip non-extension tools, but we DO allow extension-owned tools to
1206        // shadow names — document this by asserting that built-ins without
1207        // matching names in `other` are preserved unchanged.
1208        let mut other = ToolRegistry::empty();
1209        other.register(Arc::new(OwnedTool("ext:custom", Some("ext"))));
1210
1211        let merged = ToolRegistry::without_subagent_with_extensions(&other);
1212        assert!(merged.get("ext:custom").is_some());
1213        assert!(merged.get("bash").is_some());
1214        assert!(merged.get("bash").unwrap().extension_id().is_none());
1215    }
1216
1217    /// Two distinct runtime names declaring the same capability identity.
1218    struct McpTwin(&'static str);
1219
1220    #[async_trait::async_trait]
1221    impl Tool for McpTwin {
1222        fn name(&self) -> &str {
1223            self.0
1224        }
1225        fn description(&self) -> &str {
1226            "mcp twin"
1227        }
1228        fn parameters(&self) -> Value {
1229            json!({"type": "object"})
1230        }
1231        async fn execute(&self, _params: Value, _ctx: ToolContext) -> Result<String> {
1232            Ok("ok".to_string())
1233        }
1234        fn origin(&self) -> crate::tools::ToolOrigin {
1235            crate::tools::ToolOrigin::Mcp {
1236                server_id: "srv".to_string(),
1237                server_tool_name: "shared".to_string(),
1238            }
1239        }
1240    }
1241
1242    #[test]
1243    #[should_panic(expected = "tool catalog rejected mutation")]
1244    fn from_tools_with_duplicate_capability_identity_fails_closed() {
1245        // Initial construction must not silently hide an uncataloged tool:
1246        // two distinct runtime names collapsing to one capability identity
1247        // must abort construction, never diverge live tools from the catalog.
1248        let _ = ToolRegistry::from_tools(vec![
1249            Arc::new(McpTwin("twin_one")),
1250            Arc::new(McpTwin("twin_two")),
1251        ]);
1252    }
1253}