Skip to main content

agentd/registry/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The **tool registry** (RFC 0028): one registry serving the root agent,
3//! workflow steps and subagents, with three tiers and dispatch precedence
4//! **internal > code > MCP**. Every tool carries JSON Schemas for input and
5//! output and a **grant** (who may call it). Internal tools are contracts with
6//! a built-in implementation by default, **overridable** by a mapped MCP tool
7//! (`tools.overrides`) and **disable-able** (`tools.disabled`); mapping-only
8//! contracts (`code.run`, `knowledge.*`, `search.*`) are unavailable until
9//! mapped (or until a server advertises the profile's tool names).
10//!
11//! The registry knows *what* a tool is and *where* it goes ([`Route`]); the
12//! runtime executes built-ins (they mutate runtime state), the turn worker
13//! calls MCP tools itself, and mapped tools are executed by whoever holds the
14//! server connection ([`Registry::map_args`] / [`Registry::map_result`]).
15
16pub mod internal;
17
18use crate::config::v2::{Role, Settings, ToolOverride};
19use crate::jsonschema;
20use crate::sec::scope::TrifectaTag;
21use crate::store::mapping::{self, Vars};
22use crate::wire::intel::ToolDef;
23use serde::{Deserialize, Serialize};
24use serde_json::{Value, json};
25use std::collections::BTreeMap;
26
27/// The tier a tool belongs to.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ToolClass {
31    Internal,
32    Code,
33    Mcp,
34}
35
36/// An override mapping (RFC 0028 §4).
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct Mapping {
39    pub server: String,
40    pub tool: String,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub args: Option<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub result: Option<String>,
45}
46
47/// How a tool is implemented.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(tag = "impl", rename_all = "snake_case")]
50pub enum Impl {
51    /// A built-in executed by the runtime.
52    BuiltIn,
53    /// A contract with no implementation until an override maps it.
54    MappingOnly,
55    /// An internal contract implemented by a mapped MCP tool.
56    Mapped(Mapping),
57    /// A code-registered tool (RFC 0022 §4).
58    Code,
59    /// An MCP server tool.
60    Mcp { server: String, tool: String },
61}
62
63/// Who may call (RFC 0028 §3).
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct Grant {
66    pub root: bool,
67    pub workflows: bool,
68    pub subagents: bool,
69    /// A2A roles granted by default (`user`, `agent`; operator always).
70    pub roles: Vec<Role>,
71}
72
73impl Grant {
74    fn all() -> Grant {
75        Grant {
76            root: true,
77            workflows: true,
78            subagents: true,
79            roles: Vec::new(),
80        }
81    }
82}
83
84/// One registered tool.
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct ToolSpec {
87    pub name: String,
88    pub description: String,
89    pub input_schema: Value,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub output_schema: Option<Value>,
92    pub class: ToolClass,
93    #[serde(rename = "implementation")]
94    pub imp: Impl,
95    pub grant: Grant,
96    #[serde(default)]
97    pub disabled: bool,
98    #[serde(default)]
99    pub family: String,
100    /// Trifecta tags inherited from the serving MCP server (mapped/MCP tools).
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub tags: Vec<TrifectaTag>,
103    /// The server that serves this tool (MCP / mapped), for `_meta` + status.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub server: Option<String>,
106}
107
108impl ToolSpec {
109    /// Callable at all (not disabled, has an implementation).
110    pub fn is_available(&self) -> bool {
111        !self.disabled && !matches!(self.imp, Impl::MappingOnly)
112    }
113    pub fn def(&self) -> ToolDef {
114        ToolDef {
115            name: self.name.clone(),
116            description: self.description.clone(),
117            input_schema: self.input_schema.clone(),
118        }
119    }
120}
121
122/// A connected MCP server's advertised tools (input to [`Registry::build`]).
123#[derive(Debug, Clone, Default)]
124pub struct ServerTools {
125    pub name: String,
126    pub ns: Option<String>,
127    pub tags: Vec<TrifectaTag>,
128    pub tools: Vec<::mcp::wire::Tool>,
129}
130
131/// Where a call goes.
132#[derive(Debug, Clone, PartialEq)]
133pub enum Route<'a> {
134    /// A built-in: the runtime executes it.
135    Internal,
136    /// A mapped internal contract: call `mapping.tool` on `mapping.server`.
137    Mapped(&'a Mapping),
138    /// A code-registered tool.
139    Code,
140    /// An MCP tool.
141    Mcp { server: &'a str, tool: &'a str },
142}
143
144/// The caller asking for definitions / permission.
145#[derive(Debug, Clone, PartialEq)]
146pub enum Caller<'a> {
147    Root,
148    Workflow,
149    /// A subagent, optionally restricted to an explicit allow-list (`subagent.run.tools`).
150    Subagent {
151        allow: Option<&'a [String]>,
152    },
153    /// An A2A principal with a role and its explicit grants (patterns).
154    Principal {
155        role: Role,
156        grants: &'a [String],
157    },
158}
159
160/// The registry.
161#[derive(Debug, Clone, Default)]
162pub struct Registry {
163    tools: BTreeMap<String, ToolSpec>,
164    servers: Vec<String>,
165    /// Non-fatal findings from the build (collisions, missing profile tools).
166    pub warnings: Vec<String>,
167}
168
169impl Registry {
170    /// Build from settings + the connected servers' tool lists. Errors are the
171    /// RFC 0028 §4 startup validation failures (unknown override target,
172    /// server not declared, tool not advertised, mapping does not compile,
173    /// disabled ∩ overrides ≠ ∅, disabling an unknown tool).
174    pub fn build(settings: &Settings, servers: &[ServerTools]) -> Result<Registry, Vec<String>> {
175        let mut reg = Registry::default();
176        let mut errors = Vec::new();
177        // 1. Internal contracts.
178        for c in internal::contracts() {
179            // `exec` (RFC 0028 §exec): a LOCAL runner only when the binary is built
180            // `--features exec` AND `security.exec.enabled` is set; otherwise it is
181            // mapping-only (delegate off-box via `tools.overrides`). It is always
182            // tagged `sensitive` + `egress` so the Rule-of-Two gate refuses to
183            // combine it with untrusted input.
184            let (imp, tags) = if c.name == "exec" {
185                let local = cfg!(feature = "exec") && settings.security.exec.enabled;
186                (
187                    if local {
188                        Impl::BuiltIn
189                    } else {
190                        Impl::MappingOnly
191                    },
192                    vec![TrifectaTag::Sensitive, TrifectaTag::Egress],
193                )
194            } else if c.builtin {
195                (Impl::BuiltIn, Vec::new())
196            } else {
197                (Impl::MappingOnly, Vec::new())
198            };
199            reg.tools.insert(
200                c.name.to_string(),
201                ToolSpec {
202                    name: c.name.to_string(),
203                    description: c.description.to_string(),
204                    input_schema: c.input,
205                    output_schema: Some(c.output),
206                    class: ToolClass::Internal,
207                    imp,
208                    grant: Grant {
209                        root: c.grant.root,
210                        workflows: c.grant.workflows,
211                        subagents: c.grant.subagents,
212                        roles: [(c.grant.user, Role::User), (c.grant.agent, Role::Agent)]
213                            .into_iter()
214                            .filter(|(on, _)| *on)
215                            .map(|(_, r)| r)
216                            .collect(),
217                    },
218                    disabled: false,
219                    family: c.family.to_string(),
220                    tags,
221                    server: None,
222                },
223            );
224        }
225        // 2. Code-registered tools (never shadow internal names — registration
226        //    already refuses them, defensively skip anyway).
227        for d in crate::tools::defs() {
228            if reg.tools.contains_key(&d.name) {
229                reg.warnings.push(format!(
230                    "code tool {:?} collides with an internal tool and is ignored",
231                    d.name
232                ));
233                continue;
234            }
235            reg.tools.insert(
236                d.name.clone(),
237                ToolSpec {
238                    name: d.name.clone(),
239                    description: d.description.clone(),
240                    input_schema: d.input_schema.clone(),
241                    output_schema: None,
242                    class: ToolClass::Code,
243                    imp: Impl::Code,
244                    grant: Grant::all(),
245                    disabled: false,
246                    family: "code".into(),
247                    tags: Vec::new(),
248                    server: Some("code".into()),
249                },
250            );
251        }
252        // 3. MCP tools: `<ns>.<tool>` when the server declares `ns`; else the
253        //    bare name unless it collides, then `<server>.<tool>`. A profile
254        //    tool (`knowledge.*`, `search.*`, `code.run`) advertised by the
255        //    configured profile server BECOMES that contract's implementation.
256        let profile_servers: BTreeMap<&str, &str> = [
257            ("knowledge", settings.knowledge.server.as_deref()),
258            ("search", settings.search.server.as_deref()),
259        ]
260        .into_iter()
261        .filter_map(|(k, v)| v.map(|v| (k, v)))
262        .collect();
263        for srv in servers {
264            reg.servers.push(srv.name.clone());
265            for t in &srv.tools {
266                let profile_family = t.name.split('.').next().unwrap_or("");
267                let is_profile = reg
268                    .tools
269                    .get(&t.name)
270                    .is_some_and(|s| matches!(s.imp, Impl::MappingOnly))
271                    && profile_servers
272                        .get(profile_family)
273                        .is_some_and(|ps| *ps == srv.name);
274                if is_profile {
275                    let spec = reg.tools.get_mut(&t.name).expect("checked");
276                    spec.imp = Impl::Mapped(Mapping {
277                        server: srv.name.clone(),
278                        tool: t.name.clone(),
279                        args: None,
280                        result: None,
281                    });
282                    spec.tags = srv.tags.clone();
283                    spec.server = Some(srv.name.clone());
284                    continue;
285                }
286                let name = match &srv.ns {
287                    Some(ns) if !ns.is_empty() => format!("{ns}.{}", t.name),
288                    _ => {
289                        if reg.tools.contains_key(&t.name) {
290                            let q = format!("{}.{}", srv.name, t.name);
291                            reg.warnings.push(format!(
292                                "mcp tool {:?} of server {:?} collides; registered as {q:?}",
293                                t.name, srv.name
294                            ));
295                            q
296                        } else {
297                            t.name.clone()
298                        }
299                    }
300                };
301                if reg.tools.contains_key(&name) {
302                    reg.warnings.push(format!("mcp tool {name:?} of server {:?} collides with an existing tool and is ignored", srv.name));
303                    continue;
304                }
305                reg.tools.insert(
306                    name.clone(),
307                    ToolSpec {
308                        name,
309                        description: t.description.clone().unwrap_or_default(),
310                        input_schema: t.input_schema.clone(),
311                        output_schema: t.output_schema.clone(),
312                        class: ToolClass::Mcp,
313                        imp: Impl::Mcp {
314                            server: srv.name.clone(),
315                            tool: t.name.clone(),
316                        },
317                        grant: Grant::all(),
318                        disabled: false,
319                        family: "mcp".into(),
320                        tags: srv.tags.clone(),
321                        server: Some(srv.name.clone()),
322                    },
323                );
324            }
325        }
326        // 4. Overrides.
327        for (name, ov) in &settings.tools.overrides {
328            match reg.apply_override(name, ov, servers) {
329                Ok(()) => {}
330                Err(e) => errors.push(e),
331            }
332        }
333        // 5. Disabled.
334        for name in &settings.tools.disabled {
335            if settings.tools.overrides.contains_key(name) {
336                errors.push(format!(
337                    "tools.disabled and tools.overrides both name {name:?}"
338                ));
339                continue;
340            }
341            match reg.tools.get_mut(name) {
342                Some(t) => t.disabled = true,
343                None => errors.push(format!("tools.disabled names an unknown tool {name:?}")),
344            }
345        }
346        if errors.is_empty() {
347            Ok(reg)
348        } else {
349            Err(errors)
350        }
351    }
352
353    fn apply_override(
354        &mut self,
355        name: &str,
356        ov: &ToolOverride,
357        servers: &[ServerTools],
358    ) -> Result<(), String> {
359        let spec = self
360            .tools
361            .get(name)
362            .ok_or_else(|| format!("tools.overrides.{name}: unknown internal tool"))?;
363        if spec.class != ToolClass::Internal {
364            return Err(format!(
365                "tools.overrides.{name}: only internal tools can be overridden ({name} is {:?})",
366                spec.class
367            ));
368        }
369        let srv = servers
370            .iter()
371            .find(|s| s.name == ov.server)
372            .ok_or_else(|| {
373                format!(
374                    "tools.overrides.{name}: server {:?} is not a connected MCP server",
375                    ov.server
376                )
377            })?;
378        if !srv.tools.iter().any(|t| t.name == ov.tool) {
379            return Err(format!(
380                "tools.overrides.{name}: server {:?} does not advertise tool {:?}",
381                ov.server, ov.tool
382            ));
383        }
384        // The mapping must compile: render against sample vars.
385        let mut vars = Vars::new();
386        vars.insert("args".into(), json!({}));
387        vars.insert("ctx".into(), json!({"instance": "x"}));
388        if let Some(a) = &ov.args
389            && !a.trim_start().starts_with("CEL:")
390        {
391            // A JSON template with unknown placeholders would fail on `args.<field>`
392            // lookups against an empty args object; only check syntax here by
393            // rendering with a permissive args object built from the schema.
394            let sample = sample_args(&spec.input_schema);
395            vars.insert("args".into(), sample);
396            mapping::render_json(a, &vars)
397                .map_err(|e| format!("tools.overrides.{name}.args: {e}"))?;
398        }
399        if let Some(a) = &ov.args
400            && a.trim_start().starts_with("CEL:")
401        {
402            crate::cel::compile_check(a.trim_start().trim_start_matches("CEL:").trim())
403                .map_err(|e| format!("tools.overrides.{name}.args: {e}"))?;
404        }
405        if let Some(r) = &ov.result
406            && r.trim_start().starts_with("CEL:")
407        {
408            crate::cel::compile_check(r.trim_start().trim_start_matches("CEL:").trim())
409                .map_err(|e| format!("tools.overrides.{name}.result: {e}"))?;
410        }
411        let tags = srv.tags.clone();
412        let server = ov.server.clone();
413        let spec = self.tools.get_mut(name).expect("checked");
414        spec.imp = Impl::Mapped(Mapping {
415            server: ov.server.clone(),
416            tool: ov.tool.clone(),
417            args: ov.args.clone(),
418            result: ov.result.clone(),
419        });
420        spec.tags = tags;
421        spec.server = Some(server);
422        Ok(())
423    }
424
425    pub fn get(&self, name: &str) -> Option<&ToolSpec> {
426        self.tools.get(name)
427    }
428    pub fn names(&self) -> Vec<String> {
429        self.tools.keys().cloned().collect()
430    }
431    pub fn servers(&self) -> &[String] {
432        &self.servers
433    }
434    pub fn len(&self) -> usize {
435        self.tools.len()
436    }
437    pub fn is_empty(&self) -> bool {
438        self.tools.is_empty()
439    }
440    pub fn iter(&self) -> impl Iterator<Item = &ToolSpec> {
441        self.tools.values()
442    }
443
444    /// Whether `caller` may call `name` (RFC 0028 §3 grants + RFC 0029 §2).
445    pub fn allowed(&self, caller: &Caller, name: &str) -> bool {
446        let Some(t) = self.tools.get(name) else {
447            return false;
448        };
449        if !t.is_available() {
450            return false;
451        }
452        match caller {
453            Caller::Root => t.grant.root || t.class != ToolClass::Internal,
454            Caller::Workflow => t.grant.workflows || t.class != ToolClass::Internal,
455            Caller::Subagent { allow } => match allow {
456                Some(list) => list.iter().any(|p| pattern_matches(p, name)),
457                None => t.grant.subagents || t.class != ToolClass::Internal,
458            },
459            Caller::Principal { role, grants } => match role {
460                Role::Operator => true,
461                Role::Anonymous => false,
462                r => {
463                    name == "status"
464                        || t.grant.roles.contains(r)
465                        || grants.iter().any(|p| pattern_matches(p, name))
466                }
467            },
468        }
469    }
470
471    /// The LLM-facing definitions for a caller, filtered by the agent's tool
472    /// selection (`agent.tools.internal|mcp|code`) when given.
473    pub fn defs_for(
474        &self,
475        caller: &Caller,
476        select: Option<&crate::config::v2::AgentTools>,
477    ) -> Vec<ToolDef> {
478        self.tools
479            .values()
480            .filter(|t| self.allowed(caller, &t.name))
481            .filter(|t| match select {
482                None => true,
483                Some(sel) => match t.class {
484                    ToolClass::Internal => {
485                        sel.internal.allows(&t.name) || sel.internal.allows(&t.family)
486                    }
487                    ToolClass::Code => sel.code.allows(&t.name),
488                    ToolClass::Mcp => {
489                        sel.mcp.allows(&t.name)
490                            || t.server.as_deref().is_some_and(|s| sel.mcp.allows(s))
491                    }
492                },
493            })
494            .map(ToolSpec::def)
495            .collect()
496    }
497
498    /// Validate call arguments against the tool's input schema.
499    pub fn validate_args(&self, name: &str, args: &Value) -> Result<(), String> {
500        let t = self
501            .tools
502            .get(name)
503            .ok_or_else(|| format!("no such tool {name:?}"))?;
504        jsonschema::validate(&t.input_schema, args)
505            .map_err(|e| format!("invalid arguments for {name}: {}", jsonschema::explain(&e)))
506    }
507
508    /// Validate a result against the tool's output schema (when it has one).
509    pub fn validate_result(&self, name: &str, result: &Value) -> Result<(), String> {
510        let t = self
511            .tools
512            .get(name)
513            .ok_or_else(|| format!("no such tool {name:?}"))?;
514        match &t.output_schema {
515            None => Ok(()),
516            Some(schema) => jsonschema::validate(schema, result).map_err(|e| {
517                format!(
518                    "result of {name} does not match its output schema: {}",
519                    jsonschema::explain(&e)
520                )
521            }),
522        }
523    }
524
525    /// Where a call goes (`None` = unknown or unavailable).
526    pub fn route(&self, name: &str) -> Option<Route<'_>> {
527        let t = self.tools.get(name)?;
528        if !t.is_available() {
529            return None;
530        }
531        Some(match &t.imp {
532            Impl::BuiltIn => Route::Internal,
533            Impl::MappingOnly => return None,
534            Impl::Mapped(m) => Route::Mapped(m),
535            Impl::Code => Route::Code,
536            Impl::Mcp { server, tool } => Route::Mcp { server, tool },
537        })
538    }
539
540    /// Render a mapped tool's MCP arguments from the internal call's `args`
541    /// and the call context (`{instance, run?, ctx?, principal?}`). Without an
542    /// `args` template the internal args pass through unchanged.
543    pub fn map_args(m: &Mapping, args: &Value, ctx: &Value) -> Result<Value, String> {
544        match &m.args {
545            None => Ok(args.clone()),
546            Some(t) => {
547                let mut vars = Vars::new();
548                vars.insert("args".into(), args.clone());
549                vars.insert("ctx".into(), ctx.clone());
550                mapping::render_json(t, &vars).map_err(|e| format!("override args mapping: {e}"))
551            }
552        }
553    }
554
555    /// Map an MCP `CallToolResult` (as the `{"result": …}` context the store
556    /// adapter also uses) back to the internal output. Without a `result`
557    /// template: `structuredContent`, else the text parsed as JSON, else the text.
558    pub fn map_result(m: &Mapping, result_ctx: &Value) -> Result<Value, String> {
559        match &m.result {
560            None => {
561                let sc = &result_ctx["result"]["structuredContent"];
562                if !sc.is_null() {
563                    return Ok(sc.clone());
564                }
565                let text = result_ctx["result"]["text"].as_str().unwrap_or("");
566                Ok(serde_json::from_str::<Value>(text)
567                    .unwrap_or_else(|_| Value::String(text.to_string())))
568            }
569            Some(t) => {
570                let t = t.trim();
571                if t.starts_with("CEL:") {
572                    return mapping::extract(t, result_ctx)
573                        .map_err(|e| format!("override result mapping: {e}"))?
574                        .ok_or_else(|| "override result mapping produced nothing".into());
575                }
576                // A JSON template with `{{result.…}}`/`{result.…}` placeholders,
577                // or a bare path.
578                if t.starts_with('{') && !t.starts_with("{{") && !t.starts_with("{result") {
579                    let vars: Vars = match result_ctx {
580                        Value::Object(o) => mapping::vars_from(o),
581                        _ => Vars::new(),
582                    };
583                    return mapping::render_json(t, &vars)
584                        .map_err(|e| format!("override result mapping: {e}"));
585                }
586                if t.starts_with("{{") {
587                    let vars: Vars = match result_ctx {
588                        Value::Object(o) => mapping::vars_from(o),
589                        _ => Vars::new(),
590                    };
591                    return mapping::render_json(t, &vars)
592                        .map_err(|e| format!("override result mapping: {e}"));
593                }
594                mapping::extract(t, result_ctx)
595                    .map_err(|e| format!("override result mapping: {e}"))?
596                    .ok_or_else(|| {
597                        format!("override result mapping: path {t:?} not found in the result")
598                    })
599            }
600        }
601    }
602
603    /// The trifecta tags a set of tool names carries (for the gate).
604    pub fn tags_of(&self, names: &[String]) -> Vec<TrifectaTag> {
605        let mut out: Vec<TrifectaTag> = Vec::new();
606        for t in names.iter().filter_map(|n| self.tools.get(n)) {
607            for tag in &t.tags {
608                if !out.contains(tag) {
609                    out.push(*tag);
610                }
611            }
612        }
613        out
614    }
615
616    /// A status view (`agent://tools`).
617    pub fn status(&self) -> Value {
618        json!({
619            "count": self.tools.len(),
620            "servers": self.servers,
621            "warnings": self.warnings,
622            "tools": self.tools.values().map(|t| json!({
623                "name": t.name, "class": t.class, "impl": t.imp, "disabled": t.disabled,
624                "available": t.is_available(), "server": t.server, "family": t.family,
625            })).collect::<Vec<_>>(),
626        })
627    }
628}
629
630/// `memory.*` / `workflow.run` / `*` style pattern match.
631pub fn pattern_matches(pattern: &str, name: &str) -> bool {
632    let p = pattern.trim();
633    if p == "*" || p == name {
634        return true;
635    }
636    if let Some(prefix) = p.strip_suffix('*') {
637        return name.starts_with(prefix);
638    }
639    false
640}
641
642/// A permissive sample of `schema`'s properties (strings/empty values) so an
643/// args template's `{{args.x}}` placeholders resolve at compile-check time.
644fn sample_args(schema: &Value) -> Value {
645    let mut m = serde_json::Map::new();
646    if let Some(props) = schema.get("properties").and_then(Value::as_object) {
647        for (k, p) in props {
648            let v = match p.get("type").and_then(Value::as_str) {
649                Some("integer") | Some("number") => json!(0),
650                Some("boolean") => json!(false),
651                Some("array") => json!([]),
652                Some("object") => json!({}),
653                _ => json!(""),
654            };
655            m.insert(k.clone(), v);
656        }
657    }
658    Value::Object(m)
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664    use ::mcp::wire::Tool;
665
666    fn tool(name: &str) -> Tool {
667        Tool {
668            name: name.into(),
669            title: None,
670            description: Some(format!("{name} tool")),
671            input_schema: json!({"type": "object"}),
672            output_schema: None,
673        }
674    }
675
676    fn settings(doc: Value) -> Settings {
677        Settings::from_document(doc, "test").unwrap()
678    }
679
680    #[test]
681    fn exec_is_default_off_mapping_only_and_tagged() {
682        // Default (no `security.exec`): `exec` is a mapping-only contract — not
683        // available unless mapped off-box — and always carries the sensitive +
684        // egress trifecta tags (so the Rule-of-Two gate constrains it).
685        let reg = Registry::build(&settings(json!({})), &[]).unwrap();
686        let exec = reg.get("exec").expect("exec contract exists");
687        assert!(
688            !exec.is_available(),
689            "exec is off by default (mapping-only)"
690        );
691        assert!(
692            exec.tags.contains(&TrifectaTag::Sensitive) && exec.tags.contains(&TrifectaTag::Egress),
693            "exec is tagged sensitive+egress: {:?}",
694            exec.tags
695        );
696
697        // `security.exec.enabled` flips it to a LOCAL built-in — but only in a
698        // binary built with the `exec` feature (off-by-default at BOTH layers).
699        let on = settings(json!({"security": {"exec": {"enabled": true, "allow": ["echo"]}}}));
700        let reg = Registry::build(&on, &[]).unwrap();
701        assert_eq!(
702            reg.get("exec").unwrap().is_available(),
703            cfg!(feature = "exec"),
704            "enabled → available iff built with --features exec",
705        );
706    }
707
708    #[test]
709    fn precedence_namespaces_and_collisions() {
710        let servers = vec![
711            ServerTools {
712                name: "fs".into(),
713                ns: Some("fs".into()),
714                tags: vec![TrifectaTag::Sensitive],
715                tools: vec![tool("read"), tool("write")],
716            },
717            ServerTools {
718                name: "misc".into(),
719                ns: None,
720                tags: vec![],
721                tools: vec![tool("echo"), tool("memory.get"), tool("read")],
722            },
723            ServerTools {
724                name: "misc2".into(),
725                ns: None,
726                tags: vec![],
727                tools: vec![tool("echo")],
728            },
729        ];
730        let reg =
731            Registry::build(&settings(json!({"agent": {"instruction": "x"}})), &servers).unwrap();
732        assert!(reg.get("fs.read").is_some(), "namespaced");
733        assert!(
734            reg.get("read").is_some(),
735            "bare name of a server without ns"
736        );
737        assert_eq!(reg.get("echo").unwrap().server.as_deref(), Some("misc"));
738        assert!(
739            reg.get("misc2.echo").is_some(),
740            "second server's colliding tool is server-qualified"
741        );
742        assert_eq!(
743            reg.get("memory.get").unwrap().class,
744            ToolClass::Internal,
745            "internal wins over MCP"
746        );
747        assert!(
748            reg.get("misc.memory.get").is_some(),
749            "the MCP one is reachable qualified"
750        );
751        assert_eq!(
752            reg.get("fs.read").unwrap().tags,
753            vec![TrifectaTag::Sensitive]
754        );
755        assert!(matches!(
756            reg.route("fs.read"),
757            Some(Route::Mcp {
758                server: "fs",
759                tool: "read"
760            })
761        ));
762        assert!(matches!(reg.route("memory.get"), Some(Route::Internal)));
763        assert!(
764            reg.route("code.run").is_none(),
765            "mapping-only without a mapping is unavailable"
766        );
767        assert!(reg.warnings.iter().any(|w| w.contains("collides")));
768    }
769
770    #[test]
771    fn overrides_disabled_and_profiles() {
772        let servers = vec![
773            ServerTools {
774                name: "mem".into(),
775                ns: None,
776                tags: vec![],
777                tools: vec![tool("search")],
778            },
779            ServerTools {
780                name: "kb".into(),
781                ns: None,
782                tags: vec![TrifectaTag::UntrustedInput],
783                tools: vec![tool("knowledge.search"), tool("knowledge.get")],
784            },
785            ServerTools {
786                name: "sandbox".into(),
787                ns: None,
788                tags: vec![],
789                tools: vec![tool("execute")],
790            },
791        ];
792        let s = settings(json!({
793            "agent": {"instruction": "x"},
794            "knowledge": {"server": "kb"},
795            "tools": {
796                "disabled": ["workflow.delete"],
797                "overrides": {
798                    "memory.get": {"server": "mem", "tool": "search", "args": "{\"query\": \"{{args.key}}\", \"limit\": 1}", "result": "{\"found\": true, \"value\": {{result.structuredContent.results.0.text}}}"},
799                    "code.run": {"server": "sandbox", "tool": "execute", "args": "{\"lang\": \"{{args.language}}\", \"code\": \"{{args.code}}\"}"}
800                }
801            }
802        }));
803        let reg = Registry::build(&s, &servers).unwrap();
804        // The override kept the contract, swapped the implementation.
805        let mg = reg.get("memory.get").unwrap();
806        assert_eq!(mg.class, ToolClass::Internal);
807        let Some(Route::Mapped(m)) = reg.route("memory.get") else {
808            panic!("mapped")
809        };
810        assert_eq!(m.tool, "search");
811        let args =
812            Registry::map_args(m, &json!({"key": "user/name"}), &json!({"instance": "i"})).unwrap();
813        assert_eq!(args, json!({"query": "user/name", "limit": 1}));
814        let out = Registry::map_result(m, &json!({"result": {"structuredContent": {"results": [{"text": "andrii"}]}, "isError": false, "text": ""}})).unwrap();
815        assert_eq!(out, json!({"found": true, "value": "andrii"}));
816        assert!(reg.validate_result("memory.get", &out).is_ok());
817        // Mapping-only code.run is now available; knowledge.* got the profile server.
818        assert!(matches!(reg.route("code.run"), Some(Route::Mapped(_))));
819        let Some(Route::Mapped(k)) = reg.route("knowledge.search") else {
820            panic!("profile mapped")
821        };
822        assert_eq!(k.server, "kb");
823        assert_eq!(
824            reg.get("knowledge.search").unwrap().tags,
825            vec![TrifectaTag::UntrustedInput]
826        );
827        assert!(
828            reg.route("knowledge.list").is_none(),
829            "not advertised ⇒ still unavailable"
830        );
831        // Default result mapping prefers structuredContent, then text-JSON, then text.
832        let plain = Mapping {
833            server: "s".into(),
834            tool: "t".into(),
835            args: None,
836            result: None,
837        };
838        assert_eq!(
839            Registry::map_result(
840                &plain,
841                &json!({"result": {"structuredContent": {"a": 1}, "text": "x"}})
842            )
843            .unwrap(),
844            json!({"a": 1})
845        );
846        assert_eq!(
847            Registry::map_result(
848                &plain,
849                &json!({"result": {"structuredContent": null, "text": "{\"b\": 2}"}})
850            )
851            .unwrap(),
852            json!({"b": 2})
853        );
854        assert_eq!(
855            Registry::map_result(
856                &plain,
857                &json!({"result": {"structuredContent": null, "text": "hello"}})
858            )
859            .unwrap(),
860            json!("hello")
861        );
862        // Disabled.
863        assert!(reg.get("workflow.delete").unwrap().disabled);
864        assert!(reg.route("workflow.delete").is_none());
865        assert!(!reg.allowed(&Caller::Root, "workflow.delete"));
866        // Errors.
867        let bad = settings(json!({"agent": {"instruction": "x"}, "tools": {
868            "disabled": ["nope", "memory.get"],
869            "overrides": {"memory.get": {"server": "mem", "tool": "search"}, "fs.read": {"server": "mem", "tool": "search"}, "memory.set": {"server": "ghost", "tool": "x"}, "memory.list": {"server": "mem", "tool": "missing"}}
870        }}));
871        let errs = Registry::build(&bad, &servers).unwrap_err();
872        let joined = errs.join("\n");
873        assert!(joined.contains("unknown tool \"nope\""), "{joined}");
874        assert!(joined.contains("both name \"memory.get\""), "{joined}");
875        assert!(
876            joined.contains("fs.read: unknown internal tool"),
877            "{joined}"
878        );
879        assert!(joined.contains("\"ghost\" is not a connected"), "{joined}");
880        assert!(
881            joined.contains("does not advertise tool \"missing\""),
882            "{joined}"
883        );
884    }
885
886    #[test]
887    fn grants_and_definitions_per_caller() {
888        let servers = vec![ServerTools {
889            name: "fs".into(),
890            ns: None,
891            tags: vec![],
892            tools: vec![tool("read")],
893        }];
894        let s = settings(
895            json!({"agent": {"instruction": "x", "tools": {"internal": ["memory", "plan.get", "finish"], "mcp": "all"}}}),
896        );
897        let reg = Registry::build(&s, &servers).unwrap();
898        assert!(reg.allowed(&Caller::Root, "subagent.run"));
899        assert!(!reg.allowed(&Caller::Workflow, "finish"));
900        assert!(reg.allowed(&Caller::Workflow, "memory.set"));
901        assert!(reg.allowed(&Caller::Subagent { allow: None }, "memory.get"));
902        assert!(!reg.allowed(&Caller::Subagent { allow: None }, "subagent.run"));
903        assert!(reg.allowed(
904            &Caller::Subagent {
905                allow: Some(&["memory.*".to_string()])
906            },
907            "memory.list"
908        ));
909        assert!(!reg.allowed(
910            &Caller::Subagent {
911                allow: Some(&["memory.*".to_string()])
912            },
913            "read"
914        ));
915        assert!(reg.allowed(
916            &Caller::Principal {
917                role: Role::User,
918                grants: &[]
919            },
920            "status"
921        ));
922        assert!(!reg.allowed(
923            &Caller::Principal {
924                role: Role::User,
925                grants: &[]
926            },
927            "workflow.run"
928        ));
929        assert!(reg.allowed(
930            &Caller::Principal {
931                role: Role::User,
932                grants: &["workflow.*".to_string()]
933            },
934            "workflow.run"
935        ));
936        assert!(reg.allowed(
937            &Caller::Principal {
938                role: Role::Operator,
939                grants: &[]
940            },
941            "workflow.delete"
942        ));
943        assert!(!reg.allowed(
944            &Caller::Principal {
945                role: Role::Anonymous,
946                grants: &["*".to_string()]
947            },
948            "status"
949        ));
950        // Root definitions honour agent.tools selection: family + explicit names + all MCP.
951        let defs = reg.defs_for(&Caller::Root, Some(&s.agent.tools));
952        let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
953        assert!(names.contains(&"memory.get") && names.contains(&"memory.set"));
954        assert!(names.contains(&"plan.get") && !names.contains(&"plan.update"));
955        assert!(names.contains(&"finish"));
956        assert!(names.contains(&"read"));
957        assert!(!names.contains(&"subagent.run"));
958        // Argument validation.
959        assert!(
960            reg.validate_args("memory.get", &json!({"key": "k"}))
961                .is_ok()
962        );
963        let e = reg
964            .validate_args("memory.get", &json!({"ke": "k"}))
965            .unwrap_err();
966        assert!(
967            e.contains("missing required property \"key\"")
968                && e.contains("unknown property \"ke\""),
969            "{e}"
970        );
971        assert!(reg.validate_args("nope", &json!({})).is_err());
972    }
973}