Skip to main content

soma_mcp_client/
config.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use super::ConfigError;
6use crate::process::guard::SpawnGuard;
7use crate::process::stdio::StdioProcessSpec;
8use crate::security::redact::{is_sensitive_key, redact_stdio_args, redact_url};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct UpstreamConfig {
12    pub name: String,
13    #[serde(default = "default_true")]
14    pub enabled: bool,
15    #[serde(default)]
16    pub url: Option<String>,
17    #[serde(default)]
18    pub bearer_token_env: Option<String>,
19    #[serde(default)]
20    pub command: Option<String>,
21    #[serde(default)]
22    pub args: Vec<String>,
23    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
24    pub env: BTreeMap<String, String>,
25    #[serde(default)]
26    pub oauth: Option<GatewayUpstreamOauthConfig>,
27    #[serde(default = "default_true")]
28    pub proxy_resources: bool,
29    #[serde(default = "default_true")]
30    pub proxy_prompts: bool,
31    #[serde(default)]
32    pub expose_tools: Option<Vec<String>>,
33    #[serde(default)]
34    pub expose_resources: Option<Vec<String>>,
35    #[serde(default)]
36    pub expose_prompts: Option<Vec<String>>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct UpstreamConfigView {
41    pub name: String,
42    pub enabled: bool,
43    pub url: Option<String>,
44    pub bearer_token_env: Option<String>,
45    pub command: Option<String>,
46    pub args: Vec<String>,
47    pub env_keys: Vec<String>,
48    pub oauth_enabled: bool,
49    pub proxy_resources: bool,
50    pub proxy_prompts: bool,
51    pub expose_tools: Option<Vec<String>>,
52    pub expose_resources: Option<Vec<String>>,
53    pub expose_prompts: Option<Vec<String>>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct GatewayUpstreamOauthConfig {
58    pub mode: GatewayUpstreamOauthMode,
59    #[serde(default)]
60    pub registration: GatewayUpstreamOauthRegistration,
61    #[serde(default)]
62    pub scopes: Option<Vec<String>>,
63    #[serde(default)]
64    pub prefer_client_metadata_document: Option<bool>,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum GatewayUpstreamOauthMode {
70    AuthorizationCodePkce,
71}
72
73#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(tag = "strategy", rename_all = "snake_case")]
75pub enum GatewayUpstreamOauthRegistration {
76    /// Prefer a served Client ID Metadata Document and fall back to DCR only
77    /// when the authorization server does not advertise CIMD support.
78    #[default]
79    Auto,
80    ClientMetadataDocument {
81        url: String,
82    },
83    Preregistered {
84        client_id: String,
85        #[serde(default)]
86        client_secret_env: Option<String>,
87    },
88    Dynamic,
89}
90
91impl Default for UpstreamConfig {
92    fn default() -> Self {
93        Self {
94            name: String::new(),
95            enabled: true,
96            url: None,
97            bearer_token_env: None,
98            command: None,
99            args: Vec::new(),
100            env: BTreeMap::new(),
101            oauth: None,
102            proxy_resources: true,
103            proxy_prompts: true,
104            expose_tools: None,
105            expose_resources: None,
106            expose_prompts: None,
107        }
108    }
109}
110
111impl UpstreamConfig {
112    pub fn validate(&self) -> Result<(), ConfigError> {
113        validate_name(&self.name)?;
114        validate_transport_shape(self)?;
115        if let Some(name) = self.bearer_token_env.as_deref() {
116            validate_bearer_token_env(name)?;
117        }
118        if let Some(url) = self.url.as_deref() {
119            let parsed = url::Url::parse(url)
120                .map_err(|_| ConfigError::invalid("url", "must be a valid URL"))?;
121            match parsed.scheme() {
122                "http" | "https" | "ws" | "wss" => {}
123                _ => {
124                    return Err(ConfigError::invalid(
125                        "url",
126                        "must use http, https, ws, or wss",
127                    ));
128                }
129            }
130        }
131        if let Some(oauth) = &self.oauth {
132            oauth.validate()?;
133            if self.url.is_none() {
134                return Err(ConfigError::invalid("oauth", "requires upstream url"));
135            }
136        }
137        Ok(())
138    }
139
140    #[must_use]
141    pub fn redacted_view(&self) -> UpstreamConfigView {
142        UpstreamConfigView {
143            name: self.name.clone(),
144            enabled: self.enabled,
145            url: self.url.as_deref().map(redact_url),
146            bearer_token_env: self
147                .bearer_token_env
148                .as_ref()
149                .map(|_| "[redacted]".to_owned()),
150            command: self.command.clone(),
151            args: redact_stdio_args(&self.args),
152            env_keys: self
153                .env
154                .keys()
155                .map(|key| {
156                    if is_sensitive_key(key) {
157                        "[redacted]".to_owned()
158                    } else {
159                        key.clone()
160                    }
161                })
162                .collect(),
163            oauth_enabled: self.oauth.is_some(),
164            proxy_resources: self.proxy_resources,
165            proxy_prompts: self.proxy_prompts,
166            expose_tools: self.expose_tools.clone(),
167            expose_resources: self.expose_resources.clone(),
168            expose_prompts: self.expose_prompts.clone(),
169        }
170    }
171}
172
173fn validate_transport_shape(config: &UpstreamConfig) -> Result<(), ConfigError> {
174    let has_url = config
175        .url
176        .as_deref()
177        .map(str::trim)
178        .is_some_and(|url| !url.is_empty());
179    let has_command = config
180        .command
181        .as_deref()
182        .map(str::trim)
183        .is_some_and(|command| !command.is_empty());
184    match (has_url, has_command) {
185        (true, true) => {
186            return Err(ConfigError::invalid(
187                "transport",
188                "must specify either url or command, not both",
189            ));
190        }
191        (false, false) => {
192            return Err(ConfigError::invalid(
193                "transport",
194                "must specify exactly one of url or command",
195            ));
196        }
197        _ => {}
198    }
199    if has_command {
200        let spec = StdioProcessSpec {
201            command: config.command.clone().unwrap_or_default(),
202            args: config.args.clone(),
203            env: config.env.clone(),
204        };
205        spec.validate(&SpawnGuard::default())
206            .map_err(|_| ConfigError::invalid("command", "stdio command is not allowed"))?;
207    }
208    Ok(())
209}
210
211impl GatewayUpstreamOauthConfig {
212    fn validate(&self) -> Result<(), ConfigError> {
213        if let Some(scopes) = &self.scopes
214            && scopes.iter().any(|scope| scope.trim().is_empty())
215        {
216            return Err(ConfigError::invalid(
217                "oauth.scopes",
218                "must not contain blanks",
219            ));
220        }
221        if let GatewayUpstreamOauthRegistration::Preregistered {
222            client_secret_env: Some(env),
223            ..
224        } = &self.registration
225        {
226            validate_bearer_token_env(env)?;
227        }
228        Ok(())
229    }
230}
231
232pub fn default_true() -> bool {
233    true
234}
235
236fn validate_name(name: &str) -> Result<(), ConfigError> {
237    if name.trim().is_empty() {
238        return Err(ConfigError::invalid("name", "must not be empty"));
239    }
240    if !name
241        .chars()
242        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
243    {
244        return Err(ConfigError::invalid(
245            "name",
246            "must contain only ASCII letters, digits, hyphens, underscores, and dots",
247        ));
248    }
249    Ok(())
250}
251
252pub fn validate_bearer_token_env(value: &str) -> Result<(), ConfigError> {
253    let trimmed = value.trim();
254    let looks_like_secret = trimmed.starts_with("Bearer ")
255        || trimmed.starts_with("sk-")
256        || trimmed.starts_with("ghp_")
257        || trimmed.starts_with("github_pat_")
258        || looks_like_jwt(trimmed);
259    if looks_like_secret {
260        return Err(ConfigError::invalid(
261            "bearer_token_env",
262            "must be an environment variable name, not a token value",
263        ));
264    }
265    let mut chars = trimmed.chars();
266    let Some(first) = chars.next() else {
267        return Err(ConfigError::invalid(
268            "bearer_token_env",
269            "must not be empty",
270        ));
271    };
272    if !(first == '_' || first.is_ascii_uppercase()) {
273        return Err(ConfigError::invalid(
274            "bearer_token_env",
275            "must start with an uppercase ASCII letter or underscore",
276        ));
277    }
278    if !chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit()) {
279        return Err(ConfigError::invalid(
280            "bearer_token_env",
281            "must contain only uppercase ASCII letters, digits, and underscores",
282        ));
283    }
284    Ok(())
285}
286
287fn looks_like_jwt(value: &str) -> bool {
288    let parts: Vec<&str> = value.split('.').collect();
289    parts.len() == 3 && parts.iter().all(|part| part.len() >= 8)
290}
291
292#[cfg(test)]
293#[path = "config_tests.rs"]
294mod tests;