gemini-cli-sdk 0.1.0

Rust SDK wrapping Google's Gemini CLI as a subprocess via JSON-RPC 2.0
Documentation
//! MCP server configuration types.
//!
//! Defines the types used to configure MCP (Model Context Protocol) servers
//! that are passed to the Gemini CLI during session creation via `session/new`.
//! The CLI manages MCP server lifecycle; the SDK only passes configuration.

use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;

/// Collection of MCP server configurations, keyed by server name.
///
/// `BTreeMap` is used to ensure deterministic ordering when serializing to
/// the wire format, which aids reproducibility in tests and debug output.
pub type McpServers = BTreeMap<String, McpServerConfig>;

/// Configuration for a single MCP server process.
///
/// The Gemini CLI spawns and manages the server process; this struct only
/// carries the parameters required to launch it.
///
/// # Examples
///
/// ```rust
/// use gemini_cli_sdk::mcp::McpServerConfig;
///
/// let cfg = McpServerConfig::new("npx");
/// assert_eq!(cfg.command, "npx");
/// assert!(cfg.args.is_empty());
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct McpServerConfig {
    /// Command to launch the MCP server.
    pub command: String,

    /// Arguments forwarded to the command.
    #[serde(default)]
    pub args: Vec<String>,

    /// Environment variables injected into the server process.
    #[serde(default)]
    pub env: BTreeMap<String, String>,

    /// Optional working directory for the server process.
    #[serde(default)]
    pub cwd: Option<String>,

    /// Pass-through for any additional fields the Gemini CLI may support.
    /// Stored as a JSON object so unknown keys survive a round-trip.
    #[serde(flatten)]
    pub extra: Value,
}

impl McpServerConfig {
    /// Create a minimal config with only a command.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use gemini_cli_sdk::mcp::McpServerConfig;
    ///
    /// let cfg = McpServerConfig::new("uvx");
    /// assert_eq!(cfg.command, "uvx");
    /// assert!(cfg.args.is_empty());
    /// assert!(cfg.env.is_empty());
    /// assert!(cfg.cwd.is_none());
    /// ```
    pub fn new(command: impl Into<String>) -> Self {
        Self {
            command: command.into(),
            args: Vec::new(),
            env: BTreeMap::new(),
            cwd: None,
            extra: Value::Object(Default::default()),
        }
    }

    /// Create a config with a command and an explicit argument list.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use gemini_cli_sdk::mcp::McpServerConfig;
    ///
    /// let cfg = McpServerConfig::with_args(
    ///     "npx",
    ///     vec!["-y".to_string(), "@modelcontextprotocol/server-filesystem".to_string()],
    /// );
    /// assert_eq!(cfg.args.len(), 2);
    /// ```
    pub fn with_args(command: impl Into<String>, args: Vec<String>) -> Self {
        Self {
            command: command.into(),
            args,
            env: BTreeMap::new(),
            cwd: None,
            extra: Value::Object(Default::default()),
        }
    }
}

/// Convert an [`McpServers`] map to the wire format expected by `session/new`.
///
/// Each server config object gains a `"name"` field whose value is the map key.
/// The resulting array preserves `BTreeMap` order (alphabetical by name).
pub(crate) fn mcp_servers_to_wire(servers: &McpServers) -> Vec<Value> {
    servers
        .iter()
        .map(|(name, config)| {
            let mut obj = serde_json::to_value(config).unwrap_or_default();
            if let Some(map) = obj.as_object_mut() {
                map.insert("name".to_string(), Value::String(name.clone()));
            }
            obj
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_mcp_config_new() {
        let cfg = McpServerConfig::new("uvx");

        assert_eq!(cfg.command, "uvx");
        assert!(cfg.args.is_empty(), "args should default to empty");
        assert!(cfg.env.is_empty(), "env should default to empty");
        assert!(cfg.cwd.is_none(), "cwd should default to None");
        // extra must be an empty JSON object, not null or array.
        assert_eq!(cfg.extra, Value::Object(Default::default()));
    }

    #[test]
    fn test_mcp_config_with_args() {
        let args = vec![
            "-y".to_string(),
            "@modelcontextprotocol/server-filesystem".to_string(),
            "/tmp".to_string(),
        ];
        let cfg = McpServerConfig::with_args("npx", args.clone());

        assert_eq!(cfg.command, "npx");
        assert_eq!(cfg.args, args);
        assert!(cfg.env.is_empty());
        assert!(cfg.cwd.is_none());
    }

    #[test]
    fn test_mcp_config_serde_roundtrip() {
        let mut env = BTreeMap::new();
        env.insert("API_KEY".to_string(), "secret".to_string());

        let original = McpServerConfig {
            command: "python".to_string(),
            args: vec!["-m".to_string(), "mcp_server".to_string()],
            env,
            cwd: Some("/workspace".to_string()),
            extra: Value::Object(Default::default()),
        };

        let json = serde_json::to_string(&original).expect("serialize failed");
        let decoded: McpServerConfig =
            serde_json::from_str(&json).expect("deserialize failed");

        assert_eq!(original, decoded);
    }

    #[test]
    fn test_mcp_servers_to_wire_with_entries() {
        let mut servers: McpServers = BTreeMap::new();

        servers.insert(
            "alpha".to_string(),
            McpServerConfig::new("cmd-alpha"),
        );
        servers.insert(
            "beta".to_string(),
            McpServerConfig::with_args(
                "cmd-beta",
                vec!["--flag".to_string()],
            ),
        );

        let wire = mcp_servers_to_wire(&servers);

        // BTreeMap iterates alphabetically: alpha, beta.
        assert_eq!(wire.len(), 2);

        let alpha = &wire[0];
        assert_eq!(alpha["name"], Value::String("alpha".to_string()));
        assert_eq!(alpha["command"], Value::String("cmd-alpha".to_string()));

        let beta = &wire[1];
        assert_eq!(beta["name"], Value::String("beta".to_string()));
        assert_eq!(beta["command"], Value::String("cmd-beta".to_string()));
        assert_eq!(
            beta["args"],
            serde_json::json!(["--flag"]),
            "args must survive wire conversion"
        );
    }

    #[test]
    fn test_mcp_servers_to_wire_empty() {
        let servers: McpServers = BTreeMap::new();
        let wire = mcp_servers_to_wire(&servers);
        assert!(wire.is_empty(), "empty map must produce empty vec");
    }
}