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