Skip to main content

llm_kernel/install/
wizard.rs

1//! MCP config generation for AI coding tools.
2
3use serde::{Deserialize, Serialize};
4
5/// Supported AI agent tools.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7pub enum AgentKind {
8    /// Claude Desktop (claude.ai desktop app).
9    ClaudeDesktop,
10    /// Cursor IDE.
11    Cursor,
12    /// GitHub Copilot CLI / VS Code extension.
13    Copilot,
14    /// OpenCode terminal.
15    OpenCode,
16    /// Cline VS Code extension.
17    Cline,
18    /// Windsurf IDE.
19    Windsurf,
20    /// Roo Code VS Code extension.
21    RooCode,
22}
23
24impl AgentKind {
25    /// All supported agent kinds.
26    pub fn all() -> &'static [AgentKind] {
27        &[
28            AgentKind::ClaudeDesktop,
29            AgentKind::Cursor,
30            AgentKind::Copilot,
31            AgentKind::OpenCode,
32            AgentKind::Cline,
33            AgentKind::Windsurf,
34            AgentKind::RooCode,
35        ]
36    }
37
38    /// Human-readable display name.
39    pub fn display_name(&self) -> &'static str {
40        match self {
41            Self::ClaudeDesktop => "Claude Desktop",
42            Self::Cursor => "Cursor",
43            Self::Copilot => "GitHub Copilot",
44            Self::OpenCode => "OpenCode",
45            Self::Cline => "Cline",
46            Self::Windsurf => "Windsurf",
47            Self::RooCode => "Roo Code",
48        }
49    }
50
51    /// Config file path (relative to home directory).
52    pub fn config_path(&self) -> &'static str {
53        match self {
54            Self::ClaudeDesktop => ".claude.json",
55            Self::Cursor => ".cursor/mcp.json",
56            Self::Copilot => ".copilot/mcp.json",
57            Self::OpenCode => ".opencode.json",
58            Self::Cline => ".cline/mcp.json",
59            Self::Windsurf => ".windsurf/mcp.json",
60            Self::RooCode => ".roo/mcp.json",
61        }
62    }
63}
64
65/// MCP server connection configuration.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct McpConfig {
68    /// The name shown in the AI tool's MCP server list.
69    pub server_name: String,
70    /// The command to start the MCP server.
71    pub command: String,
72    /// Arguments to pass to the command.
73    pub args: Vec<String>,
74    /// Environment variables to set (name, value pairs).
75    /// Values may contain `${VAR}` references.
76    pub env: Vec<(String, String)>,
77}
78
79/// Generate an MCP configuration snippet for the given agent.
80///
81/// Returns a JSON string that can be written to the agent's config file
82/// or appended to an existing config.
83///
84/// All agent types share the same `mcpServers` JSON format; the `agent`
85/// parameter is kept for forward-compatibility with format divergence.
86pub fn generate_mcp_config(_agent: &AgentKind, config: &McpConfig) -> String {
87    let entry = mcp_server_entry(config);
88    make_mcp_json(&config.server_name, entry)
89}
90
91/// Generate the `mcpServers` JSON block (common format).
92fn mcp_server_entry(config: &McpConfig) -> serde_json::Value {
93    let mut server = serde_json::json!({
94        "command": config.command,
95        "args": config.args,
96    });
97
98    if !config.env.is_empty() {
99        let env_obj: serde_json::Map<String, serde_json::Value> = config
100            .env
101            .iter()
102            .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
103            .collect();
104        server
105            .as_object_mut()
106            .unwrap()
107            .insert("env".into(), serde_json::Value::Object(env_obj));
108    }
109
110    server
111}
112
113fn make_mcp_json(server_name: &str, entry: serde_json::Value) -> String {
114    let mut map = serde_json::Map::new();
115    map.insert(server_name.to_string(), entry);
116    let json = serde_json::json!({ "mcpServers": map });
117    serde_json::to_string_pretty(&json).unwrap_or_default()
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    fn test_config() -> McpConfig {
125        McpConfig {
126            server_name: "test-server".into(),
127            command: "test-server".into(),
128            args: vec!["--stdio".into()],
129            env: vec![("TEST_API_KEY".into(), "${TEST_API_KEY}".into())],
130        }
131    }
132
133    #[test]
134    fn all_agents_covered() {
135        assert_eq!(AgentKind::all().len(), 7);
136        for agent in AgentKind::all() {
137            let config = test_config();
138            let json = generate_mcp_config(agent, &config);
139            assert!(
140                json.contains("test-server"),
141                "missing server name for {:?}",
142                agent
143            );
144            assert!(json.contains("TEST_API_KEY"), "missing env for {:?}", agent);
145        }
146    }
147
148    #[test]
149    fn claude_desktop_format() {
150        let json = generate_mcp_config(&AgentKind::ClaudeDesktop, &test_config());
151        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
152        assert!(parsed["mcpServers"]["test-server"].is_object());
153        assert_eq!(
154            parsed["mcpServers"]["test-server"]["command"],
155            "test-server"
156        );
157    }
158
159    #[test]
160    fn no_env_when_empty() {
161        let config = McpConfig {
162            server_name: "bare".into(),
163            command: "bare".into(),
164            args: vec![],
165            env: vec![],
166        };
167        let json = generate_mcp_config(&AgentKind::Cursor, &config);
168        assert!(!json.contains("env"), "env should be absent when empty");
169    }
170
171    #[test]
172    fn agent_display_names() {
173        assert_eq!(AgentKind::ClaudeDesktop.display_name(), "Claude Desktop");
174        assert_eq!(AgentKind::Cursor.display_name(), "Cursor");
175    }
176}