aether_project/
mcp_source_spec.rs1use 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}