Skip to main content

lash_core/
tool_registry.rs

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}