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