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}
35
36#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(tag = "impl", rename_all = "snake_case")]
50pub enum Impl {
51 BuiltIn,
53 MappingOnly,
55 Mapped(Mapping),
57 Code,
59 Mcp { server: String, tool: String },
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct Grant {
66 pub root: bool,
67 pub workflows: bool,
68 pub subagents: bool,
69 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#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
102 pub tags: Vec<TrifectaTag>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub server: Option<String>,
106}
107
108impl ToolSpec {
109 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#[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#[derive(Debug, Clone, PartialEq)]
133pub enum Route<'a> {
134 Internal,
136 Mapped(&'a Mapping),
138 Code,
140 Mcp { server: &'a str, tool: &'a str },
142}
143
144#[derive(Debug, Clone, PartialEq)]
146pub enum Caller<'a> {
147 Root,
148 Workflow,
149 Subagent {
151 allow: Option<&'a [String]>,
152 },
153 Principal {
155 role: Role,
156 grants: &'a [String],
157 },
158}
159
160#[derive(Debug, Clone, Default)]
162pub struct Registry {
163 tools: BTreeMap<String, ToolSpec>,
164 servers: Vec<String>,
165 pub warnings: Vec<String>,
167}
168
169impl Registry {
170 pub fn build(settings: &Settings, servers: &[ServerTools]) -> Result<Registry, Vec<String>> {
175 let mut reg = Registry::default();
176 let mut errors = Vec::new();
177 for c in internal::contracts() {
179 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
630pub 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
642fn 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 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 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 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 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 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 assert!(reg.get("workflow.delete").unwrap().disabled);
864 assert!(reg.route("workflow.delete").is_none());
865 assert!(!reg.allowed(&Caller::Root, "workflow.delete"));
866 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 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 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}