Skip to main content

lean_ctx/setup/
mcp.rs

1//! Per-agent MCP configuration (configure/disable, target resolution).
2//!
3//! Split out of `setup/mod.rs`; `use super::*` re-imports the parent module’s
4//! aliases and sibling helpers. Public fns are re-exported via `pub(crate) use`.
5
6#[allow(clippy::wildcard_imports)]
7use super::*;
8
9/// Result of setting up a single agent with all steps.
10#[derive(Debug, Default)]
11pub struct AgentSetupResult {
12    pub mcp_ok: bool,
13    /// MCP registration was intentionally skipped because `[setup]
14    /// auto_update_mcp = false` (#281), not because it failed.
15    pub mcp_skipped: bool,
16    pub rules: crate::rules_inject::InjectResult,
17    pub skill_installed: bool,
18    pub errors: Vec<String>,
19}
20
21/// Complete per-agent setup: MCP config + global rules + skill + hook.
22/// Single source of truth — called by both `init --agent` and `setup`.
23pub fn setup_single_agent(
24    agent_name: &str,
25    global: bool,
26    mode: crate::hooks::HookMode,
27) -> AgentSetupResult {
28    let home = dirs::home_dir().unwrap_or_default();
29    let mut result = AgentSetupResult::default();
30
31    crate::hooks::install_agent_hook_with_mode(agent_name, global, mode);
32
33    // #281: honor `[setup] auto_update_mcp = false` — skip MCP registration but
34    // still install the hook, rules and skill. Locked-down environments can keep
35    // the MCP server out of agent settings without losing the CLI integration.
36    if crate::core::config::Config::load()
37        .setup
38        .should_update_mcp()
39    {
40        match configure_agent_mcp(agent_name) {
41            Ok(()) => result.mcp_ok = true,
42            Err(e) => result.errors.push(format!("MCP config: {e}")),
43        }
44    } else {
45        result.mcp_skipped = true;
46    }
47
48    result.rules = crate::rules_inject::inject_rules_for_agent(&home, agent_name);
49
50    if let Ok(path) = crate::rules_inject::install_skill_for_agent(&home, agent_name) {
51        result.skill_installed = path.exists();
52    }
53
54    result
55}
56
57pub fn configure_agent_mcp(agent: &str) -> Result<(), String> {
58    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
59    let binary = resolve_portable_binary();
60
61    let targets = agent_mcp_targets(agent, &home)?;
62
63    let mut errors = Vec::new();
64    for t in &targets {
65        if let Err(e) = crate::core::editor_registry::write_config_with_options(
66            t,
67            &binary,
68            WriteOptions {
69                overwrite_invalid: true,
70            },
71        ) {
72            eprintln!(
73                "\x1b[33m⚠\x1b[0m  Could not configure {}: {}",
74                t.config_path.display(),
75                e
76            );
77            errors.push(e);
78        }
79    }
80
81    if agent == "kiro" {
82        install_kiro_steering(&home);
83    }
84
85    if agent == "vscode" || agent == "copilot" {
86        if let Err(e) = crate::core::editor_registry::plan_mode::write_vscode_plan_settings() {
87            eprintln!("\x1b[33m⚠\x1b[0m  VS Code plan mode: {e}");
88        }
89    }
90    if agent == "claude" || agent == "claude-code" {
91        if let Err(e) =
92            crate::core::editor_registry::plan_mode::write_claude_code_plan_permissions()
93        {
94            eprintln!("\x1b[33m⚠\x1b[0m  Claude Code plan mode: {e}");
95        }
96    }
97
98    if errors.is_empty() {
99        Ok(())
100    } else {
101        Err(format!(
102            "{} config(s) could not be written. See warnings above.",
103            errors.len()
104        ))
105    }
106}
107
108pub(crate) fn agent_mcp_targets(
109    agent: &str,
110    home: &std::path::Path,
111) -> Result<Vec<EditorTarget>, String> {
112    let mut targets = Vec::<EditorTarget>::new();
113
114    let push = |targets: &mut Vec<EditorTarget>,
115                name: &'static str,
116                config_path: PathBuf,
117                config_type: ConfigType| {
118        targets.push(EditorTarget {
119            name,
120            agent_key: agent.to_string(),
121            detect_path: PathBuf::from("/nonexistent"), // not used in direct agent config
122            config_path,
123            config_type,
124        });
125    };
126
127    match agent {
128        "cursor" => push(
129            &mut targets,
130            "Cursor",
131            home.join(".cursor/mcp.json"),
132            ConfigType::McpJson,
133        ),
134        "claude" | "claude-code" => push(
135            &mut targets,
136            "Claude Code",
137            crate::core::editor_registry::claude_mcp_json_path(home),
138            ConfigType::McpJson,
139        ),
140        "augment" => {
141            push(
142                &mut targets,
143                "Augment CLI",
144                crate::core::editor_registry::augment_cli_settings_path(home),
145                ConfigType::McpJson,
146            );
147            push(
148                &mut targets,
149                "Augment (VS Code)",
150                crate::core::editor_registry::augment_vscode_mcp_path(home),
151                ConfigType::AugmentVsCode,
152            );
153        }
154        "windsurf" => push(
155            &mut targets,
156            "Windsurf",
157            home.join(".codeium/windsurf/mcp_config.json"),
158            ConfigType::McpJson,
159        ),
160        "codex" => {
161            let codex_dir =
162                crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
163            push(
164                &mut targets,
165                "Codex CLI",
166                codex_dir.join("config.toml"),
167                ConfigType::Codex,
168            );
169        }
170        "gemini" => {
171            push(
172                &mut targets,
173                "Gemini CLI",
174                home.join(".gemini/settings.json"),
175                ConfigType::GeminiSettings,
176            );
177            push(
178                &mut targets,
179                "Antigravity IDE",
180                home.join(".gemini/antigravity/mcp_config.json"),
181                ConfigType::McpJson,
182            );
183            push(
184                &mut targets,
185                "Antigravity CLI",
186                home.join(".gemini/antigravity-cli/mcp_config.json"),
187                ConfigType::McpJson,
188            );
189        }
190        "antigravity" => push(
191            &mut targets,
192            "Antigravity IDE",
193            home.join(".gemini/antigravity/mcp_config.json"),
194            ConfigType::McpJson,
195        ),
196        "antigravity-cli" => push(
197            &mut targets,
198            "Antigravity CLI",
199            home.join(".gemini/antigravity-cli/mcp_config.json"),
200            ConfigType::McpJson,
201        ),
202        "copilot" => push(
203            &mut targets,
204            "Copilot CLI",
205            home.join(".copilot/mcp-config.json"),
206            ConfigType::CopilotCli,
207        ),
208        "crush" => push(
209            &mut targets,
210            "Crush",
211            home.join(".config/crush/crush.json"),
212            ConfigType::Crush,
213        ),
214        "qoder" => {
215            for path in crate::core::editor_registry::qoder_all_mcp_paths(home) {
216                push(&mut targets, "Qoder", path, ConfigType::QoderSettings);
217            }
218        }
219        "qoderwork" => push(
220            &mut targets,
221            "QoderWork",
222            crate::core::editor_registry::qoderwork_mcp_path(home),
223            ConfigType::McpJson,
224        ),
225        "cline" => push(
226            &mut targets,
227            "Cline",
228            crate::core::editor_registry::cline_mcp_path(),
229            ConfigType::McpJson,
230        ),
231        "roo" => push(
232            &mut targets,
233            "Roo Code",
234            crate::core::editor_registry::roo_mcp_path(),
235            ConfigType::McpJson,
236        ),
237        "kiro" => push(
238            &mut targets,
239            "AWS Kiro",
240            home.join(".kiro/settings/mcp.json"),
241            ConfigType::McpJson,
242        ),
243        "verdent" => push(
244            &mut targets,
245            "Verdent",
246            home.join(".verdent/mcp.json"),
247            ConfigType::McpJson,
248        ),
249        // pi: deliberately no MCP target. Pi has no native MCP adapter — a
250        // ~/.pi/agent/mcp.json entry is never served and made older pi-lean-ctx
251        // versions disable their embedded bridge (GitHub #361). Pi runs through
252        // the pi-lean-ctx npm package; install_pi_hook_with_mode removes stale
253        // entries instead.
254        "pi" | "jetbrains" | "amp" | "openclaw" => {
255            // jetbrains/amp/openclaw: handled by dedicated install hooks
256            // (servers[] array / amp.mcpServers / mcp.servers).
257        }
258        "qwen" => push(
259            &mut targets,
260            "Qwen Code",
261            home.join(".qwen/settings.json"),
262            ConfigType::McpJson,
263        ),
264        "trae" => push(
265            &mut targets,
266            "Trae",
267            home.join(".trae/mcp.json"),
268            ConfigType::McpJson,
269        ),
270        "amazonq" => push(
271            &mut targets,
272            "Amazon Q Developer",
273            home.join(".aws/amazonq/default.json"),
274            ConfigType::McpJson,
275        ),
276        "opencode" => {
277            #[cfg(windows)]
278            let opencode_path = if let Ok(appdata) = std::env::var("APPDATA") {
279                std::path::PathBuf::from(appdata)
280                    .join("opencode")
281                    .join("opencode.json")
282            } else {
283                home.join(".config/opencode/opencode.json")
284            };
285            #[cfg(not(windows))]
286            let opencode_path = home.join(".config/opencode/opencode.json");
287            push(
288                &mut targets,
289                "OpenCode",
290                opencode_path,
291                ConfigType::OpenCode,
292            );
293        }
294        "hermes" => push(
295            &mut targets,
296            "Hermes Agent",
297            home.join(".hermes/config.yaml"),
298            ConfigType::HermesYaml,
299        ),
300        "vscode" => push(
301            &mut targets,
302            "VS Code",
303            crate::core::editor_registry::vscode_mcp_path(),
304            ConfigType::VsCodeMcp,
305        ),
306        "zed" => push(
307            &mut targets,
308            "Zed",
309            crate::core::editor_registry::zed_settings_path(home),
310            ConfigType::Zed,
311        ),
312        "aider" => push(
313            &mut targets,
314            "Aider",
315            home.join(".aider/mcp.json"),
316            ConfigType::McpJson,
317        ),
318        "continue" => push(
319            &mut targets,
320            "Continue",
321            home.join(".continue/mcp.json"),
322            ConfigType::McpJson,
323        ),
324        "neovim" => push(
325            &mut targets,
326            "Neovim (mcphub.nvim)",
327            home.join(".config/mcphub/servers.json"),
328            ConfigType::McpJson,
329        ),
330        "emacs" => push(
331            &mut targets,
332            "Emacs (mcp.el)",
333            home.join(".emacs.d/mcp.json"),
334            ConfigType::McpJson,
335        ),
336        "sublime" => push(
337            &mut targets,
338            "Sublime Text",
339            home.join(".config/sublime-text/mcp.json"),
340            ConfigType::McpJson,
341        ),
342        _ => {
343            return Err(format!("Unknown agent '{agent}'"));
344        }
345    }
346
347    Ok(targets)
348}
349
350pub fn disable_agent_mcp(agent: &str, overwrite_invalid: bool) -> Result<(), String> {
351    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
352
353    let mut targets = Vec::<EditorTarget>::new();
354
355    let push = |targets: &mut Vec<EditorTarget>,
356                name: &'static str,
357                config_path: PathBuf,
358                config_type: ConfigType| {
359        targets.push(EditorTarget {
360            name,
361            agent_key: agent.to_string(),
362            detect_path: PathBuf::from("/nonexistent"),
363            config_path,
364            config_type,
365        });
366    };
367
368    let pi_cfg = home.join(".pi").join("agent").join("mcp.json");
369
370    match agent {
371        "cursor" => push(
372            &mut targets,
373            "Cursor",
374            home.join(".cursor/mcp.json"),
375            ConfigType::McpJson,
376        ),
377        "claude" | "claude-code" => push(
378            &mut targets,
379            "Claude Code",
380            crate::core::editor_registry::claude_mcp_json_path(&home),
381            ConfigType::McpJson,
382        ),
383        "augment" => {
384            push(
385                &mut targets,
386                "Augment CLI",
387                crate::core::editor_registry::augment_cli_settings_path(&home),
388                ConfigType::McpJson,
389            );
390            push(
391                &mut targets,
392                "Augment (VS Code)",
393                crate::core::editor_registry::augment_vscode_mcp_path(&home),
394                ConfigType::AugmentVsCode,
395            );
396        }
397        "windsurf" => push(
398            &mut targets,
399            "Windsurf",
400            home.join(".codeium/windsurf/mcp_config.json"),
401            ConfigType::McpJson,
402        ),
403        "codex" => {
404            let codex_dir =
405                crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
406            push(
407                &mut targets,
408                "Codex CLI",
409                codex_dir.join("config.toml"),
410                ConfigType::Codex,
411            );
412        }
413        "gemini" => {
414            push(
415                &mut targets,
416                "Gemini CLI",
417                home.join(".gemini/settings.json"),
418                ConfigType::GeminiSettings,
419            );
420            push(
421                &mut targets,
422                "Antigravity IDE",
423                home.join(".gemini/antigravity/mcp_config.json"),
424                ConfigType::McpJson,
425            );
426            push(
427                &mut targets,
428                "Antigravity CLI",
429                home.join(".gemini/antigravity-cli/mcp_config.json"),
430                ConfigType::McpJson,
431            );
432        }
433        "antigravity" => push(
434            &mut targets,
435            "Antigravity IDE",
436            home.join(".gemini/antigravity/mcp_config.json"),
437            ConfigType::McpJson,
438        ),
439        "antigravity-cli" => push(
440            &mut targets,
441            "Antigravity CLI",
442            home.join(".gemini/antigravity-cli/mcp_config.json"),
443            ConfigType::McpJson,
444        ),
445        "copilot" => push(
446            &mut targets,
447            "Copilot CLI",
448            home.join(".copilot/mcp-config.json"),
449            ConfigType::CopilotCli,
450        ),
451        "crush" => push(
452            &mut targets,
453            "Crush",
454            home.join(".config/crush/crush.json"),
455            ConfigType::Crush,
456        ),
457        "pi" => push(&mut targets, "Pi Coding Agent", pi_cfg, ConfigType::McpJson),
458        "qoder" => {
459            for path in crate::core::editor_registry::qoder_all_mcp_paths(&home) {
460                push(&mut targets, "Qoder", path, ConfigType::QoderSettings);
461            }
462        }
463        "qoderwork" => push(
464            &mut targets,
465            "QoderWork",
466            crate::core::editor_registry::qoderwork_mcp_path(&home),
467            ConfigType::McpJson,
468        ),
469        "cline" => push(
470            &mut targets,
471            "Cline",
472            crate::core::editor_registry::cline_mcp_path(),
473            ConfigType::McpJson,
474        ),
475        "roo" => push(
476            &mut targets,
477            "Roo Code",
478            crate::core::editor_registry::roo_mcp_path(),
479            ConfigType::McpJson,
480        ),
481        "kiro" => push(
482            &mut targets,
483            "AWS Kiro",
484            home.join(".kiro/settings/mcp.json"),
485            ConfigType::McpJson,
486        ),
487        "verdent" => push(
488            &mut targets,
489            "Verdent",
490            home.join(".verdent/mcp.json"),
491            ConfigType::McpJson,
492        ),
493        "jetbrains" | "amp" | "openclaw" => {
494            // Not supported for disable via this helper.
495        }
496        "qwen" => push(
497            &mut targets,
498            "Qwen Code",
499            home.join(".qwen/settings.json"),
500            ConfigType::McpJson,
501        ),
502        "trae" => push(
503            &mut targets,
504            "Trae",
505            home.join(".trae/mcp.json"),
506            ConfigType::McpJson,
507        ),
508        "amazonq" => push(
509            &mut targets,
510            "Amazon Q Developer",
511            home.join(".aws/amazonq/default.json"),
512            ConfigType::McpJson,
513        ),
514        "opencode" => {
515            #[cfg(windows)]
516            let opencode_path = if let Ok(appdata) = std::env::var("APPDATA") {
517                std::path::PathBuf::from(appdata)
518                    .join("opencode")
519                    .join("opencode.json")
520            } else {
521                home.join(".config/opencode/opencode.json")
522            };
523            #[cfg(not(windows))]
524            let opencode_path = home.join(".config/opencode/opencode.json");
525            push(
526                &mut targets,
527                "OpenCode",
528                opencode_path,
529                ConfigType::OpenCode,
530            );
531        }
532        "hermes" => push(
533            &mut targets,
534            "Hermes Agent",
535            home.join(".hermes/config.yaml"),
536            ConfigType::HermesYaml,
537        ),
538        "vscode" => push(
539            &mut targets,
540            "VS Code",
541            crate::core::editor_registry::vscode_mcp_path(),
542            ConfigType::VsCodeMcp,
543        ),
544        "zed" => push(
545            &mut targets,
546            "Zed",
547            crate::core::editor_registry::zed_settings_path(&home),
548            ConfigType::Zed,
549        ),
550        "aider" => push(
551            &mut targets,
552            "Aider",
553            home.join(".aider/mcp.json"),
554            ConfigType::McpJson,
555        ),
556        "continue" => push(
557            &mut targets,
558            "Continue",
559            home.join(".continue/mcp.json"),
560            ConfigType::McpJson,
561        ),
562        "neovim" => push(
563            &mut targets,
564            "Neovim (mcphub.nvim)",
565            home.join(".config/mcphub/servers.json"),
566            ConfigType::McpJson,
567        ),
568        "emacs" => push(
569            &mut targets,
570            "Emacs (mcp.el)",
571            home.join(".emacs.d/mcp.json"),
572            ConfigType::McpJson,
573        ),
574        "sublime" => push(
575            &mut targets,
576            "Sublime Text",
577            home.join(".config/sublime-text/mcp.json"),
578            ConfigType::McpJson,
579        ),
580        _ => {
581            return Err(format!("Unknown agent '{agent}'"));
582        }
583    }
584
585    for t in &targets {
586        crate::core::editor_registry::remove_lean_ctx_server(
587            t,
588            WriteOptions { overwrite_invalid },
589        )?;
590    }
591
592    Ok(())
593}