1use std::collections::BTreeMap;
6use std::sync::Arc;
7
8use serde::Serialize;
9use serde_json::Value;
10
11use crate::index::Index;
12
13use super::benchmark::CaseProvider;
14use super::handler::{CapabilityError, Context, Executable, Handler};
15use super::model::{
16 Availability, BenchmarkPolicy, Capability, CapabilityId, CapabilityKind, ExecutionPolicy,
17 HttpMethod, McpResource, ModuleId, Provenance, Stability, Visibility, WaiverReason,
18};
19use super::module::ModuleDescriptor;
20
21pub struct Entry {
23 pub capability: Capability,
25 handler: Option<Arc<dyn Handler>>,
26 cases: Option<CaseProvider>,
27}
28
29#[derive(
31 Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize, schemars::JsonSchema,
32)]
33#[serde(rename_all = "snake_case")]
34pub enum ModuleSource {
35 Builtin,
37 Derived,
39 Declarative,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize, schemars::JsonSchema)]
45pub struct ModuleInfo {
46 pub id: ModuleId,
48 pub title: String,
50 pub description: String,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub stability: Option<Stability>,
55 pub source: ModuleSource,
57 pub capabilities: usize,
59}
60
61impl std::fmt::Debug for Entry {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 f.debug_struct("Entry")
64 .field("capability", &self.capability)
65 .field("handler", &self.handler.is_some())
66 .finish()
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize)]
72#[serde(tag = "code", rename_all = "snake_case")]
73pub enum RegistryError {
74 #[error("capability '{id}' ({provenance}): invalid id: {reason}")]
75 InvalidId {
77 id: String,
79 provenance: String,
81 reason: String,
83 },
84 #[error("capability '{id}' is defined twice: {first} and {second}")]
85 DuplicateId {
87 id: String,
89 first: String,
91 second: String,
93 },
94 #[error("MCP tool '{name}' is claimed by '{first}' and '{second}'")]
95 DuplicateMcpName {
97 name: String,
99 first: String,
101 second: String,
103 },
104 #[error("MCP resource '{uri}' is claimed by '{first}' and '{second}'")]
105 DuplicateMcpUri {
107 uri: String,
109 first: String,
111 second: String,
113 },
114 #[error("HTTP route {method} {path} is claimed by '{first}' and '{second}'")]
115 DuplicateHttpRoute {
117 method: String,
119 path: String,
121 first: String,
123 second: String,
125 },
126 #[error("CLI path '{path}' is claimed by '{first}' and '{second}'")]
127 DuplicateCliPath {
129 path: String,
131 first: String,
133 second: String,
135 },
136 #[error("capability '{id}' ({provenance}): invalid {projection} exposure: {reason}")]
137 InvalidExposure {
139 id: String,
141 provenance: String,
143 projection: String,
145 reason: String,
147 },
148 #[error("capability '{id}' ({provenance}) is {stability} and cannot be exposed as executable through {projection}")]
149 NotExecutable {
151 id: String,
153 provenance: String,
155 stability: String,
157 projection: String,
159 },
160 #[error("module '{id}': invalid id: {reason}")]
161 InvalidModuleId {
163 id: String,
165 reason: String,
167 },
168 #[error("module '{id}' is composed twice ({first} and {second})")]
169 DuplicateModule {
171 id: String,
173 first: String,
175 second: String,
177 },
178 #[error("capability '{id}' ({provenance}) is composed in module '{module}' but its namespace is '{namespace}'")]
179 ModuleMismatch {
181 id: String,
183 provenance: String,
185 module: String,
187 namespace: String,
189 },
190 #[error("capability '{id}' ({provenance}): invalid cache policy: {reason}")]
191 InvalidCachePolicy {
193 id: String,
195 provenance: String,
197 reason: String,
199 },
200 #[error("capability '{id}' ({provenance}): {reason}")]
201 Shape {
203 id: String,
205 provenance: String,
207 reason: String,
209 },
210}
211
212#[derive(Debug, Default)]
213pub struct CapabilityRegistry {
215 entries: BTreeMap<CapabilityId, Entry>,
216 modules: BTreeMap<ModuleId, ModuleInfo>,
217 fingerprint: String,
218 by_mcp_tool: BTreeMap<String, CapabilityId>,
219 by_mcp_uri: BTreeMap<String, CapabilityId>,
220 by_http: BTreeMap<(HttpMethod, String), CapabilityId>,
221 by_cli: BTreeMap<Vec<String>, CapabilityId>,
222 by_skill: BTreeMap<String, CapabilityId>,
223}
224
225#[derive(Default)]
227pub struct Builder {
228 pending: Vec<Entry>,
229 modules: Vec<ModuleInfo>,
230 index_fingerprint: String,
231}
232
233impl Builder {
234 pub fn with_modules(mut self, modules: Vec<ModuleDescriptor>) -> Self {
237 for m in modules {
238 self.modules.push(ModuleInfo {
239 id: m.id.clone(),
240 title: m.title,
241 description: m.description,
242 stability: Some(m.stability),
243 source: ModuleSource::Builtin,
244 capabilities: m.capabilities.len(),
245 });
246 self = self.with_builtin(m.capabilities);
247 }
248 self
249 }
250
251 pub fn with_builtin(mut self, executables: Vec<Executable>) -> Self {
254 for e in executables {
255 self.pending.push(Entry {
256 capability: e.capability,
257 handler: Some(e.handler),
258 cases: Some(e.cases),
259 });
260 }
261 self
262 }
263
264 pub fn with_index(mut self, index: &Index) -> Self {
267 self.index_fingerprint = index.fingerprint.clone();
268 for object in &index.objects {
269 self.pending.push(Entry {
270 capability: super::declarative::capability_of(object),
271 handler: None,
272 cases: None,
273 });
274 }
275 self
276 }
277
278 pub fn build(self) -> Result<CapabilityRegistry, Vec<RegistryError>> {
281 let _phase = crate::perf::phase(crate::perf::Phase::RegistryBuild);
282 crate::perf::Counters::bump(&crate::perf::COUNTERS.registry_builds);
283 let mut errors = Vec::new();
284 let mut registry = CapabilityRegistry::default();
285 for m in self.modules {
286 let id = m.id.as_str().to_string();
287 if let Err(reason) = ModuleId::parse(&id) {
288 errors.push(RegistryError::InvalidModuleId { id, reason });
289 continue;
290 }
291 if let Some(first) = registry.modules.get(&m.id) {
292 errors.push(RegistryError::DuplicateModule {
293 id,
294 first: format!("{:?}", first.source).to_lowercase(),
295 second: "builtin".into(),
296 });
297 continue;
298 }
299 registry.modules.insert(m.id.clone(), m);
300 }
301 let mut pending = self.pending;
302 pending.sort_by(|a, b| {
303 a.capability.id.cmp(&b.capability.id).then_with(|| {
304 a.capability
305 .provenance
306 .to_string()
307 .cmp(&b.capability.provenance.to_string())
308 })
309 });
310 for mut entry in pending {
311 let c = &entry.capability;
312 let id = c.id.as_str().to_string();
313 let prov = c.provenance.to_string();
314 if let Err(reason) = CapabilityId::parse(&id) {
315 errors.push(RegistryError::InvalidId {
316 id: id.clone(),
317 provenance: prov.clone(),
318 reason,
319 });
320 continue;
321 }
322 if let Some(first) = registry.entries.get(&c.id) {
323 errors.push(RegistryError::DuplicateId {
324 id: id.clone(),
325 first: first.capability.provenance.to_string(),
326 second: prov.clone(),
327 });
328 continue;
329 }
330 let namespace = c.id.namespace().to_string();
332 if entry.capability.module.as_str().is_empty() {
333 entry.capability.module = ModuleId::unchecked(&namespace);
334 }
335 let c = &entry.capability;
336 let is_builtin = matches!(c.provenance, Provenance::Builtin { .. });
337 if is_builtin && c.module.as_str() != namespace {
338 errors.push(RegistryError::ModuleMismatch {
339 id: id.clone(),
340 provenance: prov.clone(),
341 module: c.module.as_str().to_string(),
342 namespace,
343 });
344 continue;
345 }
346 match registry.modules.get_mut(&c.module) {
347 Some(m) if m.source == ModuleSource::Builtin && is_builtin => {}
348 Some(m) if m.source == ModuleSource::Builtin => {
349 errors.push(RegistryError::DuplicateModule {
350 id: c.module.as_str().to_string(),
351 first: "builtin".into(),
352 second: format!("declarative kind of {prov}"),
353 });
354 continue;
355 }
356 Some(m) => m.capabilities += 1,
357 None => {
358 if let Err(reason) = ModuleId::parse(c.module.as_str()) {
359 errors.push(RegistryError::InvalidModuleId {
360 id: c.module.as_str().to_string(),
361 reason,
362 });
363 continue;
364 }
365 let (source, title, description) = if is_builtin {
366 (
367 ModuleSource::Derived,
368 c.module.as_str().to_string(),
369 format!("Executables in the '{}' namespace, composed without a module descriptor.", c.module),
370 )
371 } else {
372 (
373 ModuleSource::Declarative,
374 c.module.as_str().to_string(),
375 format!(
376 "Declarative objects of kind '{}' from the repository's AI layer.",
377 c.module
378 ),
379 )
380 };
381 registry.modules.insert(
382 c.module.clone(),
383 ModuleInfo {
384 id: c.module.clone(),
385 title,
386 description,
387 stability: None,
388 source,
389 capabilities: 1,
390 },
391 );
392 }
393 }
394 if let Err(reason) = c.cache.validate() {
395 errors.push(RegistryError::InvalidCachePolicy {
396 id: id.clone(),
397 provenance: prov.clone(),
398 reason,
399 });
400 continue;
401 }
402 let availability = Availability::classify(c.kind, &c.exposure);
408 let visibility = Visibility::classify(&c.exposure);
409 if c.availability != availability || c.visibility != visibility {
410 errors.push(RegistryError::Shape {
411 id: id.clone(),
412 provenance: prov.clone(),
413 reason: format!(
414 "declares availability {:?} and visibility {:?}; what it exposes makes it {availability:?} and {visibility:?}. Classification follows the kind and the transports, so change those or let the declaration classify itself",
415 c.availability, c.visibility
416 ),
417 });
418 continue;
419 }
420 match (c.kind, c.benchmark) {
421 (
422 CapabilityKind::Resource,
423 BenchmarkPolicy::Waived {
424 reason: WaiverReason::NotExecutable,
425 },
426 ) => {}
427 (CapabilityKind::Resource, _) => {
428 errors.push(RegistryError::Shape {
429 id: id.clone(),
430 provenance: prov.clone(),
431 reason: "a resource is not a benchmark target: its policy is waived as not executable".into(),
432 });
433 continue;
434 }
435 (
436 _,
437 BenchmarkPolicy::Waived {
438 reason: WaiverReason::NotExecutable,
439 },
440 ) => {
441 errors.push(RegistryError::Shape {
442 id: id.clone(),
443 provenance: prov.clone(),
444 reason:
445 "an executable cannot be waived as not executable; name a real reason"
446 .into(),
447 });
448 continue;
449 }
450 _ => {}
451 }
452 if c.cache.is_enabled() && c.kind == CapabilityKind::Command {
453 errors.push(RegistryError::InvalidCachePolicy {
454 id: id.clone(),
455 provenance: prov.clone(),
456 reason: "a command changes state and is never cached".into(),
457 });
458 continue;
459 }
460 let policy = ExecutionPolicy::classify(c.kind);
465 let mut permitted = vec![policy, policy.stoppable()];
471 if c.kind == CapabilityKind::Command {
472 permitted.push(policy.writes_repository());
473 permitted.push(policy.stoppable().writes_repository());
474 permitted.push(policy.authenticates_its_input());
475 }
476 if !permitted.contains(&c.execution) {
477 errors.push(RegistryError::Shape {
478 id: id.clone(),
479 provenance: prov.clone(),
480 reason: format!(
481 "declares the execution policy {:?}; its kind makes it {policy:?}. A declaration may add cancellation (`.cancellable()`), a command may add writing the repository (`.writes_repository()`) or a signed input (`.authenticates_its_input()`), never both; the effect of a query and the concurrency of anything follow the kind",
482 c.execution
483 ),
484 });
485 continue;
486 }
487 match (c.kind, entry.handler.is_some()) {
488 (CapabilityKind::Query, false) => {
489 errors.push(RegistryError::Shape {
490 id: id.clone(),
491 provenance: prov.clone(),
492 reason: "a query needs a handler".into(),
493 });
494 continue;
495 }
496 (CapabilityKind::Command, false) => {
497 errors.push(RegistryError::Shape {
498 id: id.clone(),
499 provenance: prov.clone(),
500 reason: "a command needs a handler".into(),
501 });
502 continue;
503 }
504 (CapabilityKind::Resource, true) => {
505 errors.push(RegistryError::Shape {
506 id: id.clone(),
507 provenance: prov.clone(),
508 reason: "a resource has no handler".into(),
509 });
510 continue;
511 }
512 _ => {}
513 }
514 if let Some(mcp) = &c.exposure.mcp {
515 if let Some(tool) = &mcp.tool {
516 if !c.stability.executable() {
517 errors.push(not_executable(c, "MCP tool"));
518 }
519 if tool.is_empty()
520 || !tool
521 .bytes()
522 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
523 {
524 errors.push(RegistryError::InvalidExposure {
525 id: id.clone(),
526 provenance: prov.clone(),
527 projection: "MCP".into(),
528 reason: format!("tool name '{tool}' is not [a-z0-9_]+"),
529 });
530 } else if let Some(first) = registry.by_mcp_tool.get(tool) {
531 errors.push(RegistryError::DuplicateMcpName {
532 name: tool.clone(),
533 first: first.to_string(),
534 second: id.clone(),
535 });
536 } else {
537 registry.by_mcp_tool.insert(tool.clone(), c.id.clone());
538 }
539 }
540 if let Some(res) = &mcp.resource {
541 if !res.uri.starts_with("majordomus://")
542 || res.uri.contains(char::is_whitespace)
543 {
544 errors.push(RegistryError::InvalidExposure {
545 id: id.clone(),
546 provenance: prov.clone(),
547 projection: "MCP".into(),
548 reason: format!(
549 "resource uri '{}' is not a majordomus:// uri",
550 res.uri
551 ),
552 });
553 } else if let Some(first) = registry.by_mcp_uri.get(&res.uri) {
554 errors.push(RegistryError::DuplicateMcpUri {
555 uri: res.uri.clone(),
556 first: first.to_string(),
557 second: id.clone(),
558 });
559 } else {
560 registry.by_mcp_uri.insert(res.uri.clone(), c.id.clone());
561 }
562 let (_, required) = c.input.properties();
564 if c.kind.is_executable() && !required.is_empty() {
565 errors.push(RegistryError::Shape {
566 id: id.clone(),
567 provenance: prov.clone(),
568 reason: format!(
569 "a query read as the resource '{}' is called with no input, and its input requires {}",
570 res.uri,
571 required.join(", ")
572 ),
573 });
574 }
575 }
576 }
577 if let Some(http) = &c.exposure.http {
578 if !c.stability.executable() {
579 errors.push(not_executable(c, "HTTP"));
580 }
581 if let Err(reason) = http.validate() {
582 errors.push(RegistryError::InvalidExposure {
583 id: id.clone(),
584 provenance: prov.clone(),
585 projection: "HTTP".into(),
586 reason,
587 });
588 } else if let Some(first) = registry.by_http.get(&(http.method, http.path.clone()))
589 {
590 errors.push(RegistryError::DuplicateHttpRoute {
591 method: http.method.as_str().into(),
592 path: http.path.clone(),
593 first: first.to_string(),
594 second: id.clone(),
595 });
596 } else {
597 registry
598 .by_http
599 .insert((http.method, http.path.clone()), c.id.clone());
600 }
601 }
602 if let Some(cli) = &c.exposure.cli {
603 if !c.stability.executable() {
604 errors.push(not_executable(c, "CLI"));
605 }
606 if cli.path.is_empty()
607 || cli
608 .path
609 .iter()
610 .any(|w| w.is_empty() || w.contains(char::is_whitespace))
611 {
612 errors.push(RegistryError::InvalidExposure {
613 id: id.clone(),
614 provenance: prov.clone(),
615 projection: "CLI".into(),
616 reason: format!("path {:?} has an empty or blank word", cli.path),
617 });
618 } else if let Some(first) = registry.by_cli.get(&cli.path) {
619 errors.push(RegistryError::DuplicateCliPath {
620 path: cli.path.join(" "),
621 first: first.to_string(),
622 second: id.clone(),
623 });
624 } else {
625 registry.by_cli.insert(cli.path.clone(), c.id.clone());
626 }
627 }
628 if let Some(skill) = &c.skill {
629 let refuse = |reason: String| RegistryError::InvalidExposure {
634 id: id.clone(),
635 provenance: prov.clone(),
636 projection: "skill".into(),
637 reason,
638 };
639 let (properties, required) = c.input.properties();
640 let argument = properties.iter().find(|(name, _)| *name == skill.argument);
641 if !c.stability.executable() {
642 errors.push(not_executable(c, "skill"));
643 }
644 if let Err(reason) = skill.validate() {
645 errors.push(refuse(reason));
646 } else if let Some(first) = registry.by_skill.get(&skill.alias) {
647 errors.push(refuse(format!(
648 "alias '{}' is already the skill of {first}",
649 skill.alias
650 )));
651 } else if !argument.is_some_and(|(_, schema)| {
652 schema.get("type").and_then(|t| t.as_str()) == Some("string")
653 }) {
654 errors.push(refuse(format!(
655 "argument '{}' is not a string field of the input",
656 skill.argument
657 )));
658 } else if let Some(other) = required.iter().find(|r| **r != skill.argument) {
659 errors.push(refuse(format!(
660 "the input also requires '{other}', which the typed text cannot supply"
661 )));
662 } else {
663 registry.by_skill.insert(skill.alias.clone(), c.id.clone());
664 }
665 }
666 if c.kind == CapabilityKind::Resource
667 && (c.exposure.http.is_some()
668 || c.exposure.cli.is_some()
669 || c.exposure.mcp.as_ref().is_some_and(|m| m.tool.is_some()))
670 {
671 errors.push(RegistryError::Shape {
672 id: id.clone(),
673 provenance: prov.clone(),
674 reason: "a resource is read, not called: only an MCP resource exposure applies"
675 .into(),
676 });
677 }
678 if c.kind == CapabilityKind::Command {
679 let word = c.kind.as_str();
680 if c.exposure
681 .mcp
682 .as_ref()
683 .is_some_and(|m| m.resource.is_some())
684 {
685 errors.push(RegistryError::Shape {
686 id: id.clone(),
687 provenance: prov.clone(),
688 reason: format!(
689 "a {word} is called, not read: it has no MCP resource exposure"
690 ),
691 });
692 }
693 if let Some(http) = &c.exposure.http {
694 if http.method != HttpMethod::Post {
695 errors.push(RegistryError::InvalidExposure {
696 id: id.clone(),
697 provenance: prov.clone(),
698 projection: "HTTP".into(),
699 reason: format!(
700 "a {word} changes state and is bound to POST, not {}",
701 http.method.as_str()
702 ),
703 });
704 }
705 }
706 }
707 registry.entries.insert(c.id.clone(), entry);
708 }
709 if errors.is_empty() {
710 registry.fingerprint = fingerprint(&self.index_fingerprint, ®istry);
711 Ok(registry)
712 } else {
713 Err(errors)
714 }
715 }
716}
717
718fn fingerprint(index: &str, registry: &CapabilityRegistry) -> String {
722 use sha2::{Digest, Sha256};
723 let mut h = Sha256::new();
724 h.update(index.as_bytes());
725 for c in registry.iter() {
726 h.update(c.id.as_str().as_bytes());
727 h.update(b"\0");
728 if matches!(c.provenance, Provenance::Builtin { .. }) {
731 h.update(serde_json::to_string(c).unwrap_or_default().as_bytes());
732 }
733 h.update(b"\n");
734 }
735 format!("{:x}", h.finalize())
736}
737
738fn not_executable(c: &Capability, projection: &str) -> RegistryError {
739 RegistryError::NotExecutable {
740 id: c.id.to_string(),
741 provenance: c.provenance.to_string(),
742 stability: serde_json::to_value(c.stability)
743 .ok()
744 .and_then(|v| v.as_str().map(str::to_string))
745 .unwrap_or_default(),
746 projection: projection.into(),
747 }
748}
749
750impl CapabilityRegistry {
751 pub fn builder() -> Builder {
764 Builder::default()
765 }
766
767 pub fn iter(&self) -> impl Iterator<Item = &Capability> {
769 self.entries.values().map(|e| &e.capability)
770 }
771
772 pub fn len(&self) -> usize {
774 self.entries.len()
775 }
776
777 pub fn is_empty(&self) -> bool {
779 self.entries.is_empty()
780 }
781
782 pub fn modules(&self) -> impl Iterator<Item = &ModuleInfo> {
784 self.modules.values()
785 }
786
787 pub fn module(&self, id: &str) -> Option<&ModuleInfo> {
789 self.modules.get(&ModuleId::unchecked(id))
790 }
791
792 pub fn cases(&self, id: &str) -> Option<CaseProvider> {
794 self.entries
795 .get(&CapabilityId::unchecked(id))
796 .and_then(|e| e.cases)
797 }
798
799 pub fn get(&self, id: &str) -> Option<&Capability> {
801 self.entries
802 .get(&CapabilityId::unchecked(id))
803 .map(|e| &e.capability)
804 }
805
806 pub fn by_mcp_tool(&self, name: &str) -> Option<&Capability> {
808 self.by_mcp_tool
809 .get(name)
810 .and_then(|id| self.get(id.as_str()))
811 }
812
813 pub fn by_mcp_uri(&self, uri: &str) -> Option<(&Capability, &McpResource)> {
824 let c = self
825 .by_mcp_uri
826 .get(uri)
827 .and_then(|id| self.get(id.as_str()))?;
828 let res = c.exposure.mcp.as_ref()?.resource.as_ref()?;
829 Some((c, res))
830 }
831
832 pub fn by_http(&self, method: HttpMethod, path: &str) -> Option<&Capability> {
834 self.by_http
835 .get(&(method, path.to_string()))
836 .and_then(|id| self.get(id.as_str()))
837 }
838
839 pub fn by_cli(&self, path: &[String]) -> Option<&Capability> {
841 self.by_cli.get(path).and_then(|id| self.get(id.as_str()))
842 }
843
844 pub fn by_skill(&self, alias: &str) -> Option<&Capability> {
854 self.by_skill
855 .get(alias)
856 .and_then(|id| self.get(id.as_str()))
857 }
858
859 pub fn skills(&self) -> impl Iterator<Item = &Capability> {
873 self.by_skill
874 .values()
875 .filter_map(|id| self.get(id.as_str()))
876 }
877
878 pub fn fingerprint(&self) -> &str {
880 &self.fingerprint
881 }
882
883 pub fn dispatch(
887 &self,
888 ctx: &Context,
889 id: &str,
890 input: Value,
891 ) -> Result<Value, CapabilityError> {
892 let entry = self
893 .entries
894 .get(&CapabilityId::unchecked(id))
895 .ok_or_else(|| CapabilityError::NotFound(format!("capability {id}")))?;
896 let handler = entry.handler.as_ref().ok_or_else(|| {
897 CapabilityError::NotFound(format!("capability {id} is a resource, not a query"))
898 })?;
899 tracing::debug!(capability_id = id, "call");
900 handler.call(ctx, input)
901 }
902
903 pub fn summary(&self) -> Summary {
905 let mut s = Summary {
906 modules: self.modules.len(),
907 ..Default::default()
908 };
909 for c in self.iter() {
910 *s.by_kind
911 .entry(format!("{:?}", c.kind).to_lowercase())
912 .or_default() += 1;
913 let st = serde_json::to_value(c.stability)
914 .ok()
915 .and_then(|v| v.as_str().map(str::to_string))
916 .unwrap_or_default();
917 *s.by_stability.entry(st).or_default() += 1;
918 if c.exposure.mcp.as_ref().is_some_and(|m| m.tool.is_some()) {
919 s.mcp_tools += 1;
920 }
921 if c.exposure
922 .mcp
923 .as_ref()
924 .is_some_and(|m| m.resource.is_some())
925 {
926 s.mcp_resources += 1;
927 }
928 if c.exposure.http.is_some() {
929 s.http_routes += 1;
930 }
931 if c.exposure.cli.is_some() {
932 s.cli_commands += 1;
933 }
934 match c.benchmark {
935 BenchmarkPolicy::Required | BenchmarkPolicy::RequiredWhen { .. } => {
936 s.benchmark_required += 1
937 }
938 BenchmarkPolicy::Waived {
939 reason: WaiverReason::NotExecutable,
940 } => {}
941 BenchmarkPolicy::Waived { .. } => s.benchmark_waived += 1,
942 }
943 if c.cache.is_enabled() {
944 s.cached += 1;
945 }
946 match c.provenance {
947 Provenance::Builtin { .. } => s.builtin += 1,
948 Provenance::Declarative { .. } => s.declarative += 1,
949 }
950 }
951 s.total = self.len();
952 s
953 }
954}
955
956#[derive(
957 Debug, Default, Clone, PartialEq, Eq, Serialize, serde::Deserialize, schemars::JsonSchema,
958)]
959pub struct Summary {
961 pub total: usize,
963 pub builtin: usize,
965 pub declarative: usize,
967 pub by_kind: BTreeMap<String, usize>,
969 pub by_stability: BTreeMap<String, usize>,
971 pub mcp_tools: usize,
973 pub mcp_resources: usize,
975 pub http_routes: usize,
977 pub cli_commands: usize,
979 pub modules: usize,
981 pub benchmark_required: usize,
983 pub benchmark_waived: usize,
985 pub cached: usize,
987}