Skip to main content

sofie_cli/mcp/
mod.rs

1mod cargo;
2mod changes;
3mod dry_run;
4mod edit;
5mod generate;
6mod instructions;
7mod protocol;
8mod read;
9mod request;
10mod session;
11mod task;
12mod tools;
13mod traffic;
14
15use std::fmt::Write as _;
16use std::io::{self, BufRead, Write};
17use std::path::Path;
18
19use crate::child_process::stop_on_interrupt;
20use crate::gen_command::GenError;
21pub(crate) use cargo::build_failure;
22
23/// The most an answer holds, in bytes; a test run can print megabytes.
24const MAX_ANSWER: usize = 16 * 1024;
25
26/// Every tool reads the app afresh, so edits between calls show.
27pub fn serve_mcp(root: &Path, input: impl BufRead, mut output: impl Write) -> io::Result<()> {
28    let root = root.canonicalize().map_err(|error| {
29        io::Error::new(
30            error.kind(),
31            format!("the app root {} cannot be read: {error}", root.display()),
32        )
33    })?;
34    stop_on_interrupt()?;
35    let mut session = session::DevSession::new(&root);
36    for line in input.split(b'\n') {
37        let line = line?;
38        let response = match String::from_utf8(line) {
39            Ok(line) if line.trim().is_empty() => continue,
40            Ok(line) => protocol::answer(&root, &mut session, &line),
41            Err(_) => Some(protocol::not_utf8()),
42        };
43        if let Some(response) = response {
44            writeln!(output, "{response}")?;
45            output.flush()?;
46        }
47    }
48    Ok(())
49}
50
51pub(crate) struct ToolOutput {
52    text: String,
53    failed: bool,
54    /// The command printing what a cut answer leaves out.
55    rest: Option<String>,
56}
57
58impl ToolOutput {
59    fn done(text: String) -> Self {
60        Self::judged(text, true)
61    }
62
63    fn failed(text: String) -> Self {
64        Self::judged(text, false)
65    }
66
67    fn judged(text: String, succeeded: bool) -> Self {
68        Self {
69            text,
70            failed: !succeeded,
71            rest: None,
72        }
73    }
74
75    fn with_rest(mut self, rest: String) -> Self {
76        self.rest = Some(rest);
77        self
78    }
79
80    /// Paths are shown relative to the app root, and the answer is bounded only here.
81    fn finished(mut self, root: &Path) -> Self {
82        let shown = root.display().to_string();
83        let relative = self
84            .text
85            .replace(&format!("{shown}/"), "")
86            .replace(&shown, ".");
87        self.text = bounded(&relative, self.rest.as_deref());
88        self
89    }
90
91    fn from_error(root: &Path, error: &GenError) -> Self {
92        match error {
93            GenError::Check(findings) => Self::failed(sofie_check::report(root, findings)),
94            GenError::Build(diagnostics) => {
95                Self::failed(diagnostics.clone()).with_rest(cargo::build_rest())
96            }
97            other => Self::failed(other.to_string()),
98        }
99    }
100}
101
102/// `rest` is the command printing what was cut.
103fn bounded(text: &str, rest: Option<&str>) -> String {
104    if text.len() <= MAX_ANSWER {
105        return text.to_owned();
106    }
107    let end = text.floor_char_boundary(MAX_ANSWER);
108    let mut cut = format!(
109        "{}\n… cut at {MAX_ANSWER} bytes of {}",
110        &text[..end],
111        text.len()
112    );
113    if let Some(rest) = rest {
114        let _ = write!(cut, "; run `{rest}` for the rest");
115    }
116    cut
117}
118
119#[cfg(test)]
120mod tests {
121    use std::path::Path;
122
123    use super::{MAX_ANSWER, ToolOutput, bounded};
124
125    #[test]
126    fn a_long_answer_keeps_its_hint_for_the_rest() {
127        let text = format!("{}\n", "/app/src/lib.rs: warning\n".repeat(MAX_ANSWER));
128
129        let output = ToolOutput::judged(text, false)
130            .with_rest("cargo test --offline".to_owned())
131            .finished(Path::new("/app"));
132
133        assert!(output.failed);
134        assert!(
135            output.text.starts_with("src/lib.rs: warning"),
136            "{}",
137            &output.text[..40]
138        );
139        assert!(
140            output
141                .text
142                .ends_with("run `cargo test --offline` for the rest"),
143            "{}",
144            &output.text[output.text.len() - 120..]
145        );
146    }
147
148    #[test]
149    fn a_long_answer_is_cut_at_a_character_boundary_and_says_so() {
150        let text = "é".repeat(MAX_ANSWER);
151
152        let cut = bounded(&text, Some("cargo test --offline"));
153
154        assert!(cut.len() < MAX_ANSWER + 200, "{}", cut.len());
155        assert!(
156            cut.ends_with("run `cargo test --offline` for the rest"),
157            "{cut}"
158        );
159        assert_eq!(bounded("short", None), "short");
160    }
161}