1#![allow(clippy::items_after_test_module)]
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::sync::{Arc, RwLock};
5
6use serde::{Deserialize, Serialize};
7
8use crate::{
9 ProgressSender, ToolCall, ToolContext, ToolContract, ToolManifest, ToolProvider, ToolResult,
10};
11
12const PLUGIN_SOURCE_ID: &str = "plugins";
13
14#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
15#[serde(transparent)]
16pub struct ToolSourceHandle {
17 id: String,
18}
19
20impl ToolSourceHandle {
21 pub(crate) fn new(id: impl Into<String>) -> Self {
22 Self { id: id.into() }
23 }
24
25 pub(crate) fn as_str(&self) -> &str {
26 &self.id
27 }
28}
29
30#[derive(Clone, Debug, Serialize, Deserialize)]
31pub struct ToolStateEntry {
32 manifest: ToolManifest,
33 source_id: String,
34}
35
36impl ToolStateEntry {
37 pub fn manifest(&self) -> &ToolManifest {
38 &self.manifest
39 }
40
41 pub fn manifest_mut(&mut self) -> &mut ToolManifest {
42 &mut self.manifest
43 }
44}
45
46#[derive(Clone, Debug, Default, Serialize, Deserialize)]
47pub struct ToolState {
48 generation: u64,
49 tools: BTreeMap<String, ToolStateEntry>,
50}
51
52impl ToolState {
53 pub(crate) fn new(generation: u64, tools: BTreeMap<String, ToolStateEntry>) -> Self {
54 Self { generation, tools }
55 }
56
57 pub fn generation(&self) -> u64 {
58 self.generation
59 }
60
61 pub fn with_generation(mut self, generation: u64) -> Self {
62 self.generation = generation;
63 self
64 }
65
66 pub fn tool_manifests(&self) -> Vec<ToolManifest> {
67 self.tools
68 .values()
69 .map(|entry| entry.manifest.clone())
70 .collect()
71 }
72
73 pub fn get(&self, name: &str) -> Option<&ToolStateEntry> {
74 self.tools.get(name)
75 }
76
77 pub fn manifest_mut(&mut self, name: &str) -> Option<&mut ToolManifest> {
78 self.tools.get_mut(name).map(|entry| &mut entry.manifest)
79 }
80
81 pub fn contains(&self, name: &str) -> bool {
82 self.tools.contains_key(name)
83 }
84
85 pub fn is_empty(&self) -> bool {
86 self.tools.is_empty()
87 }
88
89 pub fn len(&self) -> usize {
90 self.tools.len()
91 }
92
93 pub fn iter(&self) -> impl Iterator<Item = (&str, &ToolStateEntry)> {
94 self.tools
95 .iter()
96 .map(|(name, entry)| (name.as_str(), entry))
97 }
98
99 pub fn set_availability(
100 &mut self,
101 name: &str,
102 availability: Option<crate::ToolAvailability>,
103 ) -> Result<(), ReconfigureError> {
104 let Some(entry) = self.tools.get_mut(name) else {
105 return Err(ReconfigureError::Validation(format!(
106 "unknown tool `{name}`"
107 )));
108 };
109 entry.manifest.availability_override = availability;
110 Ok(())
111 }
112
113 pub fn retain(&mut self, mut keep: impl FnMut(&str, &ToolStateEntry) -> bool) {
114 self.tools.retain(|name, entry| keep(name, entry));
115 }
116
117 pub fn remove(&mut self, name: &str) -> Option<ToolStateEntry> {
118 self.tools.remove(name)
119 }
120
121 pub(crate) fn entries(&self) -> &BTreeMap<String, ToolStateEntry> {
122 &self.tools
123 }
124
125 pub(crate) fn into_entries(self) -> BTreeMap<String, ToolStateEntry> {
126 self.tools
127 }
128}
129
130#[async_trait::async_trait]
131pub(crate) trait ToolSourceExecutor: Send + Sync + 'static {
132 fn id(&self) -> &str;
133 fn advertised_tools(&self) -> Vec<ToolManifest>;
134 fn resolve_manifest(&self, name: &str) -> Option<ToolManifest> {
135 self.advertised_tools()
136 .into_iter()
137 .find(|manifest| manifest.name == name)
138 }
139 fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>>;
140 async fn execute(
141 &self,
142 tool: &str,
143 args: &serde_json::Value,
144 context: &ToolContext,
145 progress: Option<&ProgressSender>,
146 ) -> ToolResult;
147}
148
149struct ToolProviderSource {
150 id: String,
151 provider: Arc<dyn ToolProvider>,
152}
153
154impl ToolProviderSource {
155 fn new(id: impl Into<String>, provider: Arc<dyn ToolProvider>) -> Self {
156 Self {
157 id: id.into(),
158 provider,
159 }
160 }
161}
162
163#[async_trait::async_trait]
164impl ToolSourceExecutor for ToolProviderSource {
165 fn id(&self) -> &str {
166 &self.id
167 }
168
169 fn advertised_tools(&self) -> Vec<ToolManifest> {
170 self.provider.tool_manifests()
171 }
172
173 fn resolve_manifest(&self, name: &str) -> Option<ToolManifest> {
174 self.provider.resolve_manifest(name)
175 }
176
177 fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
178 self.provider.resolve_contract(name)
179 }
180
181 async fn execute(
182 &self,
183 tool: &str,
184 args: &serde_json::Value,
185 context: &ToolContext,
186 progress: Option<&ProgressSender>,
187 ) -> ToolResult {
188 self.provider
189 .execute(ToolCall {
190 name: tool,
191 args,
192 context,
193 progress,
194 })
195 .await
196 }
197}
198
199#[derive(Clone)]
200struct ToolRegistryState {
201 generation: u64,
202 tools: BTreeMap<String, ToolStateEntry>,
203 next_live_source_id: u64,
204}
205
206#[derive(Debug, thiserror::Error)]
207pub enum ReconfigureError {
208 #[error("validation error: {0}")]
209 Validation(String),
210 #[error("unknown tool source: {0}")]
211 UnknownSource(String),
212 #[error("generation mismatch: expected {expected}, actual {actual}")]
213 GenerationMismatch { expected: u64, actual: u64 },
214}
215
216#[derive(Clone)]
217pub struct ToolRegistry {
218 sources: Arc<RwLock<BTreeMap<String, Arc<dyn ToolSourceExecutor>>>>,
219 state: Arc<RwLock<ToolRegistryState>>,
220}
221
222impl ToolRegistry {
223 pub fn from_tool_provider(provider: Arc<dyn ToolProvider>) -> Result<Self, ReconfigureError> {
224 let registry = Self::empty();
225 registry.upsert_source(Arc::new(ToolProviderSource::new(
226 PLUGIN_SOURCE_ID,
227 provider,
228 )))?;
229 Ok(registry)
230 }
231
232 pub(crate) fn empty() -> Self {
233 Self {
234 sources: Arc::new(RwLock::new(BTreeMap::new())),
235 state: Arc::new(RwLock::new(ToolRegistryState {
236 generation: 0,
237 tools: BTreeMap::new(),
238 next_live_source_id: 0,
239 })),
240 }
241 }
242
243 pub fn generation(&self) -> u64 {
244 self.state
245 .read()
246 .expect("tool registry state lock poisoned")
247 .generation
248 }
249
250 pub fn export_state(&self) -> ToolState {
251 let state = self
252 .state
253 .read()
254 .expect("tool registry state lock poisoned");
255 ToolState::new(state.generation, state.tools.clone())
256 }
257
258 pub fn apply_state(&self, next: ToolState) -> Result<u64, ReconfigureError> {
259 let current_generation = self.generation();
260 if next.generation != current_generation {
261 return Err(ReconfigureError::GenerationMismatch {
262 expected: next.generation,
263 actual: current_generation,
264 });
265 }
266
267 {
268 let sources = self.sources.read().expect("tool source lock poisoned");
269 for entry in next.entries().values() {
270 let Some(source) = sources.get(&entry.source_id) else {
271 return Err(ReconfigureError::UnknownSource(entry.source_id.clone()));
272 };
273 if source.resolve_manifest(&entry.manifest.name).is_none() {
274 return Err(ReconfigureError::Validation(format!(
275 "tool source `{}` does not resolve tool `{}`",
276 entry.source_id, entry.manifest.name
277 )));
278 }
279 }
280 }
281
282 let mut state = self
283 .state
284 .write()
285 .expect("tool registry state lock poisoned");
286 if state.generation != next.generation {
287 return Err(ReconfigureError::GenerationMismatch {
288 expected: next.generation,
289 actual: state.generation,
290 });
291 }
292 state.tools = next.into_entries();
293 state.generation += 1;
294 Ok(state.generation)
295 }
296
297 pub fn add_tool_provider(
298 &self,
299 provider: Arc<dyn ToolProvider>,
300 ) -> Result<ToolSourceHandle, ReconfigureError> {
301 let source_id = {
302 let mut state = self
303 .state
304 .write()
305 .expect("tool registry state lock poisoned");
306 state.next_live_source_id += 1;
307 format!("live:{}", state.next_live_source_id)
308 };
309 self.upsert_source(Arc::new(ToolProviderSource::new(
310 source_id.clone(),
311 provider,
312 )))?;
313 Ok(ToolSourceHandle::new(source_id))
314 }
315
316 pub(crate) fn upsert_source(
317 &self,
318 source: Arc<dyn ToolSourceExecutor>,
319 ) -> Result<u64, ReconfigureError> {
320 let source_id = source.id().to_string();
321 let advertised_tools = source.advertised_tools();
322 validate_unique_manifests(&advertised_tools)?;
323
324 let mut state = self
325 .state
326 .write()
327 .expect("tool registry state lock poisoned");
328 let previous_overrides = state
329 .tools
330 .iter()
331 .map(|(name, entry)| (name.clone(), entry.manifest.availability_override))
332 .collect::<BTreeMap<_, _>>();
333 let same_source_names = state
334 .tools
335 .iter()
336 .filter_map(|(name, entry)| (entry.source_id == source_id).then_some(name.clone()))
337 .collect::<BTreeSet<_>>();
338 for manifest in &advertised_tools {
339 if let Some(existing) = state.tools.get(&manifest.name)
340 && existing.source_id != source_id
341 {
342 return Err(ReconfigureError::Validation(format!(
343 "duplicate tool name `{}` from source `{}` conflicts with source `{}`",
344 manifest.name, source_id, existing.source_id
345 )));
346 }
347 }
348 state.tools.retain(|name, entry| {
349 entry.source_id != source_id || !same_source_names.contains(name)
350 });
351
352 for mut manifest in advertised_tools {
353 let name = manifest.name.clone();
354 manifest.availability_override = previous_overrides
355 .get(&name)
356 .copied()
357 .flatten()
358 .or(manifest.availability_override);
359 state.tools.insert(
360 name,
361 ToolStateEntry {
362 manifest,
363 source_id: source_id.clone(),
364 },
365 );
366 }
367
368 self.sources
369 .write()
370 .expect("tool source lock poisoned")
371 .insert(source_id, source);
372 state.generation += 1;
373 Ok(state.generation)
374 }
375
376 pub fn remove_source(&self, handle: &ToolSourceHandle) -> Result<u64, ReconfigureError> {
377 self.remove_source_id(handle.as_str())
378 }
379
380 pub(crate) fn remove_source_id(&self, source_id: &str) -> Result<u64, ReconfigureError> {
381 {
382 let mut sources = self.sources.write().expect("tool source lock poisoned");
383 if sources.remove(source_id).is_none() {
384 return Err(ReconfigureError::UnknownSource(source_id.to_string()));
385 }
386 }
387 let mut state = self
388 .state
389 .write()
390 .expect("tool registry state lock poisoned");
391 state.tools.retain(|_, entry| entry.source_id != source_id);
392 state.generation += 1;
393 Ok(state.generation)
394 }
395
396 pub(crate) fn fork_with_state(&self, snapshot: ToolState) -> Result<Self, ReconfigureError> {
397 let sources = self
398 .sources
399 .read()
400 .expect("tool source lock poisoned")
401 .iter()
402 .map(|(k, v)| (k.clone(), Arc::clone(v)))
403 .collect();
404 let generation = snapshot.generation.max(1);
405 Ok(Self {
406 sources: Arc::new(RwLock::new(sources)),
407 state: Arc::new(RwLock::new(ToolRegistryState {
408 generation,
409 tools: snapshot.into_entries(),
410 next_live_source_id: 0,
411 })),
412 })
413 }
414}
415
416#[async_trait::async_trait]
417impl ToolProvider for ToolRegistry {
418 fn tool_manifests(&self) -> Vec<ToolManifest> {
419 let state = self
420 .state
421 .read()
422 .expect("tool registry state lock poisoned");
423 state
424 .tools
425 .values()
426 .map(|entry| entry.manifest.clone())
427 .collect()
428 }
429
430 fn resolve_manifest(&self, name: &str) -> Option<ToolManifest> {
431 if let Some(manifest) = {
432 let state = self
433 .state
434 .read()
435 .expect("tool registry state lock poisoned");
436 state.tools.get(name).map(|entry| entry.manifest.clone())
437 } {
438 return Some(manifest);
439 }
440
441 let sources = self
442 .sources
443 .read()
444 .expect("tool source lock poisoned")
445 .iter()
446 .map(|(source_id, source)| (source_id.clone(), Arc::clone(source)))
447 .collect::<Vec<_>>();
448 for (source_id, source) in sources {
449 let Some(mut manifest) = source.resolve_manifest(name) else {
450 continue;
451 };
452 let previous_override = {
453 let state = self
454 .state
455 .read()
456 .expect("tool registry state lock poisoned");
457 state
458 .tools
459 .get(&manifest.name)
460 .and_then(|entry| entry.manifest.availability_override)
461 };
462 manifest.availability_override = previous_override.or(manifest.availability_override);
463 let mut state = self
464 .state
465 .write()
466 .expect("tool registry state lock poisoned");
467 if let Some(existing) = state.tools.get(&manifest.name) {
468 return (existing.source_id == source_id).then(|| existing.manifest.clone());
469 }
470 state.tools.insert(
471 manifest.name.clone(),
472 ToolStateEntry {
473 manifest: manifest.clone(),
474 source_id,
475 },
476 );
477 state.generation += 1;
478 return Some(manifest);
479 }
480 None
481 }
482
483 fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
484 let source_id = self.resolve_manifest(name).and_then(|_| {
485 let state = self
486 .state
487 .read()
488 .expect("tool registry state lock poisoned");
489 state.tools.get(name).map(|entry| entry.source_id.clone())
490 })?;
491 self.sources
492 .read()
493 .expect("tool source lock poisoned")
494 .get(&source_id)?
495 .resolve_contract(name)
496 }
497
498 async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
499 let name = call.name;
500 let source_id = self.resolve_manifest(name).and_then(|_| {
501 let state = self
502 .state
503 .read()
504 .expect("tool registry state lock poisoned");
505 state.tools.get(name).map(|entry| entry.source_id.clone())
506 });
507 let Some(source_id) = source_id else {
508 return ToolResult::err_fmt(format_args!("Unknown tool: {name}"));
509 };
510 let source = {
511 self.sources
512 .read()
513 .expect("tool source lock poisoned")
514 .get(&source_id)
515 .cloned()
516 };
517 let Some(source) = source else {
518 return ToolResult::err_fmt(format_args!("Tool source missing for tool `{name}`"));
519 };
520 source
521 .execute(name, call.args, call.context, call.progress)
522 .await
523 }
524}
525
526fn validate_unique_manifests(manifests: &[ToolManifest]) -> Result<(), ReconfigureError> {
527 let mut names = BTreeSet::new();
528 for manifest in manifests {
529 if manifest.name.trim().is_empty() {
530 return Err(ReconfigureError::Validation(
531 "tool name cannot be empty".to_string(),
532 ));
533 }
534 if !names.insert(manifest.name.clone()) {
535 return Err(ReconfigureError::Validation(format!(
536 "duplicate tool name `{}` in source",
537 manifest.name
538 )));
539 }
540 }
541 Ok(())
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547 use crate::ToolDefinition;
548 use serde_json::json;
549 use std::sync::atomic::{AtomicUsize, Ordering};
550
551 struct MockTool;
552 struct MixedEnabledTool;
553 struct ExternalMockSource;
554 struct ExactResolvingSource {
555 manifest_resolutions: Arc<AtomicUsize>,
556 contract_resolutions: Arc<AtomicUsize>,
557 executions: Arc<AtomicUsize>,
558 }
559
560 fn test_tool(
561 name: &str,
562 description: &str,
563 availability: crate::ToolAvailabilityConfig,
564 ) -> ToolDefinition {
565 ToolDefinition::raw(
566 name,
567 description,
568 ToolDefinition::default_input_schema(),
569 json!({ "type": "string" }),
570 )
571 .with_availability(availability)
572 }
573
574 fn manifests(definitions: Vec<ToolDefinition>) -> Vec<ToolManifest> {
575 definitions
576 .into_iter()
577 .map(|tool| tool.manifest())
578 .collect()
579 }
580
581 fn contract_from(definitions: Vec<ToolDefinition>, name: &str) -> Option<Arc<ToolContract>> {
582 definitions
583 .into_iter()
584 .find(|tool| tool.name == name)
585 .map(|tool| Arc::new(tool.contract()))
586 }
587
588 #[async_trait::async_trait]
589 impl ToolProvider for MockTool {
590 fn tool_manifests(&self) -> Vec<ToolManifest> {
591 manifests(vec![test_tool(
592 "mock_tool",
593 "mock",
594 crate::ToolAvailabilityConfig::callable(),
595 )])
596 }
597
598 fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
599 contract_from(
600 vec![test_tool(
601 "mock_tool",
602 "mock",
603 crate::ToolAvailabilityConfig::callable(),
604 )],
605 name,
606 )
607 }
608
609 async fn execute(&self, _call: ToolCall<'_>) -> ToolResult {
610 ToolResult::ok(serde_json::json!("ok"))
611 }
612 }
613
614 #[async_trait::async_trait]
615 impl ToolProvider for MixedEnabledTool {
616 fn tool_manifests(&self) -> Vec<ToolManifest> {
617 manifests(vec![
618 test_tool(
619 "enabled_tool",
620 "enabled",
621 crate::ToolAvailabilityConfig::callable(),
622 ),
623 test_tool(
624 "disabled_tool",
625 "disabled",
626 crate::ToolAvailabilityConfig::off(),
627 ),
628 ])
629 }
630
631 fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
632 contract_from(
633 vec![
634 test_tool(
635 "enabled_tool",
636 "enabled",
637 crate::ToolAvailabilityConfig::callable(),
638 ),
639 test_tool(
640 "disabled_tool",
641 "disabled",
642 crate::ToolAvailabilityConfig::off(),
643 ),
644 ],
645 name,
646 )
647 }
648
649 async fn execute(&self, _call: ToolCall<'_>) -> ToolResult {
650 ToolResult::ok(serde_json::json!("ok"))
651 }
652 }
653
654 #[async_trait::async_trait]
655 impl ToolSourceExecutor for ExternalMockSource {
656 fn id(&self) -> &str {
657 "external"
658 }
659
660 fn advertised_tools(&self) -> Vec<ToolManifest> {
661 manifests(vec![ToolDefinition::raw(
662 "mcp__demo__search",
663 "search",
664 json!({
665 "type": "object",
666 "properties": {
667 "query": { "type": "string" }
668 },
669 "required": ["query"],
670 "additionalProperties": false
671 }),
672 json!({ "type": "object", "additionalProperties": true }),
673 )])
674 }
675
676 fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
677 contract_from(
678 vec![ToolDefinition::raw(
679 "mcp__demo__search",
680 "search",
681 json!({
682 "type": "object",
683 "properties": {
684 "query": { "type": "string" }
685 },
686 "required": ["query"],
687 "additionalProperties": false
688 }),
689 json!({ "type": "object", "additionalProperties": true }),
690 )],
691 name,
692 )
693 }
694
695 async fn execute(
696 &self,
697 tool: &str,
698 args: &serde_json::Value,
699 _context: &ToolContext,
700 _progress: Option<&ProgressSender>,
701 ) -> ToolResult {
702 ToolResult::ok(json!({
703 "tool": tool,
704 "args": args
705 }))
706 }
707 }
708
709 #[async_trait::async_trait]
710 impl ToolSourceExecutor for ExactResolvingSource {
711 fn id(&self) -> &str {
712 "exact"
713 }
714
715 fn advertised_tools(&self) -> Vec<ToolManifest> {
716 Vec::new()
717 }
718
719 fn resolve_manifest(&self, name: &str) -> Option<ToolManifest> {
720 self.manifest_resolutions.fetch_add(1, Ordering::SeqCst);
721 (name == "host_only").then(|| {
722 test_tool(
723 "host_only",
724 "host-only",
725 crate::ToolAvailabilityConfig::callable(),
726 )
727 .manifest()
728 })
729 }
730
731 fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
732 self.contract_resolutions.fetch_add(1, Ordering::SeqCst);
733 contract_from(
734 vec![test_tool(
735 "host_only",
736 "host-only",
737 crate::ToolAvailabilityConfig::callable(),
738 )],
739 name,
740 )
741 }
742
743 async fn execute(
744 &self,
745 tool: &str,
746 _args: &serde_json::Value,
747 _context: &ToolContext,
748 _progress: Option<&ProgressSender>,
749 ) -> ToolResult {
750 self.executions.fetch_add(1, Ordering::SeqCst);
751 ToolResult::ok(json!(tool))
752 }
753 }
754
755 #[test]
756 fn registry_preserves_initial_availability_state() {
757 let registry =
758 ToolRegistry::from_tool_provider(Arc::new(MixedEnabledTool)).expect("registry");
759 let snapshot = registry.export_state();
760 assert_eq!(
761 snapshot
762 .get("enabled_tool")
763 .unwrap()
764 .manifest()
765 .effective_availability(&crate::ExecutionMode::standard()),
766 crate::ToolAvailability::Callable
767 );
768 assert_eq!(
769 snapshot
770 .get("disabled_tool")
771 .unwrap()
772 .manifest()
773 .effective_availability(&crate::ExecutionMode::standard()),
774 crate::ToolAvailability::Off
775 );
776 }
777
778 #[test]
779 fn apply_state_rejects_tools_not_advertised_by_source() {
780 let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
781 let mut snapshot = registry.export_state();
782 snapshot.tools.insert(
783 "missing".to_string(),
784 ToolStateEntry {
785 manifest: test_tool(
786 "missing",
787 "missing",
788 crate::ToolAvailabilityConfig::callable(),
789 )
790 .manifest(),
791 source_id: PLUGIN_SOURCE_ID.to_string(),
792 },
793 );
794 assert!(matches!(
795 registry.apply_state(snapshot),
796 Err(ReconfigureError::Validation(_))
797 ));
798 }
799
800 #[test]
801 fn advertised_manifest_resolves_without_exact_host_lookup() {
802 let manifest_resolutions = Arc::new(AtomicUsize::new(0));
803 let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
804 registry
805 .upsert_source(Arc::new(ExactResolvingSource {
806 manifest_resolutions: Arc::clone(&manifest_resolutions),
807 contract_resolutions: Arc::new(AtomicUsize::new(0)),
808 executions: Arc::new(AtomicUsize::new(0)),
809 }))
810 .expect("source registered");
811
812 assert_eq!(
813 registry
814 .resolve_manifest("mock_tool")
815 .map(|manifest| manifest.name),
816 Some("mock_tool".to_string())
817 );
818 assert_eq!(manifest_resolutions.load(Ordering::SeqCst), 0);
819 }
820
821 #[tokio::test]
822 async fn unknown_manifest_exact_resolves_and_routes_to_owner() {
823 let manifest_resolutions = Arc::new(AtomicUsize::new(0));
824 let contract_resolutions = Arc::new(AtomicUsize::new(0));
825 let executions = Arc::new(AtomicUsize::new(0));
826 let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
827 registry
828 .upsert_source(Arc::new(ExactResolvingSource {
829 manifest_resolutions: Arc::clone(&manifest_resolutions),
830 contract_resolutions: Arc::clone(&contract_resolutions),
831 executions: Arc::clone(&executions),
832 }))
833 .expect("source registered");
834
835 assert_eq!(
836 registry
837 .resolve_manifest("host_only")
838 .map(|manifest| manifest.name),
839 Some("host_only".to_string())
840 );
841 assert_eq!(manifest_resolutions.load(Ordering::SeqCst), 1);
842
843 let contract = registry.resolve_contract("host_only");
844 assert!(contract.is_some());
845 assert_eq!(manifest_resolutions.load(Ordering::SeqCst), 1);
846 assert_eq!(contract_resolutions.load(Ordering::SeqCst), 1);
847
848 let context = crate::ToolContext::new(
849 "registry-test".to_string(),
850 Arc::new(crate::testing::MockSessionManager::default()),
851 crate::TurnContext::default(),
852 Arc::new(crate::InMemoryAttachmentStore::new()),
853 None,
854 );
855 let args = json!({});
856 let result = registry
857 .execute(crate::ToolCall {
858 name: "host_only",
859 args: &args,
860 context: &context,
861 progress: None,
862 })
863 .await;
864 assert!(result.is_success());
865 assert_eq!(result.value_for_projection(), json!("host_only"));
866 assert_eq!(executions.load(Ordering::SeqCst), 1);
867 }
868
869 #[test]
870 fn unknown_manifest_without_host_resolver_is_unavailable() {
871 let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
872
873 assert!(registry.resolve_manifest("missing").is_none());
874 assert!(registry.resolve_contract("missing").is_none());
875 }
876
877 #[tokio::test]
878 async fn upsert_source_registers_and_executes_external_tools() {
879 let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
880 registry
881 .upsert_source(Arc::new(ExternalMockSource))
882 .expect("source registered");
883
884 let defs = registry.tool_manifests();
885 assert!(defs.iter().any(|def| def.name == "mcp__demo__search"));
886
887 let context = crate::ToolContext::new(
888 "registry-test".to_string(),
889 Arc::new(crate::testing::MockSessionManager::default()),
890 crate::TurnContext::default(),
891 Arc::new(crate::InMemoryAttachmentStore::new()),
892 None,
893 );
894 let args = json!({ "query": "hello" });
895 let result = registry
896 .execute(crate::ToolCall {
897 name: "mcp__demo__search",
898 args: &args,
899 context: &context,
900 progress: None,
901 })
902 .await;
903 assert!(result.is_success());
904 assert_eq!(
905 result.value_for_projection()["tool"],
906 json!("mcp__demo__search")
907 );
908 assert_eq!(
909 result.value_for_projection()["args"]["query"],
910 json!("hello")
911 );
912 }
913
914 #[test]
915 fn upsert_source_preserves_availability_override_on_refresh() {
916 let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
917 registry
918 .upsert_source(Arc::new(ExternalMockSource))
919 .expect("source registered");
920 let mut snapshot = registry.export_state();
921 snapshot
922 .set_availability("mcp__demo__search", Some(crate::ToolAvailability::Off))
923 .unwrap();
924 registry.apply_state(snapshot).unwrap();
925 registry
926 .upsert_source(Arc::new(ExternalMockSource))
927 .expect("source refreshed");
928 let snapshot = registry.export_state();
929 assert_eq!(
930 snapshot
931 .get("mcp__demo__search")
932 .unwrap()
933 .manifest()
934 .effective_availability(&crate::ExecutionMode::standard()),
935 crate::ToolAvailability::Off
936 );
937 }
938
939 #[test]
940 fn remove_source_removes_all_source_tools() {
941 let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
942 registry
943 .upsert_source(Arc::new(ExternalMockSource))
944 .expect("source registered");
945 registry
946 .remove_source_id("external")
947 .expect("source removed");
948 let defs = registry.tool_manifests();
949 assert!(!defs.iter().any(|def| def.name == "mcp__demo__search"));
950 }
951
952 #[test]
953 fn project_tool_catalog_keeps_searchable_tools_with_surface_metadata() {
954 fn dummy_tool(name: &str) -> crate::ToolDefinition {
955 crate::ToolDefinition::raw(
956 name,
957 format!("desc for {name}"),
958 crate::ToolDefinition::default_input_schema(),
959 serde_json::json!({}),
960 )
961 }
962 let catalog = project_tool_catalog([
963 crate::ToolSurfaceEntry {
964 manifest: dummy_tool("read_file").manifest(),
965 availability: crate::ToolAvailability::Showcased,
966 },
967 crate::ToolSurfaceEntry {
968 manifest: dummy_tool("search_tools").manifest(),
969 availability: crate::ToolAvailability::Callable,
970 },
971 ]);
972 assert_eq!(catalog.len(), 2);
973 assert_eq!(catalog[0]["name"], serde_json::json!("read_file"));
974 assert!(catalog[0].get("signature").is_none());
975 assert_eq!(catalog[0]["showcased"], serde_json::json!(true));
976 assert_eq!(catalog[1]["callable"], serde_json::json!(true));
977 }
978
979 #[test]
980 fn project_tool_catalog_preserves_dynamic_output_contracts() {
981 fn dummy_tool(name: &str) -> crate::ToolDefinition {
982 crate::ToolDefinition::raw(
983 name,
984 format!("desc for {name}"),
985 crate::ToolDefinition::default_input_schema(),
986 serde_json::json!({}),
987 )
988 }
989 let catalog = project_tool_catalog([crate::ToolSurfaceEntry {
990 manifest: dummy_tool("llm_query")
991 .with_output_from_input_schema(
992 "output",
993 Some(serde_json::json!({ "type": "string" })),
994 )
995 .manifest(),
996 availability: crate::ToolAvailability::Searchable,
997 }]);
998
999 assert!(catalog[0].get("output_contract").is_none());
1000 }
1001}
1002
1003pub(crate) fn project_tool_catalog<I>(entries: I) -> Vec<serde_json::Value>
1004where
1005 I: IntoIterator<Item = crate::ToolSurfaceEntry>,
1006{
1007 entries
1008 .into_iter()
1009 .filter(|entry| entry.availability.is_searchable())
1010 .map(|entry| {
1011 let manifest = entry.manifest;
1012 let availability = entry.availability;
1013 let projected = serde_json::json!({
1014 "name": manifest.name,
1015 "namespace": manifest.discovery.namespace,
1016 "description": manifest.description,
1017 "aliases": manifest.discovery.aliases,
1018 "availability": availability,
1019 "callable": availability.is_callable(),
1020 "showcased": availability.is_showcased(),
1021 "searchable": availability.is_searchable(),
1022 "activation": manifest.activation,
1023 });
1024 projected
1025 })
1026 .collect()
1027}