Skip to main content

agent_engine/mcp/
mod.rs

1//! MCP (Model Context Protocol) integration — JSON-RPC client, tool bridging, lazy loading.
2mod 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/// MCP server configuration — matches claude-code/gemini-cli format.
12#[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    /// Daemon-mode opt-in: ONE child serves every session in the process
20    /// (lease key `"*"`) instead of one child per session. The server then
21    /// sees every session's calls and holds cross-session state
22    /// (roots/cwd) — never the default. Excluded from the config
23    /// fingerprint (launch identity only); flipping it starts a fresh
24    /// lease key. See `docs/mcp.md`.
25    #[serde(default)]
26    pub shared: bool,
27}
28
29/// MCP config file format: { "mcpServers": { "name": { command, args, env } } }
30#[derive(Debug, Clone, serde::Deserialize)]
31pub struct McpConfig {
32    #[serde(rename = "mcpServers", default)]
33    pub mcp_servers: HashMap<String, McpServerConfig>,
34}
35
36/// Discovered tool definition from an MCP server.
37#[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
44/// Hard byte bound on `mcp.json`.
45pub const MCP_CONFIG_MAX_BYTES: u64 = 1024 * 1024;
46
47/// Load MCP config from ~/.synaps-cli/mcp.json (or profile variant).
48///
49/// Bounded local read (Task 19): one `O_NOFOLLOW|O_NONBLOCK` handle,
50/// opened-metadata regular-file check, capped read with growth rejection —
51/// symlinks, FIFOs, and oversized files are refused. Failures log only the
52/// static file name and bounded typed metadata, never raw path or parse
53/// content. Semantics are otherwise unchanged: any rejection yields `None`.
54pub 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            // Static category only: Io Display strings can embed paths.
61            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            // Numeric/class parse metadata ONLY: serde Display can embed
81            // hostile key/value excerpts.
82            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        // The WHOLE config fails closed on any structural violation.
94        tracing::warn!(
95            file = "mcp.json",
96            reason,
97            "Refusing structurally unsafe MCP config"
98        );
99        return None;
100    }
101    Some(config)
102}
103
104/// Structural bounds on `mcp.json` (Task 19 review): the 1 MiB byte cap
105/// alone leaves server/env counts and string shapes unbounded. Violations
106/// fail the WHOLE config closed with a static reason.
107pub 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
116/// Expand `${VAR}` / `${VAR:-default}` references in an `mcp.json` `env`
117/// value against `lookup` (the parent process env at spawn time).
118///
119/// MCP children are spawned with a CLEARED environment (H3: never leak host
120/// secrets such as `ANTHROPIC_API_KEY`/`AWS_*` to third-party servers). This
121/// is the explicit, per-variable opt-in for forwarding a parent value — the
122/// same `${VAR}` syntax Claude Code / Gemini CLI accept in their `mcpServers`
123/// `env` maps, so configs stay portable. Semantics:
124///   * `${VAR}`           → parent value, or "" when unset
125///   * `${VAR:-fallback}` → parent value, or `fallback` when unset/empty
126///   * anything else (incl. a lone `$`, `$VAR`, unterminated `${`) is literal
127///
128/// Only the FIRST expansion level is applied: a parent value containing `${…}`
129/// is copied verbatim (no recursion, so no expansion loops).
130pub fn expand_env_value(value: &str, lookup: impl Fn(&str) -> Option<String>) -> String {
131    expand_env_value_with(value, lookup, |_| {})
132}
133
134/// [`expand_env_value`], calling `on_unset(name)` for every `${VAR}` with no
135/// fallback whose variable is unset (and so expands to ""). A set-but-empty
136/// variable is not reported.
137pub 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            // Unterminated: keep the remainder literally.
149            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            // Not a variable reference (e.g. `${}` or `${a-b}`): literal.
170            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
178/// True the first time this process sees an unset `${VAR}` for
179/// (`server`, `var`); false after. Keeps the spawn warning to one line per
180/// server and variable instead of one per session spawn in daemon mode.
181pub(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
194/// Validate config structure before ANY use. Static reasons only — no
195/// config content is ever echoed.
196pub 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/// How engine boot treats configured MCP servers that have no
231/// fingerprint-matching descriptor-cache entry (see [`setup_lazy_mcp`]).
232#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
233pub enum McpDiscovery {
234    /// Discover them during boot, before the host is returned (bounded:
235    /// in parallel, each under the seed timeout). In-process modes (TUI
236    /// without daemon, `chat`, `agent`, `server`).
237    #[default]
238    Inline,
239    /// Boot registers cached descriptors only; discovery of the missing
240    /// servers runs later in a background task
241    /// ([`crate::EngineHost::spawn_background_mcp_discovery`]), so a hung or
242    /// dead server can never delay readiness. Used by the daemon, which must
243    /// signal ready within the auto-spawner's bound.
244    Background,
245}
246
247/// Upper bound for one server's boot-time discovery (spawn + initialize +
248/// tools/list).
249const SEED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
250
251/// How many missing servers are discovered at once. With N missing servers
252/// boot discovery costs at most `ceil(N / DISCOVERY_CONCURRENCY)` seed
253/// timeouts (one for any realistic N), never `N ×` one.
254pub const DISCOVERY_CONCURRENCY: usize = 8;
255
256/// Test seam for boot discovery — NOT user-facing configuration. Production
257/// always uses [`McpDiscoveryOpts::default`] (20 s per server, system clock).
258#[doc(hidden)]
259#[derive(Clone, Copy, Debug)]
260pub struct McpDiscoveryOpts {
261    /// Per-server bound on spawn + initialize + tools/list.
262    pub timeout: std::time::Duration,
263    /// Clock override (unix seconds) for the negative-cache backoff; `None`
264    /// uses the system clock.
265    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
289/// Set up MCP loading at engine boot, before any session tool set exists.
290///
291/// Register every configured server's cached descriptors as registry
292/// entries (Task 19, spec §7.4). They are searchable and exactly activatable
293/// under progressive disclosure, part of the core otherwise, and always
294/// execution-gated; nothing is spawned until a leased execution. Servers
295/// without a fingerprint-matching cache entry are discovered first, in
296/// parallel ([`seed_missing_descriptors`]); one that fails contributes no
297/// tools this run and is not retried until its backoff expires (see
298/// [`descriptors::DiscoveryFailure`]). Descriptors are never invented from
299/// config alone. The batch registers atomically; on failure MCP capabilities
300/// fail closed to zero. Returns the number of tools registered.
301pub async fn setup_lazy_mcp(registry: &SharedRegistry) -> usize {
302    setup_lazy_mcp_with(registry, McpDiscoveryOpts::default()).await
303}
304
305/// [`setup_lazy_mcp`] with an injectable seed timeout and clock (tests).
306#[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        // Re-read through the validating loader: entries written by the seed
314        // pass the same sanitisation as any other cache load.
315        cache = load_cache_or_empty();
316    }
317    register_dormant(registry, &config, &cache, "boot").await
318}
319
320/// Background-mode phase 1 ([`McpDiscovery::Background`]): register the
321/// cached descriptors only — never spawns — and hand back the discovery
322/// still owed for servers without a fingerprint-matching entry (`None` when
323/// there is none). Returns `(tools registered, pending discovery)`.
324pub 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/// Discovery deferred by [`setup_cached_mcp`]: exactly the configured
352/// servers (from the SAME config snapshot phase 1 used) that had no
353/// fingerprint-matching cache entry, so none of them has registered tools.
354#[derive(Debug)]
355pub struct PendingMcpDiscovery {
356    config: McpConfig,
357}
358
359impl PendingMcpDiscovery {
360    /// Names of the servers still to be discovered.
361    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    /// Discover the pending servers (parallel, bounded, negative-cached)
368    /// and register ONLY their dormant tools into the shared `registry` with
369    /// one atomic `try_register_batch`. Servers already registered at boot
370    /// are never in this set, so nothing is registered twice. A rejected
371    /// batch is logged and fails closed (none of the new tools appear);
372    /// everything registered before is untouched.
373    ///
374    /// The registry is the host's shared catalog, so this mutates it after
375    /// sessions may exist — the same precedent as late extension
376    /// registration. With progressive disclosure on the new tools are
377    /// dormant (searchable/activatable). With it off they are core tools, so
378    /// every live session sends them from its NEXT request: one prompt-cache
379    /// rewrite per live session, once.
380    pub async fn run(self, registry: &SharedRegistry) -> usize {
381        self.run_with(registry, McpDiscoveryOpts::default()).await
382    }
383
384    /// [`Self::run`] with an injectable seed timeout and clock (tests).
385    #[doc(hidden)]
386    pub async fn run_with(self, registry: &SharedRegistry, opts: McpDiscoveryOpts) -> usize {
387        // Fresh read: another process may have recorded meanwhile (its
388        // matching entry is then registered here without a spawn).
389        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
413/// Register `config`'s fingerprint-matching cached descriptors as one
414/// atomic batch; fail closed (zero) on rejection.
415async 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
456/// Bounded one-time discovery for configured servers that have no
457/// fingerprint-matching descriptor-cache entry (a new server, or a changed
458/// command/args/env). Without an entry a server contributes no tools, and the
459/// cache was only ever written after a leased call, which needs a tool to
460/// call: a new server stayed invisible forever. Missing servers are
461/// discovered concurrently (at most [`DISCOVERY_CONCURRENCY`] at once), each
462/// started once, listed, recorded through the same locked, sanitising
463/// `record_server_listing` the lease write-back uses, and stopped (children
464/// are `kill_on_drop`, so a timeout cannot leak a process). A failure (error
465/// or timeout) is recorded with `record_discovery_failure`; a server whose
466/// failure under its CURRENT fingerprint is still inside its backoff is
467/// skipped without spawning. Respects `SYNAPS_MCP_CACHE_WRITEBACK=0` (no
468/// discovery). Returns how many servers were recorded.
469async 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
523/// Discover one server (bounded by `opts.timeout`) and record the outcome:
524/// its listing on success, a negative-cache entry on error or timeout.
525/// Returns whether a listing was recorded.
526async 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    /// 01 M1 / 08 I6.3: an unset `${VAR}` with no fallback silently became
623    /// "". The spawn path now warns; this pins which names it reports.
624    #[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    /// The unset-var warning fires once per (server, var) per process, not
642    /// on every spawn (daemon mode spawns one child per session).
643    #[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        // Unset: empty (or the fallback).
662        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        // Literal forms are untouched.
667        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        // Too many servers.
752        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        // Hostile server name (control chars) and oversized name.
761        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        // Command: empty, oversized, control-bearing.
773        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        // Args: count and shape.
790        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        // Env: entry count, key shape, value shape.
803        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        // This test verifies that load_mcp_config() returns either Some or None
829        // depending on whether the config file exists
830        let result = load_mcp_config();
831
832        // Result can be either Some(config) or None - both are valid
833        // depending on whether ~/.synaps-cli/mcp.json exists
834        match result {
835            Some(_config) => {
836                // If file exists and parses correctly, we get a config
837                // (mcp_servers can be empty — that's valid)
838            }
839            None => {
840                // If file doesn't exist or fails to parse, we get None
841                // This is expected behavior
842            }
843        }
844    }
845}