Skip to main content

lean_ctx/tools/registered/
ctx_git_read.rs

1use std::path::{Path, PathBuf};
2use std::time::Duration;
3
4use rmcp::ErrorData;
5use rmcp::model::Tool;
6use serde_json::{Map, Value, json};
7
8use crate::core::git::{clone, repo_url, run_git};
9use crate::core::protocol::append_savings;
10use crate::core::tokens::count_tokens;
11use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_int, get_str};
12use crate::tool_defs::tool_def;
13
14const DEFAULT_MAX_TOKENS: usize = 6000;
15const MAX_TREE_LINES: usize = 400;
16const MAX_GREP_LINES: usize = 200;
17
18/// `ctx_git_read` — read a remote repository via a cached shallow clone instead
19/// of scraping its web page. Modes: overview / tree / read / grep.
20pub struct CtxGitReadTool;
21
22impl McpTool for CtxGitReadTool {
23    fn name(&self) -> &'static str {
24        "ctx_git_read"
25    }
26
27    fn tool_def(&self) -> Tool {
28        tool_def(
29            "ctx_git_read",
30            "Read remote git repos via cached shallow clone (not HTML scraping).\n\
31             modes: overview (tree + README) | tree (file list) | read (file content) | grep (search).\n\
32             Accepts repo URLs and GitHub/GitLab blob/tree links (ref+path auto-detected).\n\
33             https-only, SSRF-guarded. Prefer over ctx_url_read for whole-repo access.",
34            json!({
35                "type": "object",
36                "properties": {
37                    "url": { "type": "string", "description": "https repo URL (blob/tree links auto-detect ref+path)" },
38                    "mode": {
39                        "type": "string",
40                        "enum": ["overview", "tree", "read", "grep"],
41                        "description": "overview|tree|read|grep"
42                    },
43                    "path": { "type": "string", "description": "Path within repo" },
44                    "ref": { "type": "string", "description": "Branch/tag/commit (overrides URL ref)" },
45                    "query": { "type": "string", "description": "Search term for grep mode" },
46                    "max_tokens": { "type": "integer", "description": "Token budget (default: 6000)" },
47                    "timeout_secs": { "type": "integer", "description": "Timeout seconds (default: 90, max: 300)" }
48                },
49                "required": ["url"]
50            }),
51        )
52    }
53
54    fn handle(
55        &self,
56        args: &Map<String, Value>,
57        _ctx: &ToolContext,
58    ) -> Result<ToolOutput, ErrorData> {
59        let url = get_str(args, "url")
60            .ok_or_else(|| ErrorData::invalid_params("url is required", None))?;
61
62        let mut repo = repo_url::parse(&url).ok_or_else(|| {
63            ErrorData::invalid_params(
64                "not a recognized https repo URL (expected https://<host>/<owner>/<repo>[/blob|tree/<ref>/<path>])",
65                None,
66            )
67        })?;
68        if let Some(r) = get_str(args, "ref") {
69            repo.git_ref = Some(r);
70        }
71
72        let path = get_str(args, "path").or_else(|| repo.subpath.clone());
73        let mode = get_str(args, "mode").unwrap_or_else(|| {
74            if path.is_some() {
75                "read".to_string()
76            } else {
77                "overview".to_string()
78            }
79        });
80        let max_tokens = get_int(args, "max_tokens")
81            .map_or(DEFAULT_MAX_TOKENS, |n| n.clamp(200, 50_000) as usize);
82        let timeout = Duration::from_secs(
83            get_int(args, "timeout_secs").map_or(clone::DEFAULT_CLONE_TIMEOUT_SECS, |n| {
84                n.clamp(5, 300) as u64
85            }),
86        );
87        let query = get_str(args, "query");
88
89        let result = tokio::task::block_in_place(|| {
90            let dir = clone::ensure_repo(&repo, timeout)?;
91            match mode.as_str() {
92                "overview" => render_overview(&dir, &repo, max_tokens),
93                "tree" => render_tree(&dir, path.as_deref()),
94                "read" => {
95                    let p = path
96                        .as_deref()
97                        .ok_or_else(|| "read mode requires 'path'".to_string())?;
98                    render_read(&dir, &repo, p, max_tokens)
99                }
100                "grep" => {
101                    let q = query
102                        .as_deref()
103                        .ok_or_else(|| "grep mode requires 'query'".to_string())?;
104                    render_grep(&dir, q, path.as_deref())
105                }
106                other => Err(format!(
107                    "invalid mode '{other}' (use: overview, tree, read, grep)"
108                )),
109            }
110        });
111
112        match result {
113            Ok(rendered) => {
114                let sent = count_tokens(&rendered.body);
115                let saved = rendered.original_tokens.saturating_sub(sent);
116                let text = append_savings(&rendered.body, rendered.original_tokens, sent);
117                Ok(ToolOutput {
118                    text,
119                    original_tokens: rendered.original_tokens,
120                    saved_tokens: saved,
121                    mode: Some(mode),
122                    path: Some(rendered.label),
123                    changed: false,
124                    shell_outcome: None,
125                    content_blocks: None,
126                })
127            }
128            Err(e) => Err(ErrorData::invalid_params(
129                format!("ctx_git_read failed: {e}"),
130                None,
131            )),
132        }
133    }
134}
135
136struct Rendered {
137    body: String,
138    original_tokens: usize,
139    label: String,
140}
141
142fn render_overview(
143    dir: &Path,
144    repo: &repo_url::RepoRef,
145    max_tokens: usize,
146) -> Result<Rendered, String> {
147    let files = list_files(dir, None)?;
148    let total = files.len();
149    let top = top_level_summary(&files);
150    let readme = find_and_read_readme(dir).unwrap_or_default();
151
152    let mut body = format!(
153        "# {} ({} files)\n\nRef: {}\n\n## Top-level\n{}\n",
154        repo.project_path(),
155        total,
156        repo.git_ref.as_deref().unwrap_or("default (HEAD)"),
157        top
158    );
159    if !readme.is_empty() {
160        body.push_str("\n## README\n");
161        body.push_str(&readme);
162    }
163    let original_tokens = count_tokens(&body);
164    Ok(Rendered {
165        body: budget(&body, max_tokens),
166        original_tokens,
167        label: format!("{} overview", repo.project_path()),
168    })
169}
170
171fn render_tree(dir: &Path, subpath: Option<&str>) -> Result<Rendered, String> {
172    let files = list_files(dir, subpath)?;
173    let original_tokens = count_tokens(&files.join("\n"));
174    let shown: Vec<&String> = files.iter().take(MAX_TREE_LINES).collect();
175    let mut body = shown
176        .iter()
177        .map(|s| s.as_str())
178        .collect::<Vec<_>>()
179        .join("\n");
180    if files.len() > shown.len() {
181        body.push_str(&format!(
182            "\n… {} more file(s) (narrow with `path`)",
183            files.len() - shown.len()
184        ));
185    }
186    Ok(Rendered {
187        body,
188        original_tokens,
189        label: format!("tree {}", subpath.unwrap_or(".")),
190    })
191}
192
193fn render_read(
194    dir: &Path,
195    repo: &repo_url::RepoRef,
196    rel: &str,
197    max_tokens: usize,
198) -> Result<Rendered, String> {
199    let file = safe_join(dir, rel)?;
200    if file.is_dir() {
201        // A directory was requested in read mode — show its listing instead.
202        return render_tree(dir, Some(rel));
203    }
204    let content = std::fs::read_to_string(&file)
205        .map_err(|e| format!("cannot read {rel}: {e} (is it a text file?)"))?;
206    let header = format!(
207        "// {} @ {}\n",
208        rel,
209        repo.git_ref.as_deref().unwrap_or("HEAD")
210    );
211    let body = format!("{header}{content}");
212    let original_tokens = count_tokens(&body);
213    Ok(Rendered {
214        body: budget(&body, max_tokens),
215        original_tokens,
216        label: format!("{}:{}", repo.project_path(), rel),
217    })
218}
219
220fn render_grep(dir: &Path, query: &str, subpath: Option<&str>) -> Result<Rendered, String> {
221    let mut args: Vec<&str> = vec![
222        "grep",
223        "--no-color",
224        "-n",
225        "-I",
226        "-i",
227        "--heading",
228        "-e",
229        query,
230    ];
231    if let Some(p) = subpath {
232        args.push("--");
233        args.push(p);
234    }
235    let out = run_git(&args, dir, Duration::from_secs(30), &[])?;
236    // git grep exits 1 with no matches — treat that as an empty result, not error.
237    if !out.success && !out.stdout.is_empty() {
238        return Err(out.stderr.trim().to_string());
239    }
240    if out.stdout.trim().is_empty() {
241        return Ok(Rendered {
242            body: format!("No matches for '{query}'."),
243            original_tokens: 0,
244            label: format!("grep '{query}'"),
245        });
246    }
247    let original_tokens = count_tokens(&out.stdout);
248    let body: String = out
249        .stdout
250        .lines()
251        .take(MAX_GREP_LINES)
252        .collect::<Vec<_>>()
253        .join("\n");
254    Ok(Rendered {
255        body,
256        original_tokens,
257        label: format!("grep '{query}'"),
258    })
259}
260
261// ── helpers ─────────────────────────────────────────────────────────────────
262
263/// Tracked files (gitignore-aware), optionally scoped to a subpath.
264fn list_files(dir: &Path, subpath: Option<&str>) -> Result<Vec<String>, String> {
265    let mut args = vec!["ls-files"];
266    if let Some(p) = subpath {
267        args.push("--");
268        args.push(p);
269    }
270    let out = run_git(&args, dir, Duration::from_secs(20), &[])?.ok_stdout()?;
271    Ok(out
272        .lines()
273        .filter(|l| !l.trim().is_empty())
274        .map(str::to_string)
275        .collect())
276}
277
278fn top_level_summary(files: &[String]) -> String {
279    use std::collections::BTreeMap;
280    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
281    for f in files {
282        let top = f.split('/').next().unwrap_or(f);
283        let key = if top == f.as_str() {
284            top.to_string() // a top-level file
285        } else {
286            format!("{top}/")
287        };
288        *counts.entry(key).or_insert(0) += 1;
289    }
290    counts
291        .into_iter()
292        .take(40)
293        .map(|(k, n)| {
294            if k.ends_with('/') {
295                format!("- {k} ({n})")
296            } else {
297                format!("- {k}")
298            }
299        })
300        .collect::<Vec<_>>()
301        .join("\n")
302}
303
304fn find_and_read_readme(dir: &Path) -> Option<String> {
305    for name in [
306        "README.md",
307        "README.MD",
308        "Readme.md",
309        "README",
310        "README.txt",
311    ] {
312        let p = dir.join(name);
313        if p.is_file()
314            && let Ok(s) = std::fs::read_to_string(&p)
315        {
316            return Some(s);
317        }
318    }
319    None
320}
321
322/// Join `rel` under `base`, rejecting any path that escapes `base`.
323fn safe_join(base: &Path, rel: &str) -> Result<PathBuf, String> {
324    let rel = rel.trim_start_matches('/');
325    if rel.split('/').any(|seg| seg == "..") {
326        return Err("path may not contain '..'".to_string());
327    }
328    let joined = base.join(rel);
329    let canon_base = std::fs::canonicalize(base).map_err(|e| e.to_string())?;
330    match std::fs::canonicalize(&joined) {
331        Ok(canon) if canon.starts_with(&canon_base) => Ok(canon),
332        Ok(_) => Err("path escapes the repository".to_string()),
333        Err(e) => Err(format!("path not found: {rel} ({e})")),
334    }
335}
336
337fn budget(content: &str, max_tokens: usize) -> String {
338    let tokens = count_tokens(content);
339    if tokens <= max_tokens {
340        return content.to_string();
341    }
342    let ratio = max_tokens as f64 / tokens as f64;
343    let keep = ((content.chars().count() as f64 * ratio) as usize).max(1);
344    let truncated: String = content.chars().take(keep).collect();
345    format!("{truncated}\n\n…[truncated to ~{max_tokens} tokens]")
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn safe_join_blocks_parent_escape() {
354        let tmp = std::env::temp_dir();
355        assert!(safe_join(&tmp, "../etc/passwd").is_err());
356        assert!(safe_join(&tmp, "a/../../b").is_err());
357    }
358
359    #[test]
360    fn top_level_summary_groups_dirs_and_files() {
361        let files = vec![
362            "README.md".to_string(),
363            "src/a.rs".to_string(),
364            "src/b.rs".to_string(),
365            "tests/t.rs".to_string(),
366        ];
367        let s = top_level_summary(&files);
368        assert!(s.contains("- src/ (2)"));
369        assert!(s.contains("- tests/ (1)"));
370        assert!(s.contains("- README.md"));
371    }
372
373    #[test]
374    fn budget_truncates_oversized_content() {
375        let big = "word ".repeat(4000);
376        let out = budget(&big, 50);
377        assert!(out.contains("[truncated"));
378        assert!(count_tokens(&out) < count_tokens(&big));
379    }
380}