1mod connection;
3pub mod descriptors;
4pub mod lease;
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9pub use lease::{McpLeaseCapability, McpRuntimeManager, McpSessionEndGuard};
10
11#[derive(Debug, Clone, serde::Deserialize)]
13pub struct McpServerConfig {
14 pub command: String,
15 #[serde(default)]
16 pub args: Vec<String>,
17 #[serde(default)]
18 pub env: HashMap<String, String>,
19 #[serde(default)]
26 pub shared: bool,
27}
28
29#[derive(Debug, Clone, serde::Deserialize)]
31pub struct McpConfig {
32 #[serde(rename = "mcpServers", default)]
33 pub mcp_servers: HashMap<String, McpServerConfig>,
34}
35
36#[derive(Debug, Clone)]
38pub(crate) struct McpToolDef {
39 pub(crate) name: String,
40 pub(crate) description: String,
41 pub(crate) input_schema: serde_json::Value,
42}
43
44pub const MCP_CONFIG_MAX_BYTES: u64 = 1024 * 1024;
46
47pub fn load_mcp_config() -> Option<McpConfig> {
55 let path = crate::config::resolve_read_path("mcp.json");
56 let content = match descriptors::read_bounded_regular_file(&path, MCP_CONFIG_MAX_BYTES) {
57 Ok(content) => content,
58 Err(descriptors::DescriptorCacheError::NotFound) => return None,
59 Err(err) => {
60 let category = match err {
62 descriptors::DescriptorCacheError::NotFound => "not_found",
63 descriptors::DescriptorCacheError::NotRegularFile => "not_regular_file",
64 descriptors::DescriptorCacheError::Oversize { .. } => "oversize",
65 descriptors::DescriptorCacheError::Io(_) => "io",
66 descriptors::DescriptorCacheError::Parse(_) => "parse",
67 descriptors::DescriptorCacheError::Version(_) => "version",
68 };
69 tracing::warn!(
70 file = "mcp.json",
71 category,
72 "Refusing unsafe MCP config read"
73 );
74 return None;
75 }
76 };
77 let config = match serde_json::from_str::<McpConfig>(&content) {
78 Ok(config) => config,
79 Err(e) => {
80 tracing::warn!(
83 file = "mcp.json",
84 class = ?e.classify(),
85 line = e.line(),
86 column = e.column(),
87 "Failed to parse MCP config"
88 );
89 return None;
90 }
91 };
92 if let Err(reason) = validate_mcp_config(&config) {
93 tracing::warn!(
95 file = "mcp.json",
96 reason,
97 "Refusing structurally unsafe MCP config"
98 );
99 return None;
100 }
101 Some(config)
102}
103
104pub const MCP_CONFIG_MAX_SERVERS: usize = 32;
108pub const MCP_CONFIG_MAX_ARGS: usize = 64;
109pub const MCP_CONFIG_MAX_ENV_ENTRIES: usize = 64;
110pub const MCP_CONFIG_MAX_SERVER_NAME_BYTES: usize = 64;
111pub const MCP_CONFIG_MAX_COMMAND_BYTES: usize = 512;
112pub const MCP_CONFIG_MAX_ARG_BYTES: usize = 4096;
113pub const MCP_CONFIG_MAX_ENV_KEY_BYTES: usize = 128;
114pub const MCP_CONFIG_MAX_ENV_VALUE_BYTES: usize = 4096;
115
116pub fn expand_env_value(value: &str, lookup: impl Fn(&str) -> Option<String>) -> String {
131 expand_env_value_with(value, lookup, |_| {})
132}
133
134pub fn expand_env_value_with(
138 value: &str,
139 lookup: impl Fn(&str) -> Option<String>,
140 mut on_unset: impl FnMut(&str),
141) -> String {
142 let mut out = String::with_capacity(value.len());
143 let mut rest = value;
144 while let Some(start) = rest.find("${") {
145 out.push_str(&rest[..start]);
146 let after = &rest[start + 2..];
147 let Some(end) = after.find('}') else {
148 out.push_str(&rest[start..]);
150 rest = "";
151 break;
152 };
153 let inner = &after[..end];
154 let (name, fallback) = match inner.split_once(":-") {
155 Some((n, f)) => (n, Some(f)),
156 None => (inner, None),
157 };
158 let is_ident = !name.is_empty()
159 && name
160 .chars()
161 .all(|c| c.is_ascii_alphanumeric() || c == '_');
162 if is_ident {
163 match lookup(name) {
164 Some(v) if !(v.is_empty() && fallback.is_some()) => out.push_str(&v),
165 None if fallback.is_none() => on_unset(name),
166 _ => out.push_str(fallback.unwrap_or("")),
167 }
168 } else {
169 out.push_str(&rest[start..start + 2 + end + 1]);
171 }
172 rest = &after[end + 1..];
173 }
174 out.push_str(rest);
175 out
176}
177
178pub(crate) fn first_unset_warning(server: &str, var: &str) -> bool {
182 static SEEN: std::sync::LazyLock<
183 std::sync::Mutex<std::collections::HashSet<(String, String)>>,
184 > = std::sync::LazyLock::new(Default::default);
185 SEEN.lock()
186 .unwrap_or_else(std::sync::PoisonError::into_inner)
187 .insert((server.to_string(), var.to_string()))
188}
189
190fn safe_str(value: &str, max: usize) -> bool {
191 !value.is_empty() && value.len() <= max && !value.chars().any(char::is_control)
192}
193
194pub fn validate_mcp_config(config: &McpConfig) -> Result<(), &'static str> {
197 if config.mcp_servers.len() > MCP_CONFIG_MAX_SERVERS {
198 return Err("too_many_servers");
199 }
200 for (name, server) in &config.mcp_servers {
201 if !safe_str(name, MCP_CONFIG_MAX_SERVER_NAME_BYTES) {
202 return Err("invalid_server_name");
203 }
204 if !safe_str(&server.command, MCP_CONFIG_MAX_COMMAND_BYTES) {
205 return Err("invalid_command");
206 }
207 if server.args.len() > MCP_CONFIG_MAX_ARGS {
208 return Err("too_many_args");
209 }
210 for arg in &server.args {
211 if arg.len() > MCP_CONFIG_MAX_ARG_BYTES || arg.chars().any(char::is_control) {
212 return Err("invalid_arg");
213 }
214 }
215 if server.env.len() > MCP_CONFIG_MAX_ENV_ENTRIES {
216 return Err("too_many_env_entries");
217 }
218 for (key, value) in &server.env {
219 if !safe_str(key, MCP_CONFIG_MAX_ENV_KEY_BYTES) {
220 return Err("invalid_env_key");
221 }
222 if value.len() > MCP_CONFIG_MAX_ENV_VALUE_BYTES || value.chars().any(char::is_control) {
223 return Err("invalid_env_value");
224 }
225 }
226 }
227 Ok(())
228}
229
230#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
233pub enum McpDiscovery {
234 #[default]
238 Inline,
239 Background,
245}
246
247const SEED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
250
251pub const DISCOVERY_CONCURRENCY: usize = 8;
255
256#[doc(hidden)]
259#[derive(Clone, Copy, Debug)]
260pub struct McpDiscoveryOpts {
261 pub timeout: std::time::Duration,
263 pub now_unix: Option<u64>,
266}
267
268impl Default for McpDiscoveryOpts {
269 fn default() -> Self {
270 Self {
271 timeout: SEED_TIMEOUT,
272 now_unix: None,
273 }
274 }
275}
276
277impl McpDiscoveryOpts {
278 fn now_unix(&self) -> u64 {
279 self.now_unix.unwrap_or_else(|| {
280 std::time::SystemTime::now()
281 .duration_since(std::time::UNIX_EPOCH)
282 .map_or(0, |d| d.as_secs())
283 })
284 }
285}
286
287type SharedRegistry = Arc<tokio::sync::RwLock<crate::ToolRegistry>>;
288
289pub async fn setup_lazy_mcp(registry: &SharedRegistry) -> usize {
302 setup_lazy_mcp_with(registry, McpDiscoveryOpts::default()).await
303}
304
305#[doc(hidden)]
307pub async fn setup_lazy_mcp_with(registry: &SharedRegistry, opts: McpDiscoveryOpts) -> usize {
308 let Some(config) = load_nonempty_config() else {
309 return 0;
310 };
311 let mut cache = load_cache_or_empty();
312 if seed_missing_descriptors(&config, &cache, &opts).await > 0 {
313 cache = load_cache_or_empty();
316 }
317 register_dormant(registry, &config, &cache, "boot").await
318}
319
320pub async fn setup_cached_mcp(registry: &SharedRegistry) -> (usize, Option<PendingMcpDiscovery>) {
325 let Some(config) = load_nonempty_config() else {
326 return (0, None);
327 };
328 let cache = load_cache_or_empty();
329 let mut missing = HashMap::new();
330 for (name, server) in &config.mcp_servers {
331 let fingerprint = descriptors::server_config_fingerprint(server);
332 let cached = cache
333 .servers
334 .get(name)
335 .is_some_and(|entry| entry.fingerprint == fingerprint);
336 if !cached {
337 missing.insert(name.clone(), server.clone());
338 }
339 }
340 let count = register_dormant(registry, &config, &cache, "cached").await;
341 let pending = (!missing.is_empty() && descriptors::cache_writeback_enabled()).then_some(
342 PendingMcpDiscovery {
343 config: McpConfig {
344 mcp_servers: missing,
345 },
346 },
347 );
348 (count, pending)
349}
350
351#[derive(Debug)]
355pub struct PendingMcpDiscovery {
356 config: McpConfig,
357}
358
359impl PendingMcpDiscovery {
360 pub fn server_names(&self) -> Vec<String> {
362 let mut names: Vec<String> = self.config.mcp_servers.keys().cloned().collect();
363 names.sort();
364 names
365 }
366
367 pub async fn run(self, registry: &SharedRegistry) -> usize {
381 self.run_with(registry, McpDiscoveryOpts::default()).await
382 }
383
384 #[doc(hidden)]
386 pub async fn run_with(self, registry: &SharedRegistry, opts: McpDiscoveryOpts) -> usize {
387 let mut cache = load_cache_or_empty();
390 if seed_missing_descriptors(&self.config, &cache, &opts).await > 0 {
391 cache = load_cache_or_empty();
392 }
393 register_dormant(registry, &self.config, &cache, "background").await
394 }
395}
396
397fn load_nonempty_config() -> Option<McpConfig> {
398 load_mcp_config().filter(|config| !config.mcp_servers.is_empty())
399}
400
401fn load_cache_or_empty() -> descriptors::McpDescriptorCache {
402 match descriptors::load_default_cache() {
403 Ok(cache) => cache,
404 Err(descriptors::DescriptorCacheError::NotFound) => descriptors::McpDescriptorCache::empty(),
405 Err(err) => {
406 tracing::warn!(error = %err,
407 "Refusing unsafe MCP descriptor cache; MCP capabilities stay undiscoverable this run");
408 descriptors::McpDescriptorCache::empty()
409 }
410 }
411}
412
413async fn register_dormant(
416 registry: &SharedRegistry,
417 config: &McpConfig,
418 cache: &descriptors::McpDescriptorCache,
419 phase: &'static str,
420) -> usize {
421 let server_count = config.mcp_servers.len();
422 let dormant = descriptors::dormant_tools_for_config(config, cache);
423 if dormant.is_empty() {
424 tracing::info!(
425 servers = server_count,
426 phase,
427 "MCP: no valid cached descriptors; MCP tools are not discoverable this run"
428 );
429 return 0;
430 }
431 match registry.write().await.try_register_batch(dormant) {
432 Ok(count) => {
433 tracing::info!(
434 tools = count,
435 servers = server_count,
436 phase,
437 "MCP: descriptor-backed tools registered (no process started)"
438 );
439 count
440 }
441 Err(e) => {
442 tracing::warn!(error = %e, phase,
443 "MCP descriptor batch rejected; failing closed with zero MCP capabilities");
444 0
445 }
446 }
447}
448
449fn format_unix(secs: u64) -> String {
450 i64::try_from(secs)
451 .ok()
452 .and_then(|s| chrono::DateTime::<chrono::Utc>::from_timestamp(s, 0))
453 .map_or_else(|| secs.to_string(), |dt| dt.to_rfc3339())
454}
455
456async fn seed_missing_descriptors(
470 config: &McpConfig,
471 cache: &descriptors::McpDescriptorCache,
472 opts: &McpDiscoveryOpts,
473) -> usize {
474 use futures::StreamExt;
475
476 if !descriptors::cache_writeback_enabled() {
477 return 0;
478 }
479 let now = opts.now_unix();
480 let mut names: Vec<&String> = config.mcp_servers.keys().collect();
481 names.sort();
482 let mut pending = Vec::new();
483 for name in names {
484 let server = &config.mcp_servers[name];
485 let fingerprint = descriptors::server_config_fingerprint(server);
486 if cache
487 .servers
488 .get(name)
489 .is_some_and(|entry| entry.fingerprint == fingerprint)
490 {
491 continue;
492 }
493 if let Some(failure) = cache.discovery_failures.get(name) {
494 if failure.suppresses(&fingerprint, now) {
495 let when = format_unix(failure.retry_after_unix());
496 tracing::info!(
497 server = %name,
498 attempts = failure.attempts,
499 "MCP discovery for '{}' failed recently; retrying after {}",
500 name,
501 when
502 );
503 continue;
504 }
505 }
506 pending.push((name.clone(), server.clone(), fingerprint));
507 }
508 if pending.is_empty() {
509 return 0;
510 }
511 let opts = *opts;
512 let outcomes: Vec<bool> = futures::stream::iter(
513 pending
514 .into_iter()
515 .map(|(name, server, fingerprint)| discover_one(name, server, fingerprint, opts)),
516 )
517 .buffer_unordered(DISCOVERY_CONCURRENCY)
518 .collect()
519 .await;
520 outcomes.into_iter().filter(|recorded| *recorded).count()
521}
522
523async fn discover_one(
527 name: String,
528 server: McpServerConfig,
529 fingerprint: String,
530 opts: McpDiscoveryOpts,
531) -> bool {
532 let listing = tokio::time::timeout(opts.timeout, async {
533 let mut conn = connection::McpConnection::start(&name, &server).await?;
534 let result = conn.list_tools().await;
535 conn.start_kill();
536 result
537 })
538 .await;
539 let path = descriptors::default_cache_path();
540 let defs = match listing {
541 Ok(Ok(defs)) => defs,
542 Ok(Err(err)) => {
543 tracing::warn!(server = %name, error = %err, "MCP discovery failed; its tools stay undiscoverable until a retry");
544 record_failure(path, name, fingerprint, opts.now_unix()).await;
545 return false;
546 }
547 Err(_) => {
548 tracing::warn!(server = %name, "MCP discovery timed out; its tools stay undiscoverable until a retry");
549 record_failure(path, name, fingerprint, opts.now_unix()).await;
550 return false;
551 }
552 };
553 let tools: Vec<descriptors::CachedToolDescriptor> = defs
554 .into_iter()
555 .map(|def| descriptors::CachedToolDescriptor {
556 name: def.name,
557 description: def.description,
558 input_schema: def.input_schema,
559 })
560 .collect();
561 let server_name = name.clone();
562 let written = tokio::task::spawn_blocking(move || {
563 descriptors::record_server_listing(&path, &server_name, &fingerprint, &tools)
564 })
565 .await;
566 match written {
567 Ok(Ok(count)) => {
568 tracing::info!(server = %name, tools = count, "MCP server discovered and cached");
569 true
570 }
571 Ok(Err(err)) => {
572 tracing::warn!(server = %name, error = %err, "MCP discovery could not be cached");
573 false
574 }
575 Err(err) => {
576 tracing::warn!(server = %name, error = %err, "MCP discovery cache write panicked");
577 false
578 }
579 }
580}
581
582async fn record_failure(path: std::path::PathBuf, name: String, fingerprint: String, now: u64) {
583 let server_name = name.clone();
584 let written = tokio::task::spawn_blocking(move || {
585 descriptors::record_discovery_failure(&path, &server_name, &fingerprint, now)
586 })
587 .await;
588 match written {
589 Ok(Ok(failure)) => tracing::info!(
590 server = %name,
591 attempts = failure.attempts,
592 retry_after = %format_unix(failure.retry_after_unix()),
593 "MCP discovery failure recorded"
594 ),
595 Ok(Err(err)) => {
596 tracing::warn!(server = %name, error = %err, "MCP discovery failure could not be cached")
597 }
598 Err(err) => {
599 tracing::warn!(server = %name, error = %err, "MCP discovery failure write panicked")
600 }
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use serde_json::json;
608
609 #[test]
610 fn test_mcp_config_deserialize() {
611 let json_str = r#"{"mcpServers": {"test": {"command": "echo", "args": ["hi"]}}}"#;
612 let config: McpConfig = serde_json::from_str(json_str).unwrap();
613
614 assert_eq!(config.mcp_servers.len(), 1);
615 assert!(config.mcp_servers.contains_key("test"));
616
617 let server = &config.mcp_servers["test"];
618 assert_eq!(server.command, "echo");
619 assert_eq!(server.args, vec!["hi"]);
620 }
621
622 #[test]
625 fn expand_env_value_reports_unset_vars_without_fallback() {
626 let lookup = |k: &str| match k {
627 "SET" => Some("v".to_string()),
628 "EMPTY" => Some(String::new()),
629 _ => None,
630 };
631 let mut unset = Vec::new();
632 let out = expand_env_value_with(
633 "${SET}:${MISSING}:${EMPTY}:${GONE:-d}:${ALSO_MISSING}:${}",
634 lookup,
635 |name| unset.push(name.to_string()),
636 );
637 assert_eq!(out, "v:::d::${}");
638 assert_eq!(unset, ["MISSING", "ALSO_MISSING"]);
639 }
640
641 #[test]
644 fn unset_var_warning_is_once_per_server_and_var() {
645 assert!(first_unset_warning("dedupe-srv", "DEDUPE_VAR"));
646 assert!(!first_unset_warning("dedupe-srv", "DEDUPE_VAR"));
647 assert!(first_unset_warning("dedupe-srv", "OTHER_VAR"));
648 assert!(first_unset_warning("other-srv", "DEDUPE_VAR"));
649 }
650
651 #[test]
652 fn expand_env_value_forwards_parent_vars_and_defaults() {
653 let lookup = |k: &str| match k {
654 "BASE_URL" => Some("http://api:3000".to_string()),
655 "EMPTY" => Some(String::new()),
656 _ => None,
657 };
658 assert_eq!(expand_env_value("${BASE_URL}", lookup), "http://api:3000");
659 assert_eq!(expand_env_value("${BASE_URL}/v1", lookup), "http://api:3000/v1");
660 assert_eq!(expand_env_value("a=${BASE_URL};b=${BASE_URL}", lookup), "a=http://api:3000;b=http://api:3000");
661 assert_eq!(expand_env_value("${MISSING}", lookup), "");
663 assert_eq!(expand_env_value("${MISSING:-dflt}", lookup), "dflt");
664 assert_eq!(expand_env_value("${EMPTY:-dflt}", lookup), "dflt");
665 assert_eq!(expand_env_value("${EMPTY}", lookup), "");
666 assert_eq!(expand_env_value("plain", lookup), "plain");
668 assert_eq!(expand_env_value("$BASE_URL", lookup), "$BASE_URL");
669 assert_eq!(expand_env_value("${BASE_URL", lookup), "${BASE_URL");
670 assert_eq!(expand_env_value("${}", lookup), "${}");
671 assert_eq!(expand_env_value("${a-b}", lookup), "${a-b}");
672 assert_eq!(expand_env_value("cost $5", lookup), "cost $5");
673 }
674
675 #[test]
676 fn expand_env_value_does_not_recurse() {
677 let lookup = |k: &str| match k {
678 "OUTER" => Some("${INNER}".to_string()),
679 "INNER" => Some("leak".to_string()),
680 _ => None,
681 };
682 assert_eq!(expand_env_value("${OUTER}", lookup), "${INNER}");
683 }
684
685 #[test]
686 fn test_mcp_config_empty_servers() {
687 let json_str = r#"{"mcpServers": {}}"#;
688 let config: McpConfig = serde_json::from_str(json_str).unwrap();
689
690 assert_eq!(config.mcp_servers.len(), 0);
691 assert!(config.mcp_servers.is_empty());
692 }
693
694 #[test]
695 fn test_mcp_server_config_defaults() {
696 let json_str = r#"{"command": "echo"}"#;
697 let server_config: McpServerConfig = serde_json::from_str(json_str).unwrap();
698
699 assert_eq!(server_config.command, "echo");
700 assert_eq!(server_config.args, Vec::<String>::new());
701 assert_eq!(server_config.env, HashMap::new());
702 }
703
704 #[test]
705 fn test_mcp_config_deserialize_from_value() {
706 let json_value = json!({
707 "mcpServers": {
708 "test": {
709 "command": "echo",
710 "args": ["hi"]
711 }
712 }
713 });
714
715 let config: McpConfig = serde_json::from_value(json_value).unwrap();
716
717 assert_eq!(config.mcp_servers.len(), 1);
718 assert!(config.mcp_servers.contains_key("test"));
719
720 let server = &config.mcp_servers["test"];
721 assert_eq!(server.command, "echo");
722 assert_eq!(server.args, vec!["hi"]);
723 }
724
725 fn base_server() -> McpServerConfig {
726 McpServerConfig {
727 command: "/bin/true".to_string(),
728 args: vec!["--flag".to_string()],
729 env: HashMap::from([("KEY".to_string(), "value".to_string())]),
730 shared: false,
731 }
732 }
733
734 fn config_of(servers: Vec<(&str, McpServerConfig)>) -> McpConfig {
735 McpConfig {
736 mcp_servers: servers
737 .into_iter()
738 .map(|(k, v)| (k.to_string(), v))
739 .collect(),
740 }
741 }
742
743 #[test]
744 fn validate_mcp_config_accepts_sane_configs() {
745 assert!(validate_mcp_config(&config_of(vec![("srv", base_server())])).is_ok());
746 assert!(validate_mcp_config(&config_of(vec![])).is_ok());
747 }
748
749 #[test]
750 fn validate_mcp_config_fails_whole_config_closed_on_structural_violations() {
751 let many: Vec<(String, McpServerConfig)> = (0..=MCP_CONFIG_MAX_SERVERS)
753 .map(|i| (format!("s{i}"), base_server()))
754 .collect();
755 let config = McpConfig {
756 mcp_servers: many.into_iter().collect(),
757 };
758 assert_eq!(validate_mcp_config(&config), Err("too_many_servers"));
759
760 let mut c = base_server();
762 assert_eq!(
763 validate_mcp_config(&config_of(vec![("evil\u{7}", c.clone())])),
764 Err("invalid_server_name")
765 );
766 let long_name = "n".repeat(MCP_CONFIG_MAX_SERVER_NAME_BYTES + 1);
767 assert_eq!(
768 validate_mcp_config(&config_of(vec![(long_name.as_str(), c.clone())])),
769 Err("invalid_server_name")
770 );
771
772 c.command = String::new();
774 assert_eq!(
775 validate_mcp_config(&config_of(vec![("srv", c.clone())])),
776 Err("invalid_command")
777 );
778 c.command = "x".repeat(MCP_CONFIG_MAX_COMMAND_BYTES + 1);
779 assert_eq!(
780 validate_mcp_config(&config_of(vec![("srv", c.clone())])),
781 Err("invalid_command")
782 );
783 c.command = "bad\u{0}cmd".to_string();
784 assert_eq!(
785 validate_mcp_config(&config_of(vec![("srv", c.clone())])),
786 Err("invalid_command")
787 );
788
789 let mut c = base_server();
791 c.args = vec!["a".to_string(); MCP_CONFIG_MAX_ARGS + 1];
792 assert_eq!(
793 validate_mcp_config(&config_of(vec![("srv", c.clone())])),
794 Err("too_many_args")
795 );
796 c.args = vec!["ok".to_string(), "bad\u{1b}arg".to_string()];
797 assert_eq!(
798 validate_mcp_config(&config_of(vec![("srv", c.clone())])),
799 Err("invalid_arg")
800 );
801
802 let mut c = base_server();
804 c.env = (0..=MCP_CONFIG_MAX_ENV_ENTRIES)
805 .map(|i| (format!("K{i}"), "v".to_string()))
806 .collect();
807 assert_eq!(
808 validate_mcp_config(&config_of(vec![("srv", c.clone())])),
809 Err("too_many_env_entries")
810 );
811 c.env = HashMap::from([(String::new(), "v".to_string())]);
812 assert_eq!(
813 validate_mcp_config(&config_of(vec![("srv", c.clone())])),
814 Err("invalid_env_key")
815 );
816 c.env = HashMap::from([(
817 "K".to_string(),
818 "v".repeat(MCP_CONFIG_MAX_ENV_VALUE_BYTES + 1),
819 )]);
820 assert_eq!(
821 validate_mcp_config(&config_of(vec![("srv", c.clone())])),
822 Err("invalid_env_value")
823 );
824 }
825
826 #[test]
827 fn test_load_mcp_config_returns_some_or_none() {
828 let result = load_mcp_config();
831
832 match result {
835 Some(_config) => {
836 }
839 None => {
840 }
843 }
844 }
845}