Skip to main content

agentd/registry/
mod.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The **tool registry**: 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    /// A workflow registered as a tool. Invocation is a run, so the caller
35    /// gets retry, breaker, idempotency, a human gate and restart-survival
36    /// inside what looks to a model like one call — none of which an MCP tool
37    /// can express.
38    Workflow,
39}
40
41/// An override mapping: which server's tool stands in for an internal
42/// contract, plus the optional argument and result transforms that reconcile
43/// the two shapes. Both transforms are compiled at startup, so a mapping that
44/// cannot be applied is a config error rather than a call-time surprise.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct Mapping {
47    pub server: String,
48    pub tool: String,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub args: Option<String>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub result: Option<String>,
53}
54
55/// How a tool is implemented.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(tag = "impl", rename_all = "snake_case")]
58pub enum Impl {
59    /// A built-in executed by the runtime.
60    BuiltIn,
61    /// A contract with no implementation until an override maps it.
62    MappingOnly,
63    /// An internal contract implemented by a mapped MCP tool.
64    Mapped(Mapping),
65    /// A tool registered from Rust by an embedding application.
66    Code,
67    /// An MCP server tool.
68    Mcp { server: String, tool: String },
69    /// A workflow, started by name.
70    Workflow { workflow: String, sync: bool },
71}
72
73/// Who may call a tool. These flags gate the INTERNAL tools — the ones that
74/// reach agentd's own state — so a caller they do not name is refused. Code
75/// and MCP tools are not gated by them: an operator who wired a server in has
76/// already made that decision, and a subagent narrowed by an explicit `allow`
77/// list is held to that list instead.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct Grant {
80    pub root: bool,
81    pub workflows: bool,
82    pub subagents: bool,
83    /// A2A roles granted by default (`user`, `agent`; operator always).
84    pub roles: Vec<Role>,
85}
86
87impl Grant {
88    fn all() -> Grant {
89        Grant {
90            root: true,
91            workflows: true,
92            subagents: true,
93            roles: Vec::new(),
94        }
95    }
96}
97
98/// One registered tool.
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct ToolSpec {
101    pub name: String,
102    pub description: String,
103    pub input_schema: Value,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub output_schema: Option<Value>,
106    pub class: ToolClass,
107    #[serde(rename = "implementation")]
108    pub imp: Impl,
109    pub grant: Grant,
110    #[serde(default)]
111    pub disabled: bool,
112    #[serde(default)]
113    pub family: String,
114    /// Trifecta tags inherited from the serving MCP server (mapped/MCP tools).
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub tags: Vec<TrifectaTag>,
117    /// The server that serves this tool (MCP / mapped), for `_meta` + status.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub server: Option<String>,
120}
121
122impl ToolSpec {
123    /// Callable at all (not disabled, has an implementation).
124    pub fn is_available(&self) -> bool {
125        !self.disabled && !matches!(self.imp, Impl::MappingOnly)
126    }
127    pub fn def(&self) -> ToolDef {
128        ToolDef {
129            name: self.name.clone(),
130            description: self.description.clone(),
131            input_schema: self.input_schema.clone(),
132        }
133    }
134}
135
136/// A connected MCP server's advertised tools (input to [`Registry::build`]).
137#[derive(Debug, Clone, Default)]
138pub struct ServerTools {
139    pub name: String,
140    pub ns: Option<String>,
141    pub tags: Vec<TrifectaTag>,
142    pub tools: Vec<::mcp::wire::Tool>,
143}
144
145/// Where a call goes.
146#[derive(Debug, Clone, PartialEq)]
147pub enum Route<'a> {
148    /// A built-in: the runtime executes it.
149    Internal,
150    /// A mapped internal contract: call `mapping.tool` on `mapping.server`.
151    Mapped(&'a Mapping),
152    /// A code-registered tool.
153    Code,
154    /// An MCP tool.
155    Mcp { server: &'a str, tool: &'a str },
156    /// A workflow run.
157    Workflow { workflow: &'a str, sync: bool },
158}
159
160/// The caller asking for definitions / permission.
161#[derive(Debug, Clone, PartialEq)]
162pub enum Caller<'a> {
163    Root,
164    Workflow,
165    /// A subagent, optionally restricted to an explicit allow-list (`subagent.run.tools`).
166    Subagent {
167        allow: Option<&'a [String]>,
168    },
169    /// An A2A principal with a role and its explicit grants (patterns).
170    ///
171    /// Used to compute what a principal may SEE (`defs_for`). Inbound A2A
172    /// calls are authorized by the role matrix in `a2a::principals` rather
173    /// than through this arm, because a principal's reach is decided at the
174    /// protocol boundary before any tool is chosen; narrowing per principal
175    /// WITHIN one daemon is `security.policies`, whose `match: {principal}`
176    /// judges the call itself.
177    Principal {
178        role: Role,
179        grants: &'a [String],
180    },
181}
182
183/// The registry.
184#[derive(Debug, Clone, Default)]
185pub struct Registry {
186    tools: BTreeMap<String, ToolSpec>,
187    servers: Vec<String>,
188    /// Non-fatal findings from the build (collisions, missing profile tools).
189    pub warnings: Vec<String>,
190}
191
192impl Registry {
193    /// Build from settings and the connected servers' tool lists.
194    ///
195    /// Every override is resolved here, at startup, so a misconfiguration
196    /// fails to boot rather than failing on the call that needed the tool.
197    /// The errors returned are: an override naming an unknown internal tool,
198    /// a server that is not declared, a tool the server does not advertise, a
199    /// mapping expression that does not compile, a tool that is both disabled
200    /// and overridden, and disabling a tool that does not exist.
201    pub fn build(settings: &Settings, servers: &[ServerTools]) -> Result<Registry, Vec<String>> {
202        let mut reg = Registry::default();
203        let mut errors = Vec::new();
204        // 1. Internal contracts.
205        for c in internal::contracts() {
206            // `exec` runs LOCAL commands, so it is off at two independent
207            // layers: the binary must be built `--features exec` AND
208            // `security.exec.enabled` must be set. Failing either, it is
209            // mapping-only, so execution can still be delegated off-box
210            // through `tools.overrides` without agentd ever running code
211            // itself. It is always
212            // tagged `sensitive` + `egress` so the Rule-of-Two gate refuses to
213            // combine it with untrusted input.
214            let (imp, tags) = if c.name == "exec" {
215                let local = cfg!(feature = "exec") && settings.security.exec.enabled;
216                (
217                    if local {
218                        Impl::BuiltIn
219                    } else {
220                        Impl::MappingOnly
221                    },
222                    vec![TrifectaTag::Sensitive, TrifectaTag::Egress],
223                )
224            } else if c.builtin {
225                (Impl::BuiltIn, Vec::new())
226            } else {
227                (Impl::MappingOnly, Vec::new())
228            };
229            reg.tools.insert(
230                c.name.to_string(),
231                ToolSpec {
232                    name: c.name.to_string(),
233                    description: c.description.to_string(),
234                    input_schema: c.input,
235                    output_schema: Some(c.output),
236                    class: ToolClass::Internal,
237                    imp,
238                    grant: Grant {
239                        root: c.grant.root,
240                        workflows: c.grant.workflows,
241                        subagents: c.grant.subagents,
242                        roles: [(c.grant.user, Role::User), (c.grant.agent, Role::Agent)]
243                            .into_iter()
244                            .filter(|(on, _)| *on)
245                            .map(|(_, r)| r)
246                            .collect(),
247                    },
248                    disabled: false,
249                    family: c.family.to_string(),
250                    tags,
251                    server: None,
252                },
253            );
254        }
255        // 2. Code-registered tools (never shadow internal names — registration
256        //    already refuses them, defensively skip anyway).
257        for d in crate::tools::defs() {
258            if reg.tools.contains_key(&d.name) {
259                reg.warnings.push(format!(
260                    "code tool {:?} collides with an internal tool and is ignored",
261                    d.name
262                ));
263                continue;
264            }
265            reg.tools.insert(
266                d.name.clone(),
267                ToolSpec {
268                    name: d.name.clone(),
269                    description: d.description.clone(),
270                    input_schema: d.input_schema.clone(),
271                    output_schema: None,
272                    class: ToolClass::Code,
273                    imp: Impl::Code,
274                    grant: Grant::all(),
275                    disabled: false,
276                    family: "code".into(),
277                    tags: Vec::new(),
278                    server: Some("code".into()),
279                },
280            );
281        }
282        // 3. MCP tools: `<ns>.<tool>` when the server declares `ns`; else the
283        //    bare name unless it collides, then `<server>.<tool>`. A profile
284        //    tool (`knowledge.*`, `search.*`, `code.run`) advertised by the
285        //    configured profile server BECOMES that contract's implementation.
286        let profile_servers: BTreeMap<&str, &str> = [
287            ("knowledge", settings.knowledge.server.as_deref()),
288            ("search", settings.search.server.as_deref()),
289        ]
290        .into_iter()
291        .filter_map(|(k, v)| v.map(|v| (k, v)))
292        .collect();
293        for srv in servers {
294            reg.servers.push(srv.name.clone());
295            // Per-server admission control (`mcp.servers[].allow`/`exclude`),
296            // on the ADVERTISED name: a tool the operator excluded never
297            // exists here — not disabled, absent — so nothing downstream
298            // (defs, grants, overrides) can resurrect it.
299            let gate = settings.mcp.servers.iter().find(|s| s.name == srv.name);
300            for t in &srv.tools {
301                if let Some(g) = gate {
302                    let allowed = g
303                        .allow
304                        .as_ref()
305                        .is_none_or(|a| a.iter().any(|p| pattern_matches(p, &t.name)));
306                    let excluded = g.exclude.iter().any(|p| pattern_matches(p, &t.name));
307                    if !allowed || excluded {
308                        continue;
309                    }
310                }
311                let profile_family = t.name.split('.').next().unwrap_or("");
312                let is_profile = reg
313                    .tools
314                    .get(&t.name)
315                    .is_some_and(|s| matches!(s.imp, Impl::MappingOnly))
316                    && profile_servers
317                        .get(profile_family)
318                        .is_some_and(|ps| *ps == srv.name);
319                if is_profile {
320                    let spec = reg.tools.get_mut(&t.name).expect("checked");
321                    spec.imp = Impl::Mapped(Mapping {
322                        server: srv.name.clone(),
323                        tool: t.name.clone(),
324                        args: None,
325                        result: None,
326                    });
327                    spec.tags = srv.tags.clone();
328                    spec.server = Some(srv.name.clone());
329                    continue;
330                }
331                let name = match &srv.ns {
332                    Some(ns) if !ns.is_empty() => format!("{ns}.{}", t.name),
333                    _ => {
334                        if reg.tools.contains_key(&t.name) {
335                            let q = format!("{}.{}", srv.name, t.name);
336                            reg.warnings.push(format!(
337                                "mcp tool {:?} of server {:?} collides; registered as {q:?}",
338                                t.name, srv.name
339                            ));
340                            q
341                        } else {
342                            t.name.clone()
343                        }
344                    }
345                };
346                if reg.tools.contains_key(&name) {
347                    reg.warnings.push(format!("mcp tool {name:?} of server {:?} collides with an existing tool and is ignored", srv.name));
348                    continue;
349                }
350                reg.tools.insert(
351                    name.clone(),
352                    ToolSpec {
353                        name,
354                        description: t.description.clone().unwrap_or_default(),
355                        input_schema: t.input_schema.clone(),
356                        output_schema: t.output_schema.clone(),
357                        class: ToolClass::Mcp,
358                        imp: Impl::Mcp {
359                            server: srv.name.clone(),
360                            tool: t.name.clone(),
361                        },
362                        grant: Grant::all(),
363                        disabled: false,
364                        family: "mcp".into(),
365                        tags: srv.tags.clone(),
366                        server: Some(srv.name.clone()),
367                    },
368                );
369            }
370        }
371        // 4. Overrides.
372        for (name, ov) in &settings.tools.overrides {
373            match reg.apply_override(name, ov, servers) {
374                Ok(()) => {}
375                Err(e) => errors.push(e),
376            }
377        }
378        // 5. Disabled.
379        for name in &settings.tools.disabled {
380            if settings.tools.overrides.contains_key(name) {
381                errors.push(format!(
382                    "tools.disabled and tools.overrides both name {name:?}"
383                ));
384                continue;
385            }
386            match reg.tools.get_mut(name) {
387                Some(t) => t.disabled = true,
388                None => errors.push(format!("tools.disabled names an unknown tool {name:?}")),
389            }
390        }
391        if errors.is_empty() {
392            Ok(reg)
393        } else {
394            Err(errors)
395        }
396    }
397
398    /// Register every workflow carrying a `tool:` block as a first-class
399    /// contract. Called ONCE, after the startup workflow load.
400    ///
401    /// Startup-only is the whole safety argument. The registry is otherwise
402    /// built once from settings plus connected servers and validated
403    /// fail-closed; workflow tools would make it a mutable index if the model
404    /// could add to it, and `workflow.create` is root-callable — a root turn
405    /// could mint itself a new tool name, or shadow one, with no operator in
406    /// the loop. So `workflow.create`/`update` refuse a `tool:` block, and
407    /// this is the only door.
408    ///
409    /// Tags are DERIVED, never declared. A workflow author writing
410    /// `tags: [sensitive, egress]` would make the one static instance-wide
411    /// security gate something the agent-editable half of the config asserts
412    /// about itself; instead a workflow tool inherits the union of the tags of
413    /// the tools its steps actually reach, so the trifecta fold sees the truth
414    /// about what the procedure can do.
415    pub fn register_workflow_tools(
416        &mut self,
417        workflows: &[&crate::engine::Workflow],
418    ) -> Vec<String> {
419        let mut errors = Vec::new();
420        for w in workflows {
421            let Some(t) = &w.tool else { continue };
422            if let Some(existing) = self.tools.get(&t.name) {
423                errors.push(format!(
424                    "workflow {:?}: tool.name {:?} collides with an existing {} tool",
425                    w.name,
426                    t.name,
427                    match existing.class {
428                        ToolClass::Internal => "internal",
429                        ToolClass::Code => "code",
430                        ToolClass::Mcp => "MCP",
431                        ToolClass::Workflow => "workflow",
432                    }
433                ));
434                continue;
435            }
436            let tags = self.derived_tags(w);
437            self.tools.insert(
438                t.name.clone(),
439                ToolSpec {
440                    name: t.name.clone(),
441                    description: w
442                        .description
443                        .clone()
444                        .unwrap_or_else(|| format!("Run the {:?} workflow.", w.name)),
445                    // The workflow's declared inputs ARE the tool's arguments,
446                    // so a model finally sees the shape of what it is starting
447                    // — `workflow.run` could only offer a free-form object
448                    // whose contents the prompt never stated.
449                    input_schema: w
450                        .inputs_schema
451                        .clone()
452                        .unwrap_or_else(|| json!({"type": "object"})),
453                    output_schema: w.outputs_schema.clone(),
454                    class: ToolClass::Workflow,
455                    imp: Impl::Workflow {
456                        workflow: w.name.clone(),
457                        sync: t.mode == crate::engine::model::WorkflowToolMode::Sync,
458                    },
459                    grant: Grant {
460                        root: t.grant.root,
461                        workflows: t.grant.workflows,
462                        subagents: t.grant.subagents,
463                        roles: {
464                            let mut r = Vec::new();
465                            if t.grant.user {
466                                r.push(Role::User);
467                            }
468                            if t.grant.agent {
469                                r.push(Role::Agent);
470                            }
471                            r
472                        },
473                    },
474                    disabled: false,
475                    family: "workflow".into(),
476                    tags,
477                    server: None,
478                },
479            );
480        }
481        errors
482    }
483
484    /// The trifecta tags a workflow's steps actually reach — the union over
485    /// every tool its `mcp.tool` / `tool` steps name, plus the servers those
486    /// steps use.
487    fn derived_tags(&self, w: &crate::engine::Workflow) -> Vec<TrifectaTag> {
488        let mut out: Vec<TrifectaTag> = Vec::new();
489        let mut add = |tags: &[TrifectaTag]| {
490            for t in tags {
491                if !out.contains(t) {
492                    out.push(*t);
493                }
494            }
495        };
496        for step in w.steps.values() {
497            // A named tool: take that tool's tags.
498            if let Some(name) = step.spec.get("tool").and_then(Value::as_str)
499                && let Some(spec) = self.tools.get(name)
500            {
501                add(&spec.tags);
502            }
503            // A named server: take every tag any of its tools carries, since
504            // the step may reach any of them.
505            if let Some(server) = step.spec.get("server").and_then(Value::as_str) {
506                let server_tags: Vec<TrifectaTag> = self
507                    .tools
508                    .values()
509                    .filter(|t| t.server.as_deref() == Some(server))
510                    .flat_map(|t| t.tags.clone())
511                    .collect();
512                add(&server_tags);
513            }
514            // Steps that reach outside by construction.
515            if matches!(step.kind.as_str(), "http" | "a2a.send" | "a2a.delegate") {
516                add(&[TrifectaTag::Egress]);
517            }
518        }
519        out
520    }
521
522    fn apply_override(
523        &mut self,
524        name: &str,
525        ov: &ToolOverride,
526        servers: &[ServerTools],
527    ) -> Result<(), String> {
528        let spec = self
529            .tools
530            .get(name)
531            .ok_or_else(|| format!("tools.overrides.{name}: unknown internal tool"))?;
532        if spec.class != ToolClass::Internal {
533            return Err(format!(
534                "tools.overrides.{name}: only internal tools can be overridden ({name} is {:?})",
535                spec.class
536            ));
537        }
538        let srv = servers
539            .iter()
540            .find(|s| s.name == ov.server)
541            .ok_or_else(|| {
542                format!(
543                    "tools.overrides.{name}: server {:?} is not a connected MCP server",
544                    ov.server
545                )
546            })?;
547        if !srv.tools.iter().any(|t| t.name == ov.tool) {
548            return Err(format!(
549                "tools.overrides.{name}: server {:?} does not advertise tool {:?}",
550                ov.server, ov.tool
551            ));
552        }
553        // The mapping must compile: render against sample vars.
554        let mut vars = Vars::new();
555        vars.insert("args".into(), json!({}));
556        vars.insert("ctx".into(), json!({"instance": "x"}));
557        if let Some(a) = &ov.args
558            && !a.trim_start().starts_with("CEL:")
559        {
560            // A JSON template with unknown placeholders would fail on `args.<field>`
561            // lookups against an empty args object; only check syntax here by
562            // rendering with a permissive args object built from the schema.
563            let sample = sample_args(&spec.input_schema);
564            vars.insert("args".into(), sample);
565            mapping::render_json(a, &vars)
566                .map_err(|e| format!("tools.overrides.{name}.args: {e}"))?;
567        }
568        if let Some(a) = &ov.args
569            && a.trim_start().starts_with("CEL:")
570        {
571            crate::cel::compile_check(a.trim_start().trim_start_matches("CEL:").trim())
572                .map_err(|e| format!("tools.overrides.{name}.args: {e}"))?;
573        }
574        if let Some(r) = &ov.result
575            && r.trim_start().starts_with("CEL:")
576        {
577            crate::cel::compile_check(r.trim_start().trim_start_matches("CEL:").trim())
578                .map_err(|e| format!("tools.overrides.{name}.result: {e}"))?;
579        }
580        let tags = srv.tags.clone();
581        let server = ov.server.clone();
582        let spec = self.tools.get_mut(name).expect("checked");
583        spec.imp = Impl::Mapped(Mapping {
584            server: ov.server.clone(),
585            tool: ov.tool.clone(),
586            args: ov.args.clone(),
587            result: ov.result.clone(),
588        });
589        spec.tags = tags;
590        spec.server = Some(server);
591        Ok(())
592    }
593
594    pub fn get(&self, name: &str) -> Option<&ToolSpec> {
595        self.tools.get(name)
596    }
597    pub fn names(&self) -> Vec<String> {
598        self.tools.keys().cloned().collect()
599    }
600    pub fn servers(&self) -> &[String] {
601        &self.servers
602    }
603    pub fn len(&self) -> usize {
604        self.tools.len()
605    }
606    pub fn is_empty(&self) -> bool {
607        self.tools.is_empty()
608    }
609    pub fn iter(&self) -> impl Iterator<Item = &ToolSpec> {
610        self.tools.values()
611    }
612
613    /// Whether `caller` may call `name`.
614    ///
615    /// Fails closed at every step: an unknown tool, or one that is disabled or
616    /// still unmapped, is refused before any grant is consulted; an anonymous
617    /// A2A principal is refused outright; and a subagent carrying an explicit
618    /// `allow` list is held to it alone, so narrowing a child can only ever
619    /// remove reach, never restore it through a default.
620    pub fn allowed(&self, caller: &Caller, name: &str) -> bool {
621        let Some(t) = self.tools.get(name) else {
622            return false;
623        };
624        if !t.is_available() {
625            return false;
626        }
627        match caller {
628            Caller::Root => t.grant.root || t.class != ToolClass::Internal,
629            Caller::Workflow => t.grant.workflows || t.class != ToolClass::Internal,
630            Caller::Subagent { allow } => match allow {
631                Some(list) => list.iter().any(|p| pattern_matches(p, name)),
632                None => t.grant.subagents || t.class != ToolClass::Internal,
633            },
634            Caller::Principal { role, grants } => match role {
635                Role::Operator => true,
636                Role::Anonymous => false,
637                r => {
638                    name == "status"
639                        || t.grant.roles.contains(r)
640                        || grants.iter().any(|p| pattern_matches(p, name))
641                }
642            },
643        }
644    }
645
646    /// The LLM-facing definitions for a caller, filtered by the agent's tool
647    /// selection (`agent.tools.internal|mcp|code`) when given.
648    pub fn defs_for(
649        &self,
650        caller: &Caller,
651        select: Option<&crate::config::v2::AgentTools>,
652    ) -> Vec<ToolDef> {
653        self.tools
654            .values()
655            .filter(|t| self.allowed(caller, &t.name))
656            .filter(|t| match select {
657                None => true,
658                Some(sel) => match t.class {
659                    ToolClass::Internal => {
660                        sel.internal.allows(&t.name) || sel.internal.allows(&t.family)
661                    }
662                    ToolClass::Code => sel.code.allows(&t.name),
663                    ToolClass::Mcp => {
664                        sel.mcp.allows(&t.name)
665                            || t.server.as_deref().is_some_and(|s| sel.mcp.allows(s))
666                    }
667                    // A workflow tool is an operator-declared procedure, not a
668                    // surface discovered from a server, so it is not subject
669                    // to the discovery selectors — declaring it IS the
670                    // selection.
671                    ToolClass::Workflow => true,
672                },
673            })
674            .map(ToolSpec::def)
675            .collect()
676    }
677
678    /// Validate call arguments against the tool's input schema.
679    pub fn validate_args(&self, name: &str, args: &Value) -> Result<(), String> {
680        let t = self
681            .tools
682            .get(name)
683            .ok_or_else(|| format!("no such tool {name:?}"))?;
684        jsonschema::validate(&t.input_schema, args)
685            .map_err(|e| format!("invalid arguments for {name}: {}", jsonschema::explain(&e)))
686    }
687
688    /// Validate a result against the tool's output schema (when it has one).
689    pub fn validate_result(&self, name: &str, result: &Value) -> Result<(), String> {
690        let t = self
691            .tools
692            .get(name)
693            .ok_or_else(|| format!("no such tool {name:?}"))?;
694        match &t.output_schema {
695            None => Ok(()),
696            Some(schema) => jsonschema::validate(schema, result).map_err(|e| {
697                format!(
698                    "result of {name} does not match its output schema: {}",
699                    jsonschema::explain(&e)
700                )
701            }),
702        }
703    }
704
705    /// Where a call goes (`None` = unknown or unavailable).
706    pub fn route(&self, name: &str) -> Option<Route<'_>> {
707        let t = self.tools.get(name)?;
708        if !t.is_available() {
709            return None;
710        }
711        Some(match &t.imp {
712            Impl::BuiltIn => Route::Internal,
713            Impl::MappingOnly => return None,
714            Impl::Mapped(m) => Route::Mapped(m),
715            Impl::Code => Route::Code,
716            Impl::Mcp { server, tool } => Route::Mcp { server, tool },
717            Impl::Workflow { workflow, sync } => Route::Workflow {
718                workflow,
719                sync: *sync,
720            },
721        })
722    }
723
724    /// Render a mapped tool's MCP arguments from the internal call's `args`
725    /// and the call context (`{instance, run?, ctx?, principal?}`). Without an
726    /// `args` template the internal args pass through unchanged.
727    pub fn map_args(m: &Mapping, args: &Value, ctx: &Value) -> Result<Value, String> {
728        match &m.args {
729            None => Ok(args.clone()),
730            Some(t) => {
731                let mut vars = Vars::new();
732                vars.insert("args".into(), args.clone());
733                vars.insert("ctx".into(), ctx.clone());
734                mapping::render_json(t, &vars).map_err(|e| format!("override args mapping: {e}"))
735            }
736        }
737    }
738
739    /// Map an MCP `CallToolResult` (as the `{"result": …}` context the store
740    /// adapter also uses) back to the internal output. Without a `result`
741    /// template: `structuredContent`, else the text parsed as JSON, else the text.
742    pub fn map_result(m: &Mapping, result_ctx: &Value) -> Result<Value, String> {
743        match &m.result {
744            None => {
745                let sc = &result_ctx["result"]["structuredContent"];
746                if !sc.is_null() {
747                    return Ok(sc.clone());
748                }
749                let text = result_ctx["result"]["text"].as_str().unwrap_or("");
750                Ok(serde_json::from_str::<Value>(text)
751                    .unwrap_or_else(|_| Value::String(text.to_string())))
752            }
753            Some(t) => {
754                let t = t.trim();
755                if t.starts_with("CEL:") {
756                    return mapping::extract(t, result_ctx)
757                        .map_err(|e| format!("override result mapping: {e}"))?
758                        .ok_or_else(|| "override result mapping produced nothing".into());
759                }
760                // A JSON template with `{{result.…}}`/`{result.…}` placeholders,
761                // or a bare path.
762                if t.starts_with('{') && !t.starts_with("{{") && !t.starts_with("{result") {
763                    let vars: Vars = match result_ctx {
764                        Value::Object(o) => mapping::vars_from(o),
765                        _ => Vars::new(),
766                    };
767                    return mapping::render_json(t, &vars)
768                        .map_err(|e| format!("override result mapping: {e}"));
769                }
770                if t.starts_with("{{") {
771                    let vars: Vars = match result_ctx {
772                        Value::Object(o) => mapping::vars_from(o),
773                        _ => Vars::new(),
774                    };
775                    return mapping::render_json(t, &vars)
776                        .map_err(|e| format!("override result mapping: {e}"));
777                }
778                mapping::extract(t, result_ctx)
779                    .map_err(|e| format!("override result mapping: {e}"))?
780                    .ok_or_else(|| {
781                        format!("override result mapping: path {t:?} not found in the result")
782                    })
783            }
784        }
785    }
786
787    /// The trifecta tags a set of tool names carries (for the gate).
788    pub fn tags_of(&self, names: &[String]) -> Vec<TrifectaTag> {
789        let mut out: Vec<TrifectaTag> = Vec::new();
790        for t in names.iter().filter_map(|n| self.tools.get(n)) {
791            for tag in &t.tags {
792                if !out.contains(tag) {
793                    out.push(*tag);
794                }
795            }
796        }
797        out
798    }
799
800    /// A status view (`agent://tools`).
801    pub fn status(&self) -> Value {
802        json!({
803            "count": self.tools.len(),
804            "servers": self.servers,
805            "warnings": self.warnings,
806            "tools": self.tools.values().map(|t| json!({
807                "name": t.name, "class": t.class, "impl": t.imp, "disabled": t.disabled,
808                "available": t.is_available(), "server": t.server, "family": t.family,
809            })).collect::<Vec<_>>(),
810        })
811    }
812}
813
814/// `memory.*` / `workflow.run` / `*` style pattern match.
815pub fn pattern_matches(pattern: &str, name: &str) -> bool {
816    let p = pattern.trim();
817    if p == "*" || p == name {
818        return true;
819    }
820    if let Some(prefix) = p.strip_suffix('*') {
821        return name.starts_with(prefix);
822    }
823    false
824}
825
826/// A permissive sample of `schema`'s properties (strings/empty values) so an
827/// args template's `{{args.x}}` placeholders resolve at compile-check time.
828fn sample_args(schema: &Value) -> Value {
829    let mut m = serde_json::Map::new();
830    if let Some(props) = schema.get("properties").and_then(Value::as_object) {
831        for (k, p) in props {
832            let v = match p.get("type").and_then(Value::as_str) {
833                Some("integer") | Some("number") => json!(0),
834                Some("boolean") => json!(false),
835                Some("array") => json!([]),
836                Some("object") => json!({}),
837                _ => json!(""),
838            };
839            m.insert(k.clone(), v);
840        }
841    }
842    Value::Object(m)
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848    use ::mcp::wire::Tool;
849
850    fn tool(name: &str) -> Tool {
851        Tool {
852            name: name.into(),
853            title: None,
854            description: Some(format!("{name} tool")),
855            input_schema: json!({"type": "object"}),
856            output_schema: None,
857        }
858    }
859
860    fn settings(doc: Value) -> Settings {
861        Settings::from_document(doc, "test").unwrap()
862    }
863
864    #[test]
865    fn exec_is_default_off_mapping_only_and_tagged() {
866        // Default (no `security.exec`): `exec` is a mapping-only contract — not
867        // available unless mapped off-box — and always carries the sensitive +
868        // egress trifecta tags (so the Rule-of-Two gate constrains it).
869        let reg = Registry::build(&settings(json!({})), &[]).unwrap();
870        let exec = reg.get("exec").expect("exec contract exists");
871        assert!(
872            !exec.is_available(),
873            "exec is off by default (mapping-only)"
874        );
875        assert!(
876            exec.tags.contains(&TrifectaTag::Sensitive) && exec.tags.contains(&TrifectaTag::Egress),
877            "exec is tagged sensitive+egress: {:?}",
878            exec.tags
879        );
880
881        // `security.exec.enabled` flips it to a LOCAL built-in — but only in a
882        // binary built with the `exec` feature (off-by-default at BOTH layers).
883        let on = settings(json!({"security": {"exec": {"enabled": true, "allow": ["echo"]}}}));
884        let reg = Registry::build(&on, &[]).unwrap();
885        assert_eq!(
886            reg.get("exec").unwrap().is_available(),
887            cfg!(feature = "exec"),
888            "enabled → available iff built with --features exec",
889        );
890    }
891
892    #[test]
893    fn per_server_allow_and_exclude_gate_advertised_tools() {
894        let s = settings(json!({"mcp": {"servers": [{
895            "name": "fs", "endpoint": "https://fs.example",
896            "allow": ["read_*", "list"], "exclude": ["read_secrets"]
897        }]}}));
898        let servers = vec![ServerTools {
899            name: "fs".into(),
900            ns: None,
901            tags: vec![],
902            tools: vec![
903                tool("read_file"),
904                tool("read_secrets"),
905                tool("list"),
906                tool("delete_everything"),
907            ],
908        }];
909        let reg = Registry::build(&s, &servers).unwrap();
910        assert!(reg.get("read_file").is_some(), "matches allow");
911        assert!(reg.get("list").is_some(), "exact allow");
912        assert!(
913            reg.get("read_secrets").is_none(),
914            "exclude beats allow — the tool does not exist, not merely disabled"
915        );
916        assert!(reg.get("delete_everything").is_none(), "not in allow");
917    }
918
919    #[test]
920    fn precedence_namespaces_and_collisions() {
921        let servers = vec![
922            ServerTools {
923                name: "fs".into(),
924                ns: Some("fs".into()),
925                tags: vec![TrifectaTag::Sensitive],
926                tools: vec![tool("read"), tool("write")],
927            },
928            ServerTools {
929                name: "misc".into(),
930                ns: None,
931                tags: vec![],
932                tools: vec![tool("echo"), tool("memory.get"), tool("read")],
933            },
934            ServerTools {
935                name: "misc2".into(),
936                ns: None,
937                tags: vec![],
938                tools: vec![tool("echo")],
939            },
940        ];
941        let reg =
942            Registry::build(&settings(json!({"agent": {"instruction": "x"}})), &servers).unwrap();
943        assert!(reg.get("fs.read").is_some(), "namespaced");
944        assert!(
945            reg.get("read").is_some(),
946            "bare name of a server without ns"
947        );
948        assert_eq!(reg.get("echo").unwrap().server.as_deref(), Some("misc"));
949        assert!(
950            reg.get("misc2.echo").is_some(),
951            "second server's colliding tool is server-qualified"
952        );
953        assert_eq!(
954            reg.get("memory.get").unwrap().class,
955            ToolClass::Internal,
956            "internal wins over MCP"
957        );
958        assert!(
959            reg.get("misc.memory.get").is_some(),
960            "the MCP one is reachable qualified"
961        );
962        assert_eq!(
963            reg.get("fs.read").unwrap().tags,
964            vec![TrifectaTag::Sensitive]
965        );
966        assert!(matches!(
967            reg.route("fs.read"),
968            Some(Route::Mcp {
969                server: "fs",
970                tool: "read"
971            })
972        ));
973        assert!(matches!(reg.route("memory.get"), Some(Route::Internal)));
974        assert!(
975            reg.route("code.run").is_none(),
976            "mapping-only without a mapping is unavailable"
977        );
978        assert!(reg.warnings.iter().any(|w| w.contains("collides")));
979    }
980
981    #[test]
982    fn overrides_disabled_and_profiles() {
983        let servers = vec![
984            ServerTools {
985                name: "mem".into(),
986                ns: None,
987                tags: vec![],
988                tools: vec![tool("search")],
989            },
990            ServerTools {
991                name: "kb".into(),
992                ns: None,
993                tags: vec![TrifectaTag::UntrustedInput],
994                tools: vec![tool("knowledge.search"), tool("knowledge.get")],
995            },
996            ServerTools {
997                name: "sandbox".into(),
998                ns: None,
999                tags: vec![],
1000                tools: vec![tool("execute")],
1001            },
1002        ];
1003        let s = settings(json!({
1004            "agent": {"instruction": "x"},
1005            "knowledge": {"server": "kb"},
1006            "tools": {
1007                "disabled": ["workflow.delete"],
1008                "overrides": {
1009                    "memory.get": {"server": "mem", "tool": "search", "args": "{\"query\": \"{{args.key}}\", \"limit\": 1}", "result": "{\"found\": true, \"value\": {{result.structuredContent.results.0.text}}}"},
1010                    "code.run": {"server": "sandbox", "tool": "execute", "args": "{\"lang\": \"{{args.language}}\", \"code\": \"{{args.code}}\"}"}
1011                }
1012            }
1013        }));
1014        let reg = Registry::build(&s, &servers).unwrap();
1015        // The override kept the contract, swapped the implementation.
1016        let mg = reg.get("memory.get").unwrap();
1017        assert_eq!(mg.class, ToolClass::Internal);
1018        let Some(Route::Mapped(m)) = reg.route("memory.get") else {
1019            panic!("mapped")
1020        };
1021        assert_eq!(m.tool, "search");
1022        let args =
1023            Registry::map_args(m, &json!({"key": "user/name"}), &json!({"instance": "i"})).unwrap();
1024        assert_eq!(args, json!({"query": "user/name", "limit": 1}));
1025        let out = Registry::map_result(m, &json!({"result": {"structuredContent": {"results": [{"text": "andrii"}]}, "isError": false, "text": ""}})).unwrap();
1026        assert_eq!(out, json!({"found": true, "value": "andrii"}));
1027        assert!(reg.validate_result("memory.get", &out).is_ok());
1028        // Mapping-only code.run is now available; knowledge.* got the profile server.
1029        assert!(matches!(reg.route("code.run"), Some(Route::Mapped(_))));
1030        let Some(Route::Mapped(k)) = reg.route("knowledge.search") else {
1031            panic!("profile mapped")
1032        };
1033        assert_eq!(k.server, "kb");
1034        assert_eq!(
1035            reg.get("knowledge.search").unwrap().tags,
1036            vec![TrifectaTag::UntrustedInput]
1037        );
1038        assert!(
1039            reg.route("knowledge.list").is_none(),
1040            "not advertised ⇒ still unavailable"
1041        );
1042        // Default result mapping prefers structuredContent, then text-JSON, then text.
1043        let plain = Mapping {
1044            server: "s".into(),
1045            tool: "t".into(),
1046            args: None,
1047            result: None,
1048        };
1049        assert_eq!(
1050            Registry::map_result(
1051                &plain,
1052                &json!({"result": {"structuredContent": {"a": 1}, "text": "x"}})
1053            )
1054            .unwrap(),
1055            json!({"a": 1})
1056        );
1057        assert_eq!(
1058            Registry::map_result(
1059                &plain,
1060                &json!({"result": {"structuredContent": null, "text": "{\"b\": 2}"}})
1061            )
1062            .unwrap(),
1063            json!({"b": 2})
1064        );
1065        assert_eq!(
1066            Registry::map_result(
1067                &plain,
1068                &json!({"result": {"structuredContent": null, "text": "hello"}})
1069            )
1070            .unwrap(),
1071            json!("hello")
1072        );
1073        // Disabled.
1074        assert!(reg.get("workflow.delete").unwrap().disabled);
1075        assert!(reg.route("workflow.delete").is_none());
1076        assert!(!reg.allowed(&Caller::Root, "workflow.delete"));
1077        // Errors.
1078        let bad = settings(json!({"agent": {"instruction": "x"}, "tools": {
1079            "disabled": ["nope", "memory.get"],
1080            "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"}}
1081        }}));
1082        let errs = Registry::build(&bad, &servers).unwrap_err();
1083        let joined = errs.join("\n");
1084        assert!(joined.contains("unknown tool \"nope\""), "{joined}");
1085        assert!(joined.contains("both name \"memory.get\""), "{joined}");
1086        assert!(
1087            joined.contains("fs.read: unknown internal tool"),
1088            "{joined}"
1089        );
1090        assert!(joined.contains("\"ghost\" is not a connected"), "{joined}");
1091        assert!(
1092            joined.contains("does not advertise tool \"missing\""),
1093            "{joined}"
1094        );
1095    }
1096
1097    #[test]
1098    fn grants_and_definitions_per_caller() {
1099        let servers = vec![ServerTools {
1100            name: "fs".into(),
1101            ns: None,
1102            tags: vec![],
1103            tools: vec![tool("read")],
1104        }];
1105        let s = settings(
1106            json!({"agent": {"instruction": "x", "tools": {"internal": ["memory", "plan.get", "finish"], "mcp": "all"}}}),
1107        );
1108        let reg = Registry::build(&s, &servers).unwrap();
1109        assert!(reg.allowed(&Caller::Root, "subagent.run"));
1110        assert!(!reg.allowed(&Caller::Workflow, "finish"));
1111        assert!(reg.allowed(&Caller::Workflow, "memory.set"));
1112        assert!(reg.allowed(&Caller::Subagent { allow: None }, "memory.get"));
1113        assert!(!reg.allowed(&Caller::Subagent { allow: None }, "subagent.run"));
1114        assert!(reg.allowed(
1115            &Caller::Subagent {
1116                allow: Some(&["memory.*".to_string()])
1117            },
1118            "memory.list"
1119        ));
1120        assert!(!reg.allowed(
1121            &Caller::Subagent {
1122                allow: Some(&["memory.*".to_string()])
1123            },
1124            "read"
1125        ));
1126        assert!(reg.allowed(
1127            &Caller::Principal {
1128                role: Role::User,
1129                grants: &[]
1130            },
1131            "status"
1132        ));
1133        assert!(!reg.allowed(
1134            &Caller::Principal {
1135                role: Role::User,
1136                grants: &[]
1137            },
1138            "workflow.run"
1139        ));
1140        assert!(reg.allowed(
1141            &Caller::Principal {
1142                role: Role::User,
1143                grants: &["workflow.*".to_string()]
1144            },
1145            "workflow.run"
1146        ));
1147        assert!(reg.allowed(
1148            &Caller::Principal {
1149                role: Role::Operator,
1150                grants: &[]
1151            },
1152            "workflow.delete"
1153        ));
1154        assert!(!reg.allowed(
1155            &Caller::Principal {
1156                role: Role::Anonymous,
1157                grants: &["*".to_string()]
1158            },
1159            "status"
1160        ));
1161        // Root definitions honour agent.tools selection: family + explicit names + all MCP.
1162        let defs = reg.defs_for(&Caller::Root, Some(&s.agent.tools));
1163        let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
1164        assert!(names.contains(&"memory.get") && names.contains(&"memory.set"));
1165        assert!(names.contains(&"plan.get") && !names.contains(&"plan.update"));
1166        assert!(names.contains(&"finish"));
1167        assert!(names.contains(&"read"));
1168        assert!(!names.contains(&"subagent.run"));
1169        // Argument validation.
1170        assert!(
1171            reg.validate_args("memory.get", &json!({"key": "k"}))
1172                .is_ok()
1173        );
1174        let e = reg
1175            .validate_args("memory.get", &json!({"ke": "k"}))
1176            .unwrap_err();
1177        assert!(
1178            e.contains("missing required property \"key\"")
1179                && e.contains("unknown property \"ke\""),
1180            "{e}"
1181        );
1182        assert!(reg.validate_args("nope", &json!({})).is_err());
1183    }
1184}