Skip to main content

aether_project/
mcp_source_spec.rs

1use crate::SettingsError;
2use aether_core::mcp::{McpConfig, McpServerConfig};
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6use utils::variables::VarError;
7use utils::{PathOrObject, ResourcePath, is_false, string_or_object_schema};
8
9#[derive(Debug, Clone, PartialEq)]
10pub enum McpSourceSpec {
11    File(McpFileSpec),
12    Inline(McpConfig),
13}
14
15#[derive(Debug, Clone, PartialEq)]
16pub struct McpFileSpec {
17    pub path: ResourcePath,
18    pub defer_tools: bool,
19    pub optional: bool,
20}
21
22impl McpFileSpec {
23    pub fn new(path: impl Into<ResourcePath>) -> Self {
24        Self { path: path.into(), defer_tools: false, optional: false }
25    }
26
27    #[must_use]
28    pub fn defer_tools(mut self) -> Self {
29        self.defer_tools = true;
30        self
31    }
32
33    #[must_use]
34    pub fn optional(mut self) -> Self {
35        self.optional = true;
36        self
37    }
38
39    pub fn load(&self, root: &Path) -> Result<Option<McpConfig>, SettingsError> {
40        let Some(path) = self.resolve(root)? else { return Ok(None) };
41        let mut config = McpConfig::from_json_file(&path)
42            .map_err(|source| SettingsError::McpConfig { path: self.path.as_authored().to_string(), source })?;
43        if self.defer_tools {
44            config.defer_all_tools();
45        }
46        Ok(Some(config))
47    }
48
49    fn resolve(&self, root: &Path) -> Result<Option<PathBuf>, SettingsError> {
50        let full_path = match self.path.resolve(root) {
51            Ok(full_path) => full_path,
52            Err(VarError::NotFound(variable)) if self.optional => {
53                tracing::warn!(
54                    "Skipping optional MCP config '{}': variable '{variable}' is not defined",
55                    self.path.as_authored()
56                );
57                return Ok(None);
58            }
59            Err(VarError::NotFound(variable)) => {
60                return Err(SettingsError::UnresolvedMcpConfigVariable {
61                    path: self.path.as_authored().to_string(),
62                    variable,
63                });
64            }
65        };
66
67        if full_path.is_file() {
68            Ok(Some(full_path))
69        } else if self.optional {
70            Ok(None)
71        } else {
72            Err(SettingsError::InvalidMcpConfigPath { path: self.path.as_authored().to_string() })
73        }
74    }
75}
76
77impl McpSourceSpec {
78    pub fn file(path: impl Into<ResourcePath>) -> Self {
79        Self::File(McpFileSpec::new(path))
80    }
81
82    pub fn path(&self) -> Option<&str> {
83        match self {
84            Self::File(file) => Some(file.path.as_authored()),
85            Self::Inline(_) => None,
86        }
87    }
88
89    pub fn load(&self, root: &Path) -> Result<Option<McpConfig>, SettingsError> {
90        match self {
91            Self::File(file) => file.load(root),
92            Self::Inline(config) => Ok(Some(config.clone())),
93        }
94    }
95}
96
97impl From<McpFileSpec> for McpSourceSpec {
98    fn from(spec: McpFileSpec) -> Self {
99        Self::File(spec)
100    }
101}
102
103impl<'de> Deserialize<'de> for McpSourceSpec {
104    fn deserialize<T: Deserializer<'de>>(deserializer: T) -> Result<Self, T::Error> {
105        Ok(match PathOrObject::<McpSourceSpecObject>::deserialize(deserializer)? {
106            PathOrObject::Path(path) => Self::File(McpFileSpec::new(path)),
107            PathOrObject::Object(McpSourceSpecObject::File { path, defer_tools, optional }) => {
108                Self::File(McpFileSpec { path, defer_tools, optional })
109            }
110            PathOrObject::Object(McpSourceSpecObject::Inline { servers }) => Self::Inline(McpConfig::new(servers)),
111        })
112    }
113}
114
115impl Serialize for McpSourceSpec {
116    fn serialize<T: Serializer>(&self, serializer: T) -> Result<T::Ok, T::Error> {
117        match self {
118            Self::File(McpFileSpec { path, defer_tools: false, optional: false }) => path.serialize(serializer),
119            Self::File(McpFileSpec { path, defer_tools, optional }) => Serialize::serialize(
120                &McpSourceSpecObject::File { path: path.clone(), defer_tools: *defer_tools, optional: *optional },
121                serializer,
122            ),
123            Self::Inline(config) => {
124                Serialize::serialize(&McpSourceSpecObject::Inline { servers: config.servers.clone() }, serializer)
125            }
126        }
127    }
128}
129
130impl schemars::JsonSchema for McpSourceSpec {
131    fn schema_name() -> std::borrow::Cow<'static, str> {
132        "McpSourceSpec".into()
133    }
134
135    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
136        string_or_object_schema(
137            include_str!("docs/mcp_source_spec.md"),
138            &generator.subschema_for::<McpSourceSpecObject>().to_value(),
139        )
140    }
141}
142
143#[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)]
144#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
145enum McpSourceSpecObject {
146    File {
147        path: ResourcePath,
148        #[serde(rename = "deferTools", default, skip_serializing_if = "is_false")]
149        defer_tools: bool,
150        #[serde(default, skip_serializing_if = "is_false")]
151        optional: bool,
152    },
153    Inline {
154        servers: BTreeMap<String, McpServerConfig>,
155    },
156}