1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ToolClass {
31 Internal,
32 Code,
33 Mcp,
34 Workflow,
39}
40
41#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(tag = "impl", rename_all = "snake_case")]
58pub enum Impl {
59 BuiltIn,
61 MappingOnly,
63 Mapped(Mapping),
65 Code,
67 Mcp { server: String, tool: String },
69 Workflow { workflow: String, sync: bool },
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct Grant {
80 pub root: bool,
81 pub workflows: bool,
82 pub subagents: bool,
83 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#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub tags: Vec<TrifectaTag>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub server: Option<String>,
120}
121
122impl ToolSpec {
123 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#[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#[derive(Debug, Clone, PartialEq)]
147pub enum Route<'a> {
148 Internal,
150 Mapped(&'a Mapping),
152 Code,
154 Mcp { server: &'a str, tool: &'a str },
156 Workflow { workflow: &'a str, sync: bool },
158}
159
160#[derive(Debug, Clone, PartialEq)]
162pub enum Caller<'a> {
163 Root,
164 Workflow,
165 Subagent {
167 allow: Option<&'a [String]>,
168 },
169 Principal {
178 role: Role,
179 grants: &'a [String],
180 },
181}
182
183#[derive(Debug, Clone, Default)]
185pub struct Registry {
186 tools: BTreeMap<String, ToolSpec>,
187 servers: Vec<String>,
188 pub warnings: Vec<String>,
190}
191
192impl Registry {
193 pub fn build(settings: &Settings, servers: &[ServerTools]) -> Result<Registry, Vec<String>> {
202 let mut reg = Registry::default();
203 let mut errors = Vec::new();
204 for c in internal::contracts() {
206 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 ToolClass::Workflow => true,
672 },
673 })
674 .map(ToolSpec::def)
675 .collect()
676 }
677
678 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 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 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 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 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 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 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 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
814pub 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
826fn 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 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 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 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 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 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 assert!(reg.get("workflow.delete").unwrap().disabled);
1075 assert!(reg.route("workflow.delete").is_none());
1076 assert!(!reg.allowed(&Caller::Root, "workflow.delete"));
1077 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 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 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}