Skip to main content

shuyi_cli/mcp/
install.rs

1//! `shuyi mcp install`: registers the server with the agents it finds, in
2//! each one's project file of MCP servers, in the project's folder (or
3//! the working folder outside one). It shows what changes and asks before
4//! writing, and keeps each file it changes as `<file>.bak`, then runs the
5//! entry it wrote with `--ready`, so the server is built before an agent
6//! first connects and waits for it. `--dry-run` writes nothing and prints
7//! each file as it would write it.
8//!
9//! | Agent | File | Found by |
10//! | ----- | ---- | -------- |
11//! | Claude Code | `.mcp.json` | always |
12//! | Cursor | `.cursor/mcp.json` | `.cursor/` here or in the home folder |
13//! | VS Code | `.vscode/mcp.json` | `.vscode/` here, or `code` on `PATH` |
14//! | Gemini CLI | `.gemini/settings.json` | `.gemini/` here or in the home folder |
15//!
16//! Claude Code's file is also what `claude --mcp-config <file>` reads.
17//! For Claude Code it also sets `MCP_TIMEOUT` in the project's
18//! `.claude/settings.json`, so Claude Code waits for the server's first
19//! build to finish ([`SETTINGS`]).
20
21use std::fmt::Write as _;
22use std::fs;
23use std::io::BufRead;
24use std::path::{Path, PathBuf};
25use std::process::Command;
26use std::time::{Duration, Instant};
27
28use serde_json::{Map, Value, json};
29
30use crate::project::Project;
31
32/// The server's name in each agent's file.
33pub const NAME: &str = "shuyi";
34
35/// Claude Code's project settings, from the project's folder.
36pub const SETTINGS: &str = ".claude/settings.json";
37
38/// How long Claude Code waits for a server to start, in milliseconds
39/// (its `MCP_TIMEOUT`; 30 s if unset): 5 minutes, so a cold build of the
40/// server finishes first.
41pub const MCP_TIMEOUT: u64 = 300_000;
42
43/// An agent the server can be registered with.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct Agent {
46    /// Its name, for people.
47    pub name: &'static str,
48    /// Its file of MCP servers, from the project's folder.
49    pub file: &'static str,
50    /// The key of the servers in it (`mcpServers`).
51    pub key: &'static str,
52    /// Whether an entry says its transport (`"type": "stdio"`).
53    pub typed: bool,
54    /// The folders, in the project or the home folder, that show it is
55    /// used.
56    pub folders: &'static [&'static str],
57    /// The programs on `PATH` that show it is installed.
58    pub programs: &'static [&'static str],
59}
60
61/// Every agent the server can be registered with, Claude Code first.
62pub const AGENTS: [Agent; 4] = [
63    Agent {
64        name: "Claude Code",
65        file: ".mcp.json",
66        key: "mcpServers",
67        typed: false,
68        folders: &[],
69        programs: &[],
70    },
71    Agent {
72        name: "Cursor",
73        file: ".cursor/mcp.json",
74        key: "mcpServers",
75        typed: false,
76        folders: &[".cursor"],
77        programs: &[],
78    },
79    Agent {
80        name: "VS Code",
81        file: ".vscode/mcp.json",
82        key: "servers",
83        typed: true,
84        folders: &[".vscode"],
85        programs: &["code"],
86    },
87    Agent {
88        name: "Gemini CLI",
89        file: ".gemini/settings.json",
90        key: "mcpServers",
91        typed: false,
92        folders: &[".gemini"],
93        programs: &[],
94    },
95];
96
97/// Where agents are looked for on this machine.
98#[derive(Debug, Clone, Default, PartialEq, Eq)]
99pub struct Machine {
100    /// The home folder.
101    pub home: Option<PathBuf>,
102    /// The folders on `PATH`.
103    pub path: Vec<PathBuf>,
104}
105
106impl Machine {
107    /// This machine's.
108    pub fn this() -> Self {
109        let home = std::env::var_os("HOME")
110            .or_else(|| std::env::var_os("USERPROFILE"))
111            .map(PathBuf::from);
112        let path = std::env::var_os("PATH")
113            .map(|path| std::env::split_paths(&path).collect())
114            .unwrap_or_default();
115        Self { home, path }
116    }
117
118    /// Whether `program` is on `PATH` (as itself, or with an extension
119    /// Windows runs).
120    fn has_program(&self, program: &str) -> bool {
121        self.path.iter().any(|dir| {
122            ["", ".exe", ".cmd", ".bat"]
123                .iter()
124                .any(|ext| dir.join(format!("{program}{ext}")).is_file())
125        })
126    }
127}
128
129impl Agent {
130    /// Whether it is used in `root` or on `machine`: Claude Code always.
131    pub fn found(&self, root: &Path, machine: &Machine) -> bool {
132        if self.folders.is_empty() && self.programs.is_empty() {
133            return true;
134        }
135        let in_folder = self.folders.iter().any(|folder| {
136            root.join(folder).is_dir()
137                || machine
138                    .home
139                    .as_ref()
140                    .is_some_and(|home| home.join(folder).is_dir())
141        });
142        in_folder || self.programs.iter().any(|p| machine.has_program(p))
143    }
144
145    /// Its entry for the server, run as `command`'s words and `mcp`.
146    pub fn entry(&self, command: &str) -> Value {
147        let mut words = command.split_whitespace().map(str::to_owned);
148        let program = words.next().unwrap_or_else(|| NAME.to_owned());
149        let mut args: Vec<String> = words.collect();
150        args.push("mcp".to_owned());
151        let mut entry = Map::new();
152        if self.typed {
153            entry.insert("type".to_owned(), json!("stdio"));
154        }
155        entry.insert("command".to_owned(), json!(program));
156        entry.insert("args".to_owned(), json!(args));
157        Value::Object(entry)
158    }
159
160    /// Its file with the server's entry for `command` set, from the file
161    /// as it is (`old`, if there is one), everything else in it kept; or
162    /// why it can't be (not a JSON object).
163    pub fn file_text(&self, old: Option<&str>, command: &str) -> Result<String, String> {
164        let mut file = match old.map(str::trim) {
165            None | Some("") => Map::new(),
166            Some(text) => match serde_json::from_str::<Value>(text) {
167                Ok(Value::Object(file)) => file,
168                Ok(_) => return Err(format!("{}: not a JSON object", self.file)),
169                Err(e) => return Err(format!("{}: not JSON ({e})", self.file)),
170            },
171        };
172        let servers = file
173            .entry(self.key.to_owned())
174            .or_insert_with(|| Value::Object(Map::new()));
175        let Value::Object(servers) = servers else {
176            return Err(format!("{}: `{}` is not an object", self.file, self.key));
177        };
178        servers.insert(NAME.to_owned(), self.entry(command));
179        let mut text = serde_json::to_string_pretty(&Value::Object(file)).unwrap_or_default();
180        text.push('\n');
181        Ok(text)
182    }
183}
184
185/// Claude Code's settings with `env`'s `MCP_TIMEOUT` at least
186/// [`MCP_TIMEOUT`], from the file as it is (`old`, if there is one),
187/// everything else in it kept; or why it can't be (not a JSON object).
188pub fn settings_text(old: Option<&str>) -> Result<String, String> {
189    let mut file = match old.map(str::trim) {
190        None | Some("") => Map::new(),
191        Some(text) => match serde_json::from_str::<Value>(text) {
192            Ok(Value::Object(file)) => file,
193            Ok(_) => return Err(format!("{SETTINGS}: not a JSON object")),
194            Err(e) => return Err(format!("{SETTINGS}: not JSON ({e})")),
195        },
196    };
197    let env = file
198        .entry("env".to_owned())
199        .or_insert_with(|| Value::Object(Map::new()));
200    let Value::Object(env) = env else {
201        return Err(format!("{SETTINGS}: `env` is not an object"));
202    };
203    // A longer wait already set is kept.
204    let set = env
205        .get("MCP_TIMEOUT")
206        .and_then(Value::as_str)
207        .and_then(|ms| ms.trim().parse::<u64>().ok());
208    if set.is_none_or(|ms| ms < MCP_TIMEOUT) {
209        env.insert("MCP_TIMEOUT".to_owned(), json!(MCP_TIMEOUT.to_string()));
210    }
211    let mut text = serde_json::to_string_pretty(&Value::Object(file)).unwrap_or_default();
212    text.push('\n');
213    Ok(text)
214}
215
216/// One file to write.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct Planned {
219    /// The agent's name.
220    pub agent: &'static str,
221    /// The file.
222    pub path: PathBuf,
223    /// Its text now, if it is there.
224    pub old: Option<String>,
225    /// Its text with the server registered.
226    pub new: String,
227}
228
229/// The file of each agent found in `root` or on `machine`, with the
230/// server run as `command`'s words and `mcp`, and Claude Code's settings
231/// after its file.
232pub fn plan(root: &Path, machine: &Machine, command: &str) -> Result<Vec<Planned>, String> {
233    let mut planned = Vec::new();
234    for agent in AGENTS.iter().filter(|agent| agent.found(root, machine)) {
235        let path = root.join(agent.file);
236        let old = fs::read_to_string(&path).ok();
237        let new = agent.file_text(old.as_deref(), command)?;
238        planned.push(Planned {
239            agent: agent.name,
240            path,
241            old,
242            new,
243        });
244        if agent.file == AGENTS[0].file {
245            let path = root.join(SETTINGS);
246            let old = fs::read_to_string(&path).ok();
247            let new = settings_text(old.as_deref())?;
248            planned.push(Planned {
249                agent: agent.name,
250                path,
251                old,
252                new,
253            });
254        }
255    }
256    Ok(planned)
257}
258
259/// The lines of `old` and `new`, each marked: `  ` both have it, `- `
260/// only `old`, `+ ` only `new` (a longest common subsequence).
261pub fn diff(old: &str, new: &str) -> String {
262    let (a, b): (Vec<&str>, Vec<&str>) = (old.lines().collect(), new.lines().collect());
263    // common[i][j]: the longest common subsequence of a[i..] and b[j..].
264    let mut common = vec![vec![0usize; b.len() + 1]; a.len() + 1];
265    for i in (0..a.len()).rev() {
266        for j in (0..b.len()).rev() {
267            common[i][j] = if a[i] == b[j] {
268                common[i + 1][j + 1] + 1
269            } else {
270                common[i + 1][j].max(common[i][j + 1])
271            };
272        }
273    }
274    let (mut i, mut j, mut out) = (0, 0, String::new());
275    // Each line is marked once, so a step for each line of either side is
276    // enough (a shared line takes one step for two).
277    for _ in 0..a.len() + b.len() {
278        if i == a.len() && j == b.len() {
279            break;
280        }
281        if i < a.len() && j < b.len() && a[i] == b[j] {
282            let _ = writeln!(out, "  {}", a[i]);
283            (i, j) = (i + 1, j + 1);
284        } else if i < a.len() && common[i + 1][j] == common[i][j] {
285            // Leaving a[i] out keeps the most lines shared.
286            let _ = writeln!(out, "- {}", a[i]);
287            i += 1;
288        } else {
289            let _ = writeln!(out, "+ {}", b[j]);
290            j += 1;
291        }
292    }
293    out
294}
295
296/// What `install` was asked.
297#[derive(Debug, Clone, Default, PartialEq, Eq)]
298pub struct Options {
299    /// Write nothing; print each file as it would be written.
300    pub dry_run: bool,
301    /// Don't ask before writing.
302    pub yes: bool,
303    /// How to run `shuyi`, if given.
304    pub command: Option<String>,
305}
306
307/// Reads `install`'s arguments.
308pub fn parse_args(args: &[String]) -> Result<Options, String> {
309    let mut options = Options::default();
310    let mut iter = args.iter();
311    while let Some(arg) = iter.next() {
312        match arg.as_str() {
313            "--dry-run" => options.dry_run = true,
314            "--yes" => options.yes = true,
315            "--command" => {
316                let command = iter.next().ok_or("--command needs a value")?;
317                if command.trim().is_empty() {
318                    return Err("--command: the words that run shuyi, not nothing".to_owned());
319                }
320                options.command = Some(command.clone());
321            }
322            other => return Err(format!("unknown argument: {other}")),
323        }
324    }
325    Ok(options)
326}
327
328/// Installs as `options` say in `root`, on `machine`, the server run as
329/// `command`; `ask` is asked whether to write the files it is shown.
330/// After writing, `ready` is given what there is to print so far and runs
331/// the server with `--ready`: how long that took, or why it failed.
332/// Returns what to print, or the error.
333pub fn install(
334    root: &Path,
335    options: &Options,
336    machine: &Machine,
337    command: &str,
338    ask: &mut dyn FnMut(&str) -> bool,
339    ready: &mut dyn FnMut(&str) -> Result<Duration, String>,
340) -> Result<String, String> {
341    let planned = plan(root, machine, command)?;
342    let (same, changed): (Vec<Planned>, Vec<Planned>) = planned
343        .into_iter()
344        .partition(|p| p.old.as_deref() == Some(p.new.as_str()));
345    let mut out = String::new();
346    let shown = |p: &Planned| shown_path(root, &p.path);
347    for p in &same {
348        let _ = writeln!(
349            out,
350            "mcp install: {} ({}) is set up already",
351            shown(p),
352            p.agent
353        );
354    }
355    if options.dry_run {
356        for p in &changed {
357            let _ = writeln!(out, "mcp install: would write {} ({}):", shown(p), p.agent);
358            out.push_str(&p.new);
359        }
360        return Ok(out);
361    }
362    if changed.is_empty() {
363        return Ok(out);
364    }
365    let mut question = String::new();
366    for p in &changed {
367        let _ = writeln!(question, "{} ({}):", shown(p), p.agent);
368        question.push_str(&diff(p.old.as_deref().unwrap_or(""), &p.new));
369    }
370    let _ = write!(question, "write {} file(s)? [y/N] ", changed.len());
371    if !options.yes && !ask(&question) {
372        out.push_str("mcp install: nothing written\n");
373        return Ok(out);
374    }
375    for p in &changed {
376        if let Some(dir) = p.path.parent() {
377            fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
378        }
379        let mut wrote = format!("mcp install: wrote {} ({})", shown(p), p.agent);
380        if p.old.is_some() {
381            let mut backup = p.path.clone().into_os_string();
382            backup.push(".bak");
383            let backup = PathBuf::from(backup);
384            fs::copy(&p.path, &backup).map_err(|e| format!("{}: {e}", backup.display()))?;
385            let _ = write!(wrote, ", its old text in {}", shown_path(root, &backup));
386        }
387        fs::write(&p.path, &p.new).map_err(|e| format!("{}: {e}", p.path.display()))?;
388        let _ = writeln!(out, "{wrote}");
389    }
390    let took = ready(&std::mem::take(&mut out))?;
391    let _ = writeln!(
392        out,
393        "mcp install: the server is ready (\"{command} mcp --ready\" took {:.1} s)",
394        took.as_secs_f64()
395    );
396    Ok(out)
397}
398
399/// Runs the server as `command`'s words and `mcp --ready` in `root`, its
400/// output passed through: how long it took, or why it failed.
401pub fn run_ready(root: &Path, command: &str) -> Result<Duration, String> {
402    let mut words = command.split_whitespace();
403    let program = words.next().unwrap_or(NAME);
404    let started = Instant::now();
405    let status = Command::new(program)
406        .args(words)
407        .args(["mcp", "--ready"])
408        .current_dir(root)
409        .status()
410        .map_err(|e| format!("cannot run \"{command} mcp --ready\": {e}"))?;
411    if status.success() {
412        return Ok(started.elapsed());
413    }
414    let code = status
415        .code()
416        .map_or_else(String::new, |c| format!(" (exit code {c})"));
417    Err(format!(
418        "\"{command} mcp --ready\" failed{code}: the server won't start until it builds; its \
419         error is above"
420    ))
421}
422
423/// Whether the line `input` gives says yes (`y`, `Y` or `yes`).
424pub fn said_yes(input: &mut impl BufRead) -> bool {
425    let mut answer = String::new();
426    input.read_line(&mut answer).is_ok() && matches!(answer.trim(), "y" | "Y" | "yes")
427}
428
429/// `path` from `root`, for people.
430fn shown_path(root: &Path, path: &Path) -> String {
431    path.strip_prefix(root)
432        .unwrap_or(path)
433        .display()
434        .to_string()
435}
436
437/// `shuyi mcp install` with `args` in the working folder `cwd`: in the
438/// project's folder (or `cwd` outside one), the server run as
439/// `--command`, or the project's `command`, or `shuyi`. Returns the exit
440/// code.
441pub fn main(args: &[String], cwd: &Path) -> u8 {
442    let options = match parse_args(args) {
443        Ok(options) => options,
444        Err(e) => {
445            eprintln!("mcp install: {e}\n\n{}", super::usage("shuyi"));
446            return 2;
447        }
448    };
449    let project = Project::find(cwd).ok();
450    let root = project.as_ref().map_or(cwd, |p| p.root.as_path());
451    let command = options.command.clone().unwrap_or_else(|| {
452        project
453            .as_ref()
454            .map_or("shuyi", |p| p.config.command())
455            .to_owned()
456    });
457    let mut ask = |question: &str| {
458        print!("{question}");
459        let _ = std::io::Write::flush(&mut std::io::stdout());
460        said_yes(&mut std::io::stdin().lock())
461    };
462    let mut ready = |said: &str| {
463        print!("{said}");
464        let _ = std::io::Write::flush(&mut std::io::stdout());
465        run_ready(root, &command)
466    };
467    let machine = Machine::this();
468    match install(root, &options, &machine, &command, &mut ask, &mut ready) {
469        Ok(out) => {
470            print!("{out}");
471            0
472        }
473        Err(e) => {
474            eprintln!("mcp install: {e}");
475            1
476        }
477    }
478}
479
480#[cfg(test)]
481mod tests;