Skip to main content

aether_project/
mcp_source_spec.rs

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