Skip to main content

aether_project/
agent_catalog.rs

1use crate::aether_settings::{project_settings_exist, user_settings_exist};
2use crate::error::SettingsError;
3use crate::{AetherSettings, AgentConfig, McpSourceSpec};
4use aether_core::agent_spec::{AgentSpec, AgentSpecExposure};
5use aether_core::core::{AgentRegistry, Prompt};
6use aether_core::mcp::McpConfig;
7use llm::catalog::{LlmModel, ModelSpec, ModelSpecError};
8use llm::{ProviderConnectionOverrides, ReasoningEffort};
9use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11
12/// A resolved catalog of agents from a project.
13#[derive(Debug, Clone)]
14pub struct AgentCatalog {
15    project_root: PathBuf,
16    registry: AgentRegistry,
17    selected_agent: Option<String>,
18    provider_connections: ProviderConnectionOverrides,
19}
20
21impl AgentCatalog {
22    pub fn load_default(project_root: &Path) -> Result<Option<Self>, SettingsError> {
23        let has_default_settings = user_settings_exist() || project_settings_exist(project_root);
24        let settings = AetherSettings::load_default(project_root)?;
25        if settings.agents.is_empty() && !has_default_settings {
26            return Ok(None);
27        }
28
29        let catalog = Self::from_settings(project_root, settings)?;
30        if catalog.user_invocable().next().is_none() {
31            return Err(SettingsError::NoUserInvocableAgents);
32        }
33
34        Ok(Some(catalog))
35    }
36
37    pub fn from_settings_or_empty(project_root: &Path, settings: AetherSettings) -> Result<Self, SettingsError> {
38        if settings.agents.is_empty() {
39            return Ok(Self::with_defaults(project_root.to_path_buf(), Vec::new(), None, settings.providers));
40        }
41        Self::from_settings(project_root, settings)
42    }
43
44    pub fn from_settings(project_root: &Path, settings: AetherSettings) -> Result<Self, SettingsError> {
45        validate_selected_agent(&settings)?;
46        let selected_agent =
47            settings.agent.as_deref().map(str::trim).filter(|name| !name.is_empty()).map(str::to_string);
48        let provider_connections = settings.providers.clone();
49        let defaults = AgentDefaults { prompts: settings.prompts, mcps: settings.mcps, providers: settings.providers };
50        let mut seen_names = HashSet::new();
51        let mut specs = Vec::with_capacity(settings.agents.len());
52        for (index, entry) in settings.agents.into_iter().enumerate() {
53            specs.push(resolve_agent_entry(project_root, entry, &defaults, index, &mut seen_names)?);
54        }
55
56        Ok(Self::with_defaults(project_root.to_path_buf(), specs, selected_agent, provider_connections))
57    }
58
59    pub fn new(project_root: PathBuf, specs: Vec<AgentSpec>, selected_agent: Option<String>) -> Self {
60        Self::with_defaults(project_root, specs, selected_agent, ProviderConnectionOverrides::default())
61    }
62
63    #[must_use]
64    pub fn with_provider_connections(mut self, overrides: ProviderConnectionOverrides) -> Self {
65        if overrides.is_empty() {
66            return self;
67        }
68
69        let specs = self
70            .registry
71            .all()
72            .iter()
73            .cloned()
74            .map(|mut spec| {
75                spec.provider_connections.merge(overrides.clone());
76                spec
77            })
78            .collect();
79
80        self.registry = AgentRegistry::new(specs);
81        self.provider_connections.merge(overrides);
82        self
83    }
84
85    /// Create an empty catalog for a project with no settings.
86    pub fn empty(project_root: PathBuf) -> Self {
87        Self::new(project_root, Vec::new(), None)
88    }
89
90    /// The project root directory.
91    pub fn project_root(&self) -> &Path {
92        &self.project_root
93    }
94
95    /// Get all agent specs in the catalog.
96    pub fn all(&self) -> &[AgentSpec] {
97        self.registry.all()
98    }
99
100    pub fn selected_agent(&self) -> Option<&str> {
101        self.selected_agent.as_deref()
102    }
103
104    pub fn default_agent(&self) -> Option<&AgentSpec> {
105        self.selected_agent.as_deref().and_then(|name| self.registry.get(name)).or_else(|| self.user_invocable().next())
106    }
107
108    /// Get a specific agent by name.
109    pub fn get(&self, name: &str) -> Result<&AgentSpec, SettingsError> {
110        self.registry.get(name).ok_or_else(|| SettingsError::AgentNotFound { name: name.to_string() })
111    }
112
113    /// Iterate over user-invocable agents.
114    pub fn user_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
115        self.registry.all().iter().filter(|s| s.exposure.user_invocable)
116    }
117
118    /// Iterate over agent-invocable agents.
119    pub fn agent_invocable(&self) -> impl Iterator<Item = &AgentSpec> {
120        self.registry.agent_invocable()
121    }
122
123    pub fn registry(&self) -> &AgentRegistry {
124        &self.registry
125    }
126
127    pub fn default_spec(&self, model: &LlmModel, reasoning_effort: Option<ReasoningEffort>) -> AgentSpec {
128        let mut spec = AgentSpec::bare(model, reasoning_effort, Vec::new());
129        spec.provider_connections.merge(self.provider_connections.clone());
130        spec
131    }
132
133    /// Resolve and return a named agent spec ready for runtime use.
134    pub fn resolve(&self, name: &str) -> Result<AgentSpec, SettingsError> {
135        self.get(name).cloned()
136    }
137}
138
139impl AgentCatalog {
140    fn with_defaults(
141        project_root: PathBuf,
142        specs: Vec<AgentSpec>,
143        selected_agent: Option<String>,
144        provider_connections: ProviderConnectionOverrides,
145    ) -> Self {
146        Self { project_root, registry: AgentRegistry::new(specs), selected_agent, provider_connections }
147    }
148}
149
150struct AgentDefaults {
151    prompts: Vec<crate::PromptSource>,
152    mcps: Vec<McpSourceSpec>,
153    providers: ProviderConnectionOverrides,
154}
155
156fn validate_selected_agent(settings: &AetherSettings) -> Result<(), SettingsError> {
157    if settings.agents.is_empty() {
158        return Err(SettingsError::EmptyAgents);
159    }
160
161    if let Some(agent) = settings.agent.as_deref() {
162        let selector = agent.trim();
163        let Some(entry) = settings.agents.iter().find(|entry| entry.name.trim() == selector) else {
164            return Err(SettingsError::InvalidAgentSelector { name: selector.to_string() });
165        };
166
167        if !entry.user_invocable {
168            return Err(SettingsError::NonUserInvocableAgentSelector { name: selector.to_string() });
169        }
170    }
171
172    Ok(())
173}
174
175fn resolve_agent_entry(
176    project_root: &Path,
177    entry: AgentConfig,
178    defaults: &AgentDefaults,
179    index: usize,
180    seen_names: &mut HashSet<String>,
181) -> Result<AgentSpec, SettingsError> {
182    let name = entry.name.trim().to_string();
183    if name.is_empty() {
184        return Err(SettingsError::EmptyAgentName { index });
185    }
186    if name == "__default__" {
187        return Err(SettingsError::ReservedAgentName { name });
188    }
189    if !seen_names.insert(name.clone()) {
190        return Err(SettingsError::DuplicateAgentName { name });
191    }
192
193    let description = entry.description.trim().to_string();
194    if description.is_empty() {
195        return Err(SettingsError::MissingField { agent: name.clone(), field: "description".to_string() });
196    }
197
198    let model = parse_model(&name, &entry.model)?;
199    model
200        .validate_reasoning_effort(entry.reasoning_effort)
201        .map_err(|source| SettingsError::InvalidReasoningEffort { agent: name.clone(), source })?;
202    if entry.context_window == Some(0) {
203        return Err(SettingsError::InvalidContextWindow { agent: name.clone(), context_window: 0 });
204    }
205    if !entry.user_invocable && !entry.agent_invocable {
206        return Err(SettingsError::NoInvocationSurface { agent: name.clone() });
207    }
208    let prompt_sources = if entry.prompts.is_empty() { &defaults.prompts } else { &entry.prompts };
209    let prompts = Prompt::from_sources(project_root, prompt_sources)
210        .map_err(|source| SettingsError::AgentPromptSource { agent: name.clone(), source })?;
211    if prompts.is_empty() {
212        return Err(if prompt_sources.is_empty() {
213            SettingsError::NoPromptsDeclared { agent: name.clone() }
214        } else {
215            SettingsError::AllOptionalPromptsMissing { agent: name.clone() }
216        });
217    }
218    let mcp_sources = if entry.mcps.is_empty() { &defaults.mcps } else { &entry.mcps };
219    let mcp = load_mcp_config(project_root, mcp_sources)?;
220    let mut provider_connections = defaults.providers.clone();
221    provider_connections.merge(entry.providers);
222
223    Ok(AgentSpec {
224        name,
225        description,
226        model: model.to_string(),
227        reasoning_effort: entry.reasoning_effort,
228        model_settings: entry.model_settings,
229        context_window: entry.context_window,
230        prompts,
231        provider_connections,
232        mcp,
233        exposure: AgentSpecExposure { user_invocable: entry.user_invocable, agent_invocable: entry.agent_invocable },
234        tools: entry.tools,
235    })
236}
237
238fn load_mcp_config(workspace_root: &Path, entries: &[McpSourceSpec]) -> Result<McpConfig, SettingsError> {
239    entries.iter().filter_map(|entry| entry.load(workspace_root).transpose()).collect()
240}
241
242fn parse_model(agent: &str, model: &str) -> Result<ModelSpec, SettingsError> {
243    model.parse().map_err(|error: ModelSpecError| SettingsError::InvalidModel {
244        agent: agent.to_string(),
245        model: model.to_string(),
246        error: error.to_string(),
247    })
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::testing::{DEFAULT_MODEL, agent_json_with, fake_spec, mcp_json, project, settings_agent};
254    use aether_core::agent_spec::AgentSpecExposure;
255    use llm::ModelSettings;
256    use serde_json::json;
257
258    fn create_test_catalog(project_root: PathBuf) -> AgentCatalog {
259        let planner = fake_spec("planner", AgentSpecExposure::both());
260        AgentCatalog::new(project_root, vec![planner], None)
261    }
262
263    fn mcp_servers(spec: &AgentSpec) -> Vec<&str> {
264        spec.mcp.servers.keys().map(String::as_str).collect()
265    }
266
267    fn has_prompt_file(spec: &AgentSpec, expected: &str) -> bool {
268        spec.prompts.iter().any(|prompt| match prompt {
269            Prompt::File { path, .. } => path.ends_with(expected),
270            Prompt::Text(_) | Prompt::McpInstructions(_) => false,
271        })
272    }
273
274    fn overrides(url: &str) -> ProviderConnectionOverrides {
275        ProviderConnectionOverrides::new(std::collections::BTreeMap::from([(
276            "anthropic".to_string(),
277            llm::ProviderConnectionOverride::url(url),
278        )]))
279    }
280
281    fn base_url(spec: &AgentSpec) -> Option<String> {
282        spec.provider_connections.config_for("anthropic").base_url
283    }
284
285    #[test]
286    fn provider_connections_reach_every_agent_not_just_the_selected_one() {
287        let dir = project();
288        let catalog = AgentCatalog::new(
289            dir.root().to_path_buf(),
290            vec![fake_spec("planner", AgentSpecExposure::both()), fake_spec("worker", AgentSpecExposure::agent_only())],
291            None,
292        )
293        .with_provider_connections(overrides("https://runtime.test"));
294
295        for name in ["planner", "worker"] {
296            assert_eq!(base_url(catalog.get(name).unwrap()).as_deref(), Some("https://runtime.test"), "agent {name}");
297        }
298    }
299
300    #[test]
301    fn runtime_provider_connections_win_over_settings_declared_ones() {
302        let dir = project();
303        let mut declared = fake_spec("planner", AgentSpecExposure::both());
304        declared.provider_connections = overrides("https://from-settings.test");
305
306        let catalog = AgentCatalog::new(dir.root().to_path_buf(), vec![declared], None)
307            .with_provider_connections(overrides("https://runtime.test"));
308
309        assert_eq!(base_url(catalog.get("planner").unwrap()).as_deref(), Some("https://runtime.test"));
310    }
311
312    #[test]
313    fn default_spec_inherits_runtime_provider_connections() {
314        let dir = project();
315        let catalog =
316            AgentCatalog::empty(dir.root().to_path_buf()).with_provider_connections(overrides("https://runtime.test"));
317
318        let spec = catalog.default_spec(&DEFAULT_MODEL.parse().unwrap(), None);
319
320        assert_eq!(spec.name, "__default__");
321        assert_eq!(base_url(&spec).as_deref(), Some("https://runtime.test"));
322    }
323
324    #[test]
325    fn settings_provider_connections_apply_to_explicit_model_specs() {
326        let dir = project().file("BASE.md", "Base instructions");
327        let settings = AetherSettings {
328            agents: vec![AgentConfig {
329                prompts: vec![crate::PromptSource::file("BASE.md")],
330                ..settings_agent("planner", "Planner agent")
331            }],
332            providers: overrides("https://settings.test"),
333            ..AetherSettings::default()
334        };
335        let catalog = AgentCatalog::from_settings_or_empty(dir.root(), settings).unwrap();
336
337        let spec = catalog.default_spec(&DEFAULT_MODEL.parse().unwrap(), None);
338
339        assert_eq!(base_url(&spec).as_deref(), Some("https://settings.test"));
340    }
341
342    #[test]
343    fn settings_provider_connections_apply_to_fallback_specs_without_agents() {
344        let dir = project();
345        let settings = AetherSettings { providers: overrides("https://settings.test"), ..AetherSettings::default() };
346        let catalog = AgentCatalog::from_settings_or_empty(dir.root(), settings).unwrap();
347
348        let spec = catalog.default_spec(&DEFAULT_MODEL.parse().unwrap(), None);
349
350        assert_eq!(base_url(&spec).as_deref(), Some("https://settings.test"));
351    }
352
353    #[test]
354    fn runtime_provider_connections_override_settings_for_default_specs() {
355        let dir = project();
356        let settings = AetherSettings { providers: overrides("https://settings.test"), ..AetherSettings::default() };
357        let catalog = AgentCatalog::from_settings_or_empty(dir.root(), settings)
358            .unwrap()
359            .with_provider_connections(overrides("https://runtime.test"));
360
361        let spec = catalog.default_spec(&DEFAULT_MODEL.parse().unwrap(), None);
362
363        assert_eq!(base_url(&spec).as_deref(), Some("https://runtime.test"));
364    }
365
366    #[test]
367    fn default_spec_without_overrides_leaves_connections_unset() {
368        let dir = project();
369        let catalog = AgentCatalog::empty(dir.root().to_path_buf());
370
371        let spec = catalog.default_spec(&DEFAULT_MODEL.parse().unwrap(), None);
372
373        assert_eq!(base_url(&spec), None);
374    }
375
376    #[test]
377    fn user_invocable_filters_correctly() {
378        let dir = project();
379        let root = dir.root().to_path_buf();
380        let catalog = AgentCatalog::new(
381            root,
382            vec![
383                fake_spec("planner", AgentSpecExposure::both()),
384                fake_spec("internal", AgentSpecExposure::agent_only()),
385            ],
386            None,
387        );
388
389        let user_invocable: Vec<_> = catalog.user_invocable().collect();
390        assert_eq!(user_invocable.len(), 1);
391        assert_eq!(user_invocable[0].name, "planner");
392    }
393
394    #[test]
395    fn agent_invocable_filters_correctly() {
396        let dir = project();
397        let root = dir.root().to_path_buf();
398        let catalog = AgentCatalog::new(
399            root,
400            vec![
401                fake_spec("planner", AgentSpecExposure::both()),
402                fake_spec("user-only", AgentSpecExposure::user_only()),
403            ],
404            None,
405        );
406
407        let agent_invocable: Vec<_> = catalog.agent_invocable().collect();
408        assert_eq!(agent_invocable.len(), 1);
409        assert_eq!(agent_invocable[0].name, "planner");
410    }
411
412    #[test]
413    fn default_agent_uses_selected_agent() {
414        let dir = project();
415        let catalog = AgentCatalog::new(
416            dir.root().to_path_buf(),
417            vec![fake_spec("first", AgentSpecExposure::both()), fake_spec("second", AgentSpecExposure::both())],
418            Some("second".to_string()),
419        );
420
421        assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("second"));
422    }
423
424    #[test]
425    fn default_agent_falls_back_to_first_user_invocable() {
426        let dir = project();
427        let catalog = AgentCatalog::new(
428            dir.root().to_path_buf(),
429            vec![
430                fake_spec("internal", AgentSpecExposure::agent_only()),
431                fake_spec("visible", AgentSpecExposure::user_only()),
432            ],
433            None,
434        );
435
436        assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("visible"));
437    }
438
439    #[test]
440    fn get_returns_error_for_missing_agent() {
441        let dir = project();
442        let catalog = create_test_catalog(dir.root().to_path_buf());
443        let result = catalog.get("nonexistent");
444        assert!(matches!(result, Err(SettingsError::AgentNotFound { .. })));
445    }
446
447    #[test]
448    fn agent_rejects_reasoning_effort_unsupported_by_model() {
449        let dir = project().file("BASE.md", "Base instructions");
450        let config = AetherSettings {
451            agents: vec![AgentConfig {
452                model: "anthropic:claude-opus-4-6".to_string(),
453                reasoning_effort: Some(llm::ReasoningEffort::Xhigh),
454                prompts: vec![crate::PromptSource::file("BASE.md")],
455                ..settings_agent("planner", "Planner agent")
456            }],
457            ..AetherSettings::default()
458        };
459
460        let error = AgentCatalog::from_settings(dir.root(), config).unwrap_err();
461
462        assert!(matches!(error, SettingsError::InvalidReasoningEffort { .. }));
463    }
464
465    #[test]
466    fn agent_context_window_is_resolved_into_spec() {
467        let dir = project().file("BASE.md", "Base instructions");
468
469        let config = AetherSettings {
470            agents: vec![AgentConfig {
471                context_window: Some(200_000),
472                prompts: vec![crate::PromptSource::file("BASE.md")],
473                ..settings_agent("planner", "Planner agent")
474            }],
475            ..AetherSettings::default()
476        };
477
478        let catalog = AgentCatalog::from_settings(dir.root(), config).unwrap();
479        let spec = catalog.resolve("planner").unwrap();
480
481        assert_eq!(spec.context_window, Some(200_000));
482    }
483
484    #[test]
485    fn agent_model_settings_resolve_from_config_json() {
486        let dir = project().file("BASE.md", "Base instructions");
487
488        let json = json!({
489            "agents": [agent_json_with(
490                "judge",
491                "Judge agent",
492                json!({
493                    "prompts": ["BASE.md"],
494                    "modelSettings": { "temperature": 0, "topP": 0.9, "maxTokens": 1024 }
495                }),
496            )]
497        });
498
499        let config: AetherSettings = serde_json::from_value(json).unwrap();
500        let catalog = AgentCatalog::from_settings(dir.root(), config).unwrap();
501        let spec = catalog.resolve("judge").unwrap();
502
503        assert_eq!(
504            spec.model_settings,
505            ModelSettings { temperature: Some(0.0), top_p: Some(0.9), max_tokens: Some(1024) }
506        );
507    }
508
509    #[test]
510    fn agent_context_window_rejects_zero() {
511        let config = AetherSettings {
512            agents: vec![AgentConfig { context_window: Some(0), ..settings_agent("planner", "Planner agent") }],
513            ..AetherSettings::default()
514        };
515
516        let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
517
518        assert!(matches!(
519            err,
520            SettingsError::InvalidContextWindow { agent, context_window: 0 } if agent == "planner"
521        ));
522    }
523
524    #[test]
525    fn top_level_prompts_are_inherited_when_agent_prompts_are_empty() {
526        let dir = project().file("BASE.md", "Base instructions");
527
528        let config = AetherSettings {
529            prompts: vec![crate::PromptSource::file("BASE.md")],
530            agents: vec![settings_agent("planner", "Planner agent")],
531            ..AetherSettings::default()
532        };
533
534        let catalog = AgentCatalog::from_settings(dir.root(), config).unwrap();
535        let spec = catalog.resolve("planner").unwrap();
536
537        assert!(has_prompt_file(&spec, "BASE.md"));
538    }
539
540    #[test]
541    fn agent_prompts_override_top_level_prompts() {
542        let dir = project().file("BASE.md", "Base instructions").file("AGENT.md", "Agent instructions");
543
544        let config = AetherSettings {
545            prompts: vec![crate::PromptSource::file("BASE.md")],
546            agents: vec![AgentConfig {
547                prompts: vec![crate::PromptSource::file("AGENT.md")],
548                ..settings_agent("planner", "Planner agent")
549            }],
550            ..AetherSettings::default()
551        };
552
553        let catalog = AgentCatalog::from_settings(dir.root(), config).unwrap();
554        let spec = catalog.resolve("planner").unwrap();
555
556        assert!(has_prompt_file(&spec, "AGENT.md"));
557        assert!(!has_prompt_file(&spec, "BASE.md"));
558    }
559
560    #[test]
561    fn top_level_mcps_are_inherited_when_agent_mcps_are_empty() {
562        let dir = project().file("BASE.md", "Base instructions").file("base-mcp.json", &mcp_json("base"));
563
564        let config = AetherSettings {
565            prompts: vec![crate::PromptSource::file("BASE.md")],
566            mcps: vec![McpSourceSpec::file("base-mcp.json")],
567            agents: vec![settings_agent("planner", "Planner agent")],
568            ..AetherSettings::default()
569        };
570
571        let catalog = AgentCatalog::from_settings(dir.root(), config).unwrap();
572        let spec = catalog.resolve("planner").unwrap();
573
574        assert_eq!(mcp_servers(&spec), ["base"]);
575    }
576
577    #[test]
578    fn agent_mcps_override_top_level_mcps() {
579        let dir = project()
580            .file("BASE.md", "Base instructions")
581            .file("base-mcp.json", &mcp_json("base"))
582            .file("agent-mcp.json", &mcp_json("agent"));
583
584        let config = AetherSettings {
585            prompts: vec![crate::PromptSource::file("BASE.md")],
586            mcps: vec![McpSourceSpec::file("base-mcp.json")],
587            agents: vec![AgentConfig {
588                mcps: vec![McpSourceSpec::file("agent-mcp.json")],
589                ..settings_agent("planner", "Planner agent")
590            }],
591            ..AetherSettings::default()
592        };
593
594        let catalog = AgentCatalog::from_settings(dir.root(), config).unwrap();
595        let spec = catalog.resolve("planner").unwrap();
596
597        assert_eq!(mcp_servers(&spec), ["agent"]);
598    }
599
600    #[test]
601    fn unreadable_mcp_config_names_its_path() {
602        let dir = project().file("BASE.md", "Base instructions").file("broken.json", "not json");
603        let config = AetherSettings {
604            prompts: vec![crate::PromptSource::file("BASE.md")],
605            mcps: vec![McpSourceSpec::file("broken.json")],
606            agents: vec![settings_agent("planner", "Planner agent")],
607            ..AetherSettings::default()
608        };
609
610        let error = AgentCatalog::from_settings(dir.root(), config).unwrap_err();
611
612        assert!(matches!(error, SettingsError::McpConfig { ref path, .. } if path == "broken.json"), "{error}");
613    }
614
615    #[test]
616    fn missing_top_level_and_agent_prompts_still_errors() {
617        let config =
618            AetherSettings { agents: vec![settings_agent("planner", "Planner agent")], ..AetherSettings::default() };
619
620        let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
621
622        assert!(matches!(err, SettingsError::NoPromptsDeclared { agent } if agent == "planner"));
623    }
624
625    #[test]
626    fn resolve_missing_agent_returns_error() {
627        let dir = project();
628        let catalog = create_test_catalog(dir.root().to_path_buf());
629        let result = catalog.resolve("missing");
630        assert!(matches!(result, Err(SettingsError::AgentNotFound { .. })));
631    }
632
633    #[test]
634    fn resolve_preserves_agent_mcp() {
635        let dir = project();
636        let mut planner = fake_spec("planner", AgentSpecExposure::both());
637        planner.mcp = McpConfig::from_json(&mcp_json("agent")).unwrap();
638
639        let catalog = AgentCatalog::new(dir.root().to_path_buf(), vec![planner], None);
640
641        let spec = catalog.resolve("planner").unwrap();
642        assert_eq!(mcp_servers(&spec), ["agent"]);
643    }
644
645    #[test]
646    fn resolve_no_mcp_config_is_valid() {
647        let dir = project();
648        let catalog =
649            AgentCatalog::new(dir.root().to_path_buf(), vec![fake_spec("planner", AgentSpecExposure::both())], None);
650
651        let spec = catalog.resolve("planner").unwrap();
652        assert!(spec.mcp.is_empty());
653    }
654}