1use std::{
2 collections::BTreeMap,
3 env, fs, io,
4 path::{Path, PathBuf},
5};
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use thiserror::Error;
10use toml::{value::Table as TomlTable, Value as TomlValue};
11
12use super::{
13 AppRuntime, AppRuntimeLauncher, McpRuntimeServer, McpServerLauncher, StdioServerConfig,
14};
15
16pub const DEFAULT_CONFIG_FILE: &str = "config.toml";
18const MCP_SERVERS_KEY: &str = "mcp_servers";
19const APP_RUNTIMES_KEY: &str = "app_runtimes";
20
21#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
23pub struct McpServerEntry {
24 pub name: String,
25 pub definition: McpServerDefinition,
26}
27
28#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
30pub struct AppRuntimeEntry {
31 pub name: String,
32 pub definition: AppRuntimeDefinition,
33}
34
35#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
37pub struct McpServerDefinition {
38 pub transport: McpTransport,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub description: Option<String>,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub tags: Vec<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub tools: Option<McpToolConfig>,
45}
46
47#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
49#[serde(tag = "transport", rename_all = "snake_case")]
50pub enum McpTransport {
51 Stdio(StdioServerDefinition),
52 StreamableHttp(StreamableHttpDefinition),
53}
54
55#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
57pub struct StdioServerDefinition {
58 pub command: String,
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub args: Vec<String>,
61 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
62 pub env: BTreeMap<String, String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub timeout_ms: Option<u64>,
65}
66
67#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
69pub struct StreamableHttpDefinition {
70 pub url: String,
71 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
72 pub headers: BTreeMap<String, String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub bearer_env_var: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub connect_timeout_ms: Option<u64>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub request_timeout_ms: Option<u64>,
79}
80
81#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
83pub struct McpToolConfig {
84 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub enabled: Vec<String>,
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub disabled: Vec<String>,
88}
89
90#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
92pub struct AppRuntimeDefinition {
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub description: Option<String>,
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub tags: Vec<String>,
97 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
98 pub env: BTreeMap<String, String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub code_home: Option<PathBuf>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub current_dir: Option<PathBuf>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub mirror_stdio: Option<bool>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub startup_timeout_ms: Option<u64>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub binary: Option<PathBuf>,
109 #[serde(default, skip_serializing_if = "Value::is_null")]
110 pub metadata: Value,
111}
112
113#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
115pub struct AddAppRuntimeRequest {
116 pub name: String,
117 pub definition: AppRuntimeDefinition,
118 #[serde(default)]
119 pub overwrite: bool,
120}
121
122#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
124pub struct AddServerRequest {
125 pub name: String,
126 pub definition: McpServerDefinition,
127 #[serde(default)]
128 pub overwrite: bool,
129 #[serde(default)]
130 pub env: BTreeMap<String, String>,
131 #[serde(default)]
132 pub bearer_token: Option<String>,
133}
134
135#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
137pub struct McpLoginResult {
138 pub server: String,
139 pub env_var: Option<String>,
140}
141
142#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
144pub struct McpLogoutResult {
145 pub server: String,
146 pub env_var: Option<String>,
147 pub cleared: bool,
148}
149
150#[derive(Debug, Error)]
152pub enum McpConfigError {
153 #[error("failed to read {path}: {source}")]
154 Read {
155 path: PathBuf,
156 #[source]
157 source: io::Error,
158 },
159 #[error("failed to write {path}: {source}")]
160 Write {
161 path: PathBuf,
162 #[source]
163 source: io::Error,
164 },
165 #[error("failed to create directory {path}: {source}")]
166 CreateDir {
167 path: PathBuf,
168 #[source]
169 source: io::Error,
170 },
171 #[error("failed to parse {path}: {source}")]
172 Parse {
173 path: PathBuf,
174 #[source]
175 source: toml::de::Error,
176 },
177 #[error("config root at {path} must be a table")]
178 InvalidRoot { path: PathBuf },
179 #[error("`mcp_servers` must be a table in {path}")]
180 InvalidServers { path: PathBuf },
181 #[error("failed to decode mcp_servers: {source}")]
182 DecodeServers {
183 #[source]
184 source: toml::de::Error,
185 },
186 #[error("`app_runtimes` must be a table in {path}")]
187 InvalidAppRuntimes { path: PathBuf },
188 #[error("failed to decode app_runtimes: {source}")]
189 DecodeAppRuntimes {
190 #[source]
191 source: toml::de::Error,
192 },
193 #[error("failed to serialize config: {source}")]
194 Serialize {
195 #[source]
196 source: toml::ser::Error,
197 },
198 #[error("server `{0}` already exists")]
199 ServerAlreadyExists(String),
200 #[error("server `{0}` not found")]
201 ServerNotFound(String),
202 #[error("server name may not be empty")]
203 InvalidServerName,
204 #[error("app runtime `{0}` already exists")]
205 AppRuntimeAlreadyExists(String),
206 #[error("app runtime `{0}` not found")]
207 AppRuntimeNotFound(String),
208 #[error("app runtime name may not be empty")]
209 InvalidAppRuntimeName,
210 #[error("invalid env var name `{name}`")]
211 InvalidEnvVarName { name: String },
212 #[error("server `{server}` missing bearer_env_var for auth token")]
213 MissingBearerEnvVar { server: String },
214 #[error("server `{server}` transport does not support login/logout")]
215 UnsupportedAuthTransport { server: String },
216}
217
218pub struct McpConfigManager {
224 config_path: PathBuf,
225}
226
227impl McpConfigManager {
228 pub fn new(config_path: impl Into<PathBuf>) -> Self {
230 Self {
231 config_path: config_path.into(),
232 }
233 }
234
235 pub fn from_code_home(code_home: impl AsRef<Path>) -> Self {
237 Self::new(code_home.as_ref().join(DEFAULT_CONFIG_FILE))
238 }
239
240 pub fn config_path(&self) -> &Path {
242 &self.config_path
243 }
244
245 pub fn list_servers(&self) -> Result<Vec<McpServerEntry>, McpConfigError> {
247 let servers = self.read_servers()?;
248 Ok(servers
249 .into_iter()
250 .map(|(name, definition)| McpServerEntry { name, definition })
251 .collect())
252 }
253
254 pub fn get_server(&self, name: &str) -> Result<McpServerEntry, McpConfigError> {
256 let servers = self.read_servers()?;
257 let Some(definition) = servers.get(name).cloned() else {
258 return Err(McpConfigError::ServerNotFound(name.to_string()));
259 };
260
261 Ok(McpServerEntry {
262 name: name.to_string(),
263 definition,
264 })
265 }
266
267 pub fn list_app_runtimes(&self) -> Result<Vec<AppRuntimeEntry>, McpConfigError> {
269 let runtimes = self.read_app_runtimes()?;
270 Ok(runtimes
271 .into_iter()
272 .map(|(name, definition)| AppRuntimeEntry { name, definition })
273 .collect())
274 }
275
276 pub fn get_app_runtime(&self, name: &str) -> Result<AppRuntimeEntry, McpConfigError> {
278 let runtimes = self.read_app_runtimes()?;
279 let Some(definition) = runtimes.get(name).cloned() else {
280 return Err(McpConfigError::AppRuntimeNotFound(name.to_string()));
281 };
282
283 Ok(AppRuntimeEntry {
284 name: name.to_string(),
285 definition,
286 })
287 }
288
289 pub fn app_runtimes(&self) -> Result<Vec<AppRuntime>, McpConfigError> {
291 Ok(self
292 .list_app_runtimes()?
293 .into_iter()
294 .map(AppRuntime::from)
295 .collect())
296 }
297
298 pub fn app_runtime(&self, name: &str) -> Result<AppRuntime, McpConfigError> {
300 self.get_app_runtime(name).map(AppRuntime::from)
301 }
302
303 pub fn app_runtime_launchers(
305 &self,
306 defaults: &StdioServerConfig,
307 ) -> Result<Vec<AppRuntimeLauncher>, McpConfigError> {
308 self.app_runtimes().map(|runtimes| {
309 runtimes
310 .into_iter()
311 .map(|runtime| runtime.into_launcher(defaults))
312 .collect()
313 })
314 }
315
316 pub fn app_runtime_launcher(
318 &self,
319 name: &str,
320 defaults: &StdioServerConfig,
321 ) -> Result<AppRuntimeLauncher, McpConfigError> {
322 self.app_runtime(name)
323 .map(|runtime| runtime.into_launcher(defaults))
324 }
325
326 pub fn runtime_servers(&self) -> Result<Vec<McpRuntimeServer>, McpConfigError> {
328 Ok(self
329 .list_servers()?
330 .into_iter()
331 .map(McpRuntimeServer::from)
332 .collect())
333 }
334
335 pub fn runtime_server(&self, name: &str) -> Result<McpRuntimeServer, McpConfigError> {
337 self.get_server(name).map(McpRuntimeServer::from)
338 }
339
340 pub fn runtime_launchers(
342 &self,
343 defaults: &StdioServerConfig,
344 ) -> Result<Vec<McpServerLauncher>, McpConfigError> {
345 self.runtime_servers().map(|servers| {
346 servers
347 .into_iter()
348 .map(|server| server.into_launcher(defaults))
349 .collect()
350 })
351 }
352
353 pub fn runtime_launcher(
355 &self,
356 name: &str,
357 defaults: &StdioServerConfig,
358 ) -> Result<McpServerLauncher, McpConfigError> {
359 self.runtime_server(name)
360 .map(|server| server.into_launcher(defaults))
361 }
362
363 pub fn add_app_runtime(
365 &self,
366 request: AddAppRuntimeRequest,
367 ) -> Result<AppRuntimeEntry, McpConfigError> {
368 let AddAppRuntimeRequest {
369 name,
370 definition,
371 overwrite,
372 } = request;
373
374 if name.trim().is_empty() {
375 return Err(McpConfigError::InvalidAppRuntimeName);
376 }
377
378 let (table, mut runtimes) = self.read_table_and_app_runtimes()?;
379 if !overwrite && runtimes.contains_key(&name) {
380 return Err(McpConfigError::AppRuntimeAlreadyExists(name));
381 }
382
383 runtimes.insert(name.clone(), definition.clone());
384 self.persist_app_runtimes(table, &runtimes)?;
385
386 Ok(AppRuntimeEntry { name, definition })
387 }
388
389 pub fn add_server(
391 &self,
392 mut request: AddServerRequest,
393 ) -> Result<McpServerEntry, McpConfigError> {
394 if request.name.trim().is_empty() {
395 return Err(McpConfigError::InvalidServerName);
396 }
397
398 let mut env_injections = request.env.clone();
399 if let Some(token) = request.bearer_token.take() {
400 let var = Self::bearer_env_var(&request.name, &request.definition)?;
401 env_injections.entry(var).or_insert(token);
402 }
403
404 if let McpTransport::Stdio(transport) = &mut request.definition.transport {
405 for (key, value) in &env_injections {
406 transport.env.entry(key.clone()).or_insert(value.clone());
407 }
408 }
409
410 self.set_env_vars(&env_injections)?;
411
412 let (table, mut servers) = self.read_table_and_servers()?;
413 if !request.overwrite && servers.contains_key(&request.name) {
414 return Err(McpConfigError::ServerAlreadyExists(request.name));
415 }
416
417 servers.insert(request.name.clone(), request.definition.clone());
418 self.persist_servers(table, &servers)?;
419
420 Ok(McpServerEntry {
421 name: request.name,
422 definition: request.definition,
423 })
424 }
425
426 pub fn remove_server(&self, name: &str) -> Result<Option<McpServerEntry>, McpConfigError> {
428 let (table, mut servers) = self.read_table_and_servers()?;
429 let removed = servers.remove(name).map(|definition| McpServerEntry {
430 name: name.to_string(),
431 definition,
432 });
433
434 if removed.is_some() {
435 self.persist_servers(table, &servers)?;
436 }
437
438 Ok(removed)
439 }
440
441 pub fn login(
443 &self,
444 name: &str,
445 token: impl AsRef<str>,
446 ) -> Result<McpLoginResult, McpConfigError> {
447 let servers = self.read_servers()?;
448 let definition = servers
449 .get(name)
450 .ok_or_else(|| McpConfigError::ServerNotFound(name.to_string()))?;
451 let env_var = Self::bearer_env_var(name, definition)?;
452 self.validate_env_key(&env_var)?;
453 env::set_var(&env_var, token.as_ref());
454 Ok(McpLoginResult {
455 server: name.to_string(),
456 env_var: Some(env_var),
457 })
458 }
459
460 pub fn logout(&self, name: &str) -> Result<McpLogoutResult, McpConfigError> {
462 let servers = self.read_servers()?;
463 let definition = servers
464 .get(name)
465 .ok_or_else(|| McpConfigError::ServerNotFound(name.to_string()))?;
466 let env_var = Self::bearer_env_var(name, definition)?;
467 let cleared = env::var(&env_var).is_ok();
468 env::remove_var(&env_var);
469 Ok(McpLogoutResult {
470 server: name.to_string(),
471 env_var: Some(env_var),
472 cleared,
473 })
474 }
475
476 fn bearer_env_var(
477 name: &str,
478 definition: &McpServerDefinition,
479 ) -> Result<String, McpConfigError> {
480 match &definition.transport {
481 McpTransport::StreamableHttp(http) => {
482 http.bearer_env_var
483 .clone()
484 .ok_or_else(|| McpConfigError::MissingBearerEnvVar {
485 server: name.to_string(),
486 })
487 }
488 McpTransport::Stdio(_) => Err(McpConfigError::UnsupportedAuthTransport {
489 server: name.to_string(),
490 }),
491 }
492 }
493
494 fn read_servers(&self) -> Result<BTreeMap<String, McpServerDefinition>, McpConfigError> {
495 let table = self.load_table()?;
496 self.parse_servers(table.get(MCP_SERVERS_KEY))
497 }
498
499 fn read_table_and_servers(
500 &self,
501 ) -> Result<(TomlTable, BTreeMap<String, McpServerDefinition>), McpConfigError> {
502 let table = self.load_table()?;
503 let servers = self.parse_servers(table.get(MCP_SERVERS_KEY))?;
504 Ok((table, servers))
505 }
506
507 fn parse_servers(
508 &self,
509 value: Option<&TomlValue>,
510 ) -> Result<BTreeMap<String, McpServerDefinition>, McpConfigError> {
511 let Some(value) = value else {
512 return Ok(BTreeMap::new());
513 };
514
515 let table = value
516 .as_table()
517 .ok_or_else(|| McpConfigError::InvalidServers {
518 path: self.config_path.clone(),
519 })?;
520 let cloned = TomlValue::Table(table.clone());
521 cloned
522 .try_into()
523 .map_err(|source| McpConfigError::DecodeServers { source })
524 }
525
526 fn persist_servers(
527 &self,
528 mut table: TomlTable,
529 servers: &BTreeMap<String, McpServerDefinition>,
530 ) -> Result<(), McpConfigError> {
531 if servers.is_empty() {
532 table.remove(MCP_SERVERS_KEY);
533 } else {
534 let value = TomlValue::try_from(servers.clone())
535 .map_err(|source| McpConfigError::Serialize { source })?;
536 table.insert(MCP_SERVERS_KEY.to_string(), value);
537 }
538
539 self.write_table(table)
540 }
541
542 fn read_app_runtimes(&self) -> Result<BTreeMap<String, AppRuntimeDefinition>, McpConfigError> {
543 let table = self.load_table()?;
544 self.parse_app_runtimes(table.get(APP_RUNTIMES_KEY))
545 }
546
547 fn read_table_and_app_runtimes(
548 &self,
549 ) -> Result<(TomlTable, BTreeMap<String, AppRuntimeDefinition>), McpConfigError> {
550 let table = self.load_table()?;
551 let runtimes = self.parse_app_runtimes(table.get(APP_RUNTIMES_KEY))?;
552 Ok((table, runtimes))
553 }
554
555 fn parse_app_runtimes(
556 &self,
557 value: Option<&TomlValue>,
558 ) -> Result<BTreeMap<String, AppRuntimeDefinition>, McpConfigError> {
559 let Some(value) = value else {
560 return Ok(BTreeMap::new());
561 };
562
563 let table = value
564 .as_table()
565 .ok_or_else(|| McpConfigError::InvalidAppRuntimes {
566 path: self.config_path.clone(),
567 })?;
568 let cloned = TomlValue::Table(table.clone());
569 cloned
570 .try_into()
571 .map_err(|source| McpConfigError::DecodeAppRuntimes { source })
572 }
573
574 fn persist_app_runtimes(
575 &self,
576 mut table: TomlTable,
577 runtimes: &BTreeMap<String, AppRuntimeDefinition>,
578 ) -> Result<(), McpConfigError> {
579 if runtimes.is_empty() {
580 table.remove(APP_RUNTIMES_KEY);
581 } else {
582 let value = TomlValue::try_from(runtimes.clone())
583 .map_err(|source| McpConfigError::Serialize { source })?;
584 table.insert(APP_RUNTIMES_KEY.to_string(), value);
585 }
586
587 self.write_table(table)
588 }
589
590 fn load_table(&self) -> Result<TomlTable, McpConfigError> {
591 if !self.config_path.exists() {
592 return Ok(TomlTable::new());
593 }
594
595 let contents =
596 fs::read_to_string(&self.config_path).map_err(|source| McpConfigError::Read {
597 path: self.config_path.clone(),
598 source,
599 })?;
600
601 if contents.trim().is_empty() {
602 return Ok(TomlTable::new());
603 }
604
605 let value: TomlValue = contents.parse().map_err(|source| McpConfigError::Parse {
606 path: self.config_path.clone(),
607 source,
608 })?;
609
610 value
611 .as_table()
612 .cloned()
613 .ok_or_else(|| McpConfigError::InvalidRoot {
614 path: self.config_path.clone(),
615 })
616 }
617
618 fn write_table(&self, table: TomlTable) -> Result<(), McpConfigError> {
619 if let Some(parent) = self.config_path.parent() {
620 fs::create_dir_all(parent).map_err(|source| McpConfigError::CreateDir {
621 path: parent.to_path_buf(),
622 source,
623 })?;
624 }
625
626 let serialized = toml::to_string_pretty(&TomlValue::Table(table))
627 .map_err(|source| McpConfigError::Serialize { source })?;
628
629 fs::write(&self.config_path, serialized).map_err(|source| McpConfigError::Write {
630 path: self.config_path.clone(),
631 source,
632 })
633 }
634
635 fn set_env_vars(&self, vars: &BTreeMap<String, String>) -> Result<(), McpConfigError> {
636 for (key, value) in vars {
637 self.validate_env_key(key)?;
638 env::set_var(key, value);
639 }
640 Ok(())
641 }
642
643 fn validate_env_key(&self, key: &str) -> Result<(), McpConfigError> {
644 let invalid = key.is_empty() || key.contains('=') || key.contains('\0');
645 if invalid {
646 return Err(McpConfigError::InvalidEnvVarName {
647 name: key.to_string(),
648 });
649 }
650 Ok(())
651 }
652}