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    // Prefer HOME/USERPROFILE so tests and portable installs work on Windows
29    // (dirs::home_dir uses Known Folder API and ignores env overrides).
30    let home = crate::core::home::resolve_home_dir().unwrap_or_default();
31    let mut result = AgentSetupResult::default();
32
33    crate::hooks::install_agent_hook_with_mode(agent_name, global, mode);
34
35    // #281: honor `[setup] auto_update_mcp = false` — skip MCP registration but
36    // still install the hook, rules and skill. Locked-down environments can keep
37    // the MCP server out of agent settings without losing the CLI integration.
38    if crate::core::config::Config::load()
39        .setup
40        .should_update_mcp()
41    {
42        match configure_agent_mcp(agent_name) {
43            Ok(()) => result.mcp_ok = true,
44            Err(e) => result.errors.push(format!("MCP config: {e}")),
45        }
46    } else {
47        result.mcp_skipped = true;
48    }
49
50    result.rules = crate::rules_inject::inject_rules_for_agent(&home, agent_name);
51
52    if let Ok(path) = crate::rules_inject::install_skill_for_agent(&home, agent_name) {
53        result.skill_installed = path.exists();
54    }
55
56    result
57}
58
59pub fn configure_agent_mcp(agent: &str) -> Result<(), String> {
60    // Prefer HOME/USERPROFILE (dirs::home_dir ignores env on Windows).
61    let home = crate::core::home::resolve_home_dir()
62        .ok_or_else(|| "Cannot determine home directory".to_string())?;
63    let binary = resolve_portable_binary();
64
65    let targets = agent_mcp_targets(agent, &home)?;
66
67    let mut errors = Vec::new();
68    for t in &targets {
69        if let Err(e) = crate::core::editor_registry::write_config_with_options(
70            t,
71            &binary,
72            WriteOptions {
73                overwrite_invalid: true,
74            },
75        ) {
76            eprintln!(
77                "\x1b[33m⚠\x1b[0m  Could not configure {}: {}",
78                t.config_path.display(),
79                e
80            );
81            errors.push(e);
82        }
83    }
84
85    if agent == "kiro" {
86        install_kiro_steering(&home);
87    }
88
89    if (agent == "vscode" || agent == "copilot")
90        && let Err(e) = crate::core::editor_registry::plan_mode::write_vscode_plan_settings()
91    {
92        eprintln!("\x1b[33m⚠\x1b[0m  VS Code plan mode: {e}");
93    }
94    if (agent == "claude" || agent == "claude-code")
95        && let Err(e) =
96            crate::core::editor_registry::plan_mode::write_claude_code_plan_permissions()
97    {
98        eprintln!("\x1b[33m⚠\x1b[0m  Claude Code plan mode: {e}");
99    }
100    if agent == "codebuddy"
101        && let Err(e) =
102            crate::core::editor_registry::plan_mode::write_claude_code_plan_permissions()
103    {
104        eprintln!("\x1b[33m⚠\x1b[0m  CodeBuddy plan mode: {e}");
105    }
106
107    if errors.is_empty() {
108        Ok(())
109    } else {
110        Err(format!(
111            "{} config(s) could not be written. See warnings above.",
112            errors.len()
113        ))
114    }
115}
116
117pub(crate) fn agent_mcp_targets(
118    agent: &str,
119    home: &std::path::Path,
120) -> Result<Vec<EditorTarget>, String> {
121    let mut targets = Vec::<EditorTarget>::new();
122
123    let push = |targets: &mut Vec<EditorTarget>,
124                name: &'static str,
125                config_path: PathBuf,
126                config_type: ConfigType| {
127        targets.push(EditorTarget {
128            name,
129            agent_key: agent.to_string(),
130            detect_path: PathBuf::from("/nonexistent"), // not used in direct agent config
131            config_path,
132            config_type,
133        });
134    };
135
136    match agent {
137        "cursor" => push(
138            &mut targets,
139            "Cursor",
140            home.join(".cursor/mcp.json"),
141            ConfigType::McpJson,
142        ),
143        "claude" | "claude-code" => push(
144            &mut targets,
145            "Claude Code",
146            crate::core::editor_registry::claude_mcp_json_path(home),
147            ConfigType::McpJson,
148        ),
149        "codebuddy" => push(
150            &mut targets,
151            "CodeBuddy",
152            crate::core::editor_registry::codebuddy_mcp_json_path(home),
153            ConfigType::McpJson,
154        ),
155        "commandcode" => push(
156            &mut targets,
157            "Command Code",
158            home.join(".commandcode/mcp.json"),
159            ConfigType::CommandCode,
160        ),
161        "augment" => {
162            push(
163                &mut targets,
164                "Augment CLI",
165                crate::core::editor_registry::augment_cli_settings_path(home),
166                ConfigType::McpJson,
167            );
168            push(
169                &mut targets,
170                "Augment (VS Code)",
171                crate::core::editor_registry::augment_vscode_mcp_path(home),
172                ConfigType::AugmentVsCode,
173            );
174        }
175        "windsurf" => push(
176            &mut targets,
177            "Windsurf",
178            home.join(".codeium/windsurf/mcp_config.json"),
179            ConfigType::McpJson,
180        ),
181        "codex" => push(
182            &mut targets,
183            "Codex CLI",
184            crate::core::home::resolve_codex_config_path()
185                .unwrap_or_else(|| home.join(".codex/config.toml")),
186            ConfigType::Codex,
187        ),
188        "grok" => push(
189            &mut targets,
190            "Grok",
191            home.join(".grok/config.toml"),
192            ConfigType::Codex,
193        ),
194        "gemini" => {
195            push(
196                &mut targets,
197                "Gemini CLI",
198                home.join(".gemini/settings.json"),
199                ConfigType::GeminiSettings,
200            );
201            push(
202                &mut targets,
203                "Antigravity IDE",
204                home.join(".gemini/antigravity/mcp_config.json"),
205                ConfigType::McpJson,
206            );
207            push(
208                &mut targets,
209                "Antigravity CLI",
210                home.join(".gemini/antigravity-cli/mcp_config.json"),
211                ConfigType::McpJson,
212            );
213        }
214        "antigravity" => push(
215            &mut targets,
216            "Antigravity IDE",
217            home.join(".gemini/antigravity/mcp_config.json"),
218            ConfigType::McpJson,
219        ),
220        "antigravity-cli" => push(
221            &mut targets,
222            "Antigravity CLI",
223            home.join(".gemini/antigravity-cli/mcp_config.json"),
224            ConfigType::McpJson,
225        ),
226        "copilot" => push(
227            &mut targets,
228            "Copilot CLI",
229            home.join(".copilot/mcp-config.json"),
230            ConfigType::CopilotCli,
231        ),
232        "crush" => push(
233            &mut targets,
234            "Crush",
235            home.join(".config/crush/crush.json"),
236            ConfigType::Crush,
237        ),
238        "qoder" => {
239            for path in crate::core::editor_registry::qoder_all_mcp_paths(home) {
240                push(&mut targets, "Qoder", path, ConfigType::QoderSettings);
241            }
242        }
243        "qoderwork" => push(
244            &mut targets,
245            "QoderWork",
246            crate::core::editor_registry::qoderwork_mcp_path(home),
247            ConfigType::McpJson,
248        ),
249        "qodercli" => push(
250            &mut targets,
251            "Qoder CLI",
252            crate::core::editor_registry::qodercli_settings_path(home),
253            ConfigType::QoderSettings,
254        ),
255        "cline" => push(
256            &mut targets,
257            "Cline",
258            crate::core::editor_registry::cline_mcp_path(),
259            ConfigType::McpJson,
260        ),
261        "roo" => push(
262            &mut targets,
263            "Roo Code",
264            crate::core::editor_registry::roo_mcp_path(),
265            ConfigType::McpJson,
266        ),
267        "kiro" => push(
268            &mut targets,
269            "AWS Kiro",
270            home.join(".kiro/settings/mcp.json"),
271            ConfigType::McpJson,
272        ),
273        "verdent" => push(
274            &mut targets,
275            "Verdent",
276            home.join(".verdent/mcp.json"),
277            ConfigType::McpJson,
278        ),
279        // pi: deliberately no MCP target. Pi has no native MCP adapter — a
280        // ~/.pi/agent/mcp.json entry is never served and made older pi-lean-ctx
281        // versions disable their embedded bridge (GitHub #361). Pi runs through
282        // the pi-lean-ctx npm package; install_pi_hook_with_mode removes stale
283        // entries instead.
284        "pi" | "jetbrains" | "amp" | "openclaw" => {
285            // jetbrains/amp/openclaw: handled by dedicated install hooks
286            // (servers[] array / amp.mcpServers / mcp.servers).
287        }
288        "qwen" => push(
289            &mut targets,
290            "Qwen Code",
291            home.join(".qwen/settings.json"),
292            ConfigType::McpJson,
293        ),
294        "trae" => push(
295            &mut targets,
296            "Trae",
297            home.join(".trae/mcp.json"),
298            ConfigType::McpJson,
299        ),
300        "amazonq" => push(
301            &mut targets,
302            "Amazon Q Developer",
303            home.join(".aws/amazonq/default.json"),
304            ConfigType::McpJson,
305        ),
306        "opencode" => {
307            #[cfg(windows)]
308            let opencode_path = if let Ok(appdata) = std::env::var("APPDATA") {
309                std::path::PathBuf::from(appdata)
310                    .join("opencode")
311                    .join("opencode.json")
312            } else {
313                home.join(".config/opencode/opencode.json")
314            };
315            #[cfg(not(windows))]
316            let opencode_path = home.join(".config/opencode/opencode.json");
317            push(
318                &mut targets,
319                "OpenCode",
320                opencode_path,
321                ConfigType::OpenCode,
322            );
323        }
324        "hermes" => push(
325            &mut targets,
326            "Hermes Agent",
327            home.join(".hermes/config.yaml"),
328            ConfigType::HermesYaml,
329        ),
330        "vscode" => push(
331            &mut targets,
332            "VS Code",
333            crate::core::editor_registry::vscode_mcp_path(),
334            ConfigType::VsCodeMcp,
335        ),
336        "vscode-insiders" => push(
337            &mut targets,
338            "VS Code Insiders",
339            crate::core::editor_registry::vscode_insiders_mcp_path(),
340            ConfigType::VsCodeMcp,
341        ),
342        "zed" => push(
343            &mut targets,
344            "Zed",
345            crate::core::editor_registry::zed_settings_path(home),
346            ConfigType::Zed,
347        ),
348        "aider" => push(
349            &mut targets,
350            "Aider",
351            home.join(".aider/mcp.json"),
352            ConfigType::McpJson,
353        ),
354        "continue" => push(
355            &mut targets,
356            "Continue",
357            home.join(".continue/mcp.json"),
358            ConfigType::McpJson,
359        ),
360        "neovim" => push(
361            &mut targets,
362            "Neovim (mcphub.nvim)",
363            home.join(".config/mcphub/servers.json"),
364            ConfigType::McpJson,
365        ),
366        "emacs" => push(
367            &mut targets,
368            "Emacs (mcp.el)",
369            home.join(".emacs.d/mcp.json"),
370            ConfigType::McpJson,
371        ),
372        "sublime" => push(
373            &mut targets,
374            "Sublime Text",
375            home.join(".config/sublime-text/mcp.json"),
376            ConfigType::McpJson,
377        ),
378        _ => {
379            return Err(format!("Unknown agent '{agent}'"));
380        }
381    }
382
383    Ok(targets)
384}
385
386pub fn disable_agent_mcp(agent: &str, overwrite_invalid: bool) -> Result<(), String> {
387    // Prefer HOME/USERPROFILE (dirs::home_dir ignores env on Windows).
388    let home = crate::core::home::resolve_home_dir()
389        .ok_or_else(|| "Cannot determine home directory".to_string())?;
390
391    let mut targets = Vec::<EditorTarget>::new();
392
393    let push = |targets: &mut Vec<EditorTarget>,
394                name: &'static str,
395                config_path: PathBuf,
396                config_type: ConfigType| {
397        targets.push(EditorTarget {
398            name,
399            agent_key: agent.to_string(),
400            detect_path: PathBuf::from("/nonexistent"),
401            config_path,
402            config_type,
403        });
404    };
405
406    let pi_cfg = home.join(".pi").join("agent").join("mcp.json");
407
408    match agent {
409        "cursor" => push(
410            &mut targets,
411            "Cursor",
412            home.join(".cursor/mcp.json"),
413            ConfigType::McpJson,
414        ),
415        "claude" | "claude-code" => push(
416            &mut targets,
417            "Claude Code",
418            crate::core::editor_registry::claude_mcp_json_path(&home),
419            ConfigType::McpJson,
420        ),
421        "codebuddy" => push(
422            &mut targets,
423            "CodeBuddy",
424            crate::core::editor_registry::codebuddy_mcp_json_path(&home),
425            ConfigType::McpJson,
426        ),
427        "commandcode" => push(
428            &mut targets,
429            "Command Code",
430            home.join(".commandcode/mcp.json"),
431            ConfigType::CommandCode,
432        ),
433        "augment" => {
434            push(
435                &mut targets,
436                "Augment CLI",
437                crate::core::editor_registry::augment_cli_settings_path(&home),
438                ConfigType::McpJson,
439            );
440            push(
441                &mut targets,
442                "Augment (VS Code)",
443                crate::core::editor_registry::augment_vscode_mcp_path(&home),
444                ConfigType::AugmentVsCode,
445            );
446        }
447        "windsurf" => push(
448            &mut targets,
449            "Windsurf",
450            home.join(".codeium/windsurf/mcp_config.json"),
451            ConfigType::McpJson,
452        ),
453        "codex" => push(
454            &mut targets,
455            "Codex CLI",
456            crate::core::home::resolve_codex_config_path()
457                .unwrap_or_else(|| home.join(".codex/config.toml")),
458            ConfigType::Codex,
459        ),
460        "grok" => push(
461            &mut targets,
462            "Grok",
463            home.join(".grok/config.toml"),
464            ConfigType::Codex,
465        ),
466        "gemini" => {
467            push(
468                &mut targets,
469                "Gemini CLI",
470                home.join(".gemini/settings.json"),
471                ConfigType::GeminiSettings,
472            );
473            push(
474                &mut targets,
475                "Antigravity IDE",
476                home.join(".gemini/antigravity/mcp_config.json"),
477                ConfigType::McpJson,
478            );
479            push(
480                &mut targets,
481                "Antigravity CLI",
482                home.join(".gemini/antigravity-cli/mcp_config.json"),
483                ConfigType::McpJson,
484            );
485        }
486        "antigravity" => push(
487            &mut targets,
488            "Antigravity IDE",
489            home.join(".gemini/antigravity/mcp_config.json"),
490            ConfigType::McpJson,
491        ),
492        "antigravity-cli" => push(
493            &mut targets,
494            "Antigravity CLI",
495            home.join(".gemini/antigravity-cli/mcp_config.json"),
496            ConfigType::McpJson,
497        ),
498        "copilot" => push(
499            &mut targets,
500            "Copilot CLI",
501            home.join(".copilot/mcp-config.json"),
502            ConfigType::CopilotCli,
503        ),
504        "crush" => push(
505            &mut targets,
506            "Crush",
507            home.join(".config/crush/crush.json"),
508            ConfigType::Crush,
509        ),
510        "pi" => push(&mut targets, "Pi Coding Agent", pi_cfg, ConfigType::McpJson),
511        "qoder" => {
512            for path in crate::core::editor_registry::qoder_all_mcp_paths(&home) {
513                push(&mut targets, "Qoder", path, ConfigType::QoderSettings);
514            }
515        }
516        "qoderwork" => push(
517            &mut targets,
518            "QoderWork",
519            crate::core::editor_registry::qoderwork_mcp_path(&home),
520            ConfigType::McpJson,
521        ),
522        "qodercli" => push(
523            &mut targets,
524            "Qoder CLI",
525            crate::core::editor_registry::qodercli_settings_path(&home),
526            ConfigType::QoderSettings,
527        ),
528        "cline" => push(
529            &mut targets,
530            "Cline",
531            crate::core::editor_registry::cline_mcp_path(),
532            ConfigType::McpJson,
533        ),
534        "roo" => push(
535            &mut targets,
536            "Roo Code",
537            crate::core::editor_registry::roo_mcp_path(),
538            ConfigType::McpJson,
539        ),
540        "kiro" => push(
541            &mut targets,
542            "AWS Kiro",
543            home.join(".kiro/settings/mcp.json"),
544            ConfigType::McpJson,
545        ),
546        "verdent" => push(
547            &mut targets,
548            "Verdent",
549            home.join(".verdent/mcp.json"),
550            ConfigType::McpJson,
551        ),
552        "jetbrains" | "amp" | "openclaw" => {
553            // Not supported for disable via this helper.
554        }
555        "qwen" => push(
556            &mut targets,
557            "Qwen Code",
558            home.join(".qwen/settings.json"),
559            ConfigType::McpJson,
560        ),
561        "trae" => push(
562            &mut targets,
563            "Trae",
564            home.join(".trae/mcp.json"),
565            ConfigType::McpJson,
566        ),
567        "amazonq" => push(
568            &mut targets,
569            "Amazon Q Developer",
570            home.join(".aws/amazonq/default.json"),
571            ConfigType::McpJson,
572        ),
573        "opencode" => {
574            #[cfg(windows)]
575            let opencode_path = if let Ok(appdata) = std::env::var("APPDATA") {
576                std::path::PathBuf::from(appdata)
577                    .join("opencode")
578                    .join("opencode.json")
579            } else {
580                home.join(".config/opencode/opencode.json")
581            };
582            #[cfg(not(windows))]
583            let opencode_path = home.join(".config/opencode/opencode.json");
584            push(
585                &mut targets,
586                "OpenCode",
587                opencode_path,
588                ConfigType::OpenCode,
589            );
590        }
591        "hermes" => push(
592            &mut targets,
593            "Hermes Agent",
594            home.join(".hermes/config.yaml"),
595            ConfigType::HermesYaml,
596        ),
597        "vscode" => push(
598            &mut targets,
599            "VS Code",
600            crate::core::editor_registry::vscode_mcp_path(),
601            ConfigType::VsCodeMcp,
602        ),
603        "vscode-insiders" => push(
604            &mut targets,
605            "VS Code Insiders",
606            crate::core::editor_registry::vscode_insiders_mcp_path(),
607            ConfigType::VsCodeMcp,
608        ),
609        "zed" => push(
610            &mut targets,
611            "Zed",
612            crate::core::editor_registry::zed_settings_path(&home),
613            ConfigType::Zed,
614        ),
615        "aider" => push(
616            &mut targets,
617            "Aider",
618            home.join(".aider/mcp.json"),
619            ConfigType::McpJson,
620        ),
621        "continue" => push(
622            &mut targets,
623            "Continue",
624            home.join(".continue/mcp.json"),
625            ConfigType::McpJson,
626        ),
627        "neovim" => push(
628            &mut targets,
629            "Neovim (mcphub.nvim)",
630            home.join(".config/mcphub/servers.json"),
631            ConfigType::McpJson,
632        ),
633        "emacs" => push(
634            &mut targets,
635            "Emacs (mcp.el)",
636            home.join(".emacs.d/mcp.json"),
637            ConfigType::McpJson,
638        ),
639        "sublime" => push(
640            &mut targets,
641            "Sublime Text",
642            home.join(".config/sublime-text/mcp.json"),
643            ConfigType::McpJson,
644        ),
645        _ => {
646            return Err(format!("Unknown agent '{agent}'"));
647        }
648    }
649
650    for t in &targets {
651        crate::core::editor_registry::remove_lean_ctx_server(
652            t,
653            WriteOptions { overwrite_invalid },
654        )?;
655    }
656
657    Ok(())
658}
659
660#[cfg(test)]
661mod qodercli_tests {
662    use super::*;
663
664    #[test]
665    fn commandcode_agent_target_uses_command_code_schema() {
666        let home = std::path::Path::new("/home/tester");
667        let targets = agent_mcp_targets("commandcode", home).unwrap();
668
669        assert_eq!(targets.len(), 1);
670        assert_eq!(targets[0].name, "Command Code");
671        assert_eq!(targets[0].config_path, home.join(".commandcode/mcp.json"));
672        assert_eq!(targets[0].config_type, ConfigType::CommandCode);
673    }
674
675    #[test]
676    fn qodercli_agent_target_uses_settings_json() {
677        let home = std::path::Path::new("/home/tester");
678        let targets = agent_mcp_targets("qodercli", home).unwrap();
679
680        assert_eq!(targets.len(), 1);
681        assert_eq!(targets[0].name, "Qoder CLI");
682        assert_eq!(targets[0].config_path, home.join(".qoder/settings.json"));
683    }
684
685    #[test]
686    fn qodercli_agent_target_uses_qoder_settings_writer() {
687        let home = std::path::Path::new("/home/tester");
688        let targets = agent_mcp_targets("qodercli", home).unwrap();
689
690        assert_eq!(targets[0].agent_key, "qodercli");
691        assert_eq!(targets[0].config_type, ConfigType::QoderSettings);
692    }
693
694    #[test]
695    fn qodercli_agent_target_is_separate_from_qoder_ide_targets() {
696        let home = std::path::Path::new("/home/tester");
697        let cli = agent_mcp_targets("qodercli", home).unwrap();
698        let ide = agent_mcp_targets("qoder", home).unwrap();
699
700        assert!(
701            ide.iter()
702                .all(|target| target.config_path != cli[0].config_path)
703        );
704    }
705}