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                })
126            }
127            Err(e) => Err(ErrorData::invalid_params(
128                format!("ctx_git_read failed: {e}"),
129                None,
130            )),
131        }
132    }
133}
134
135struct Rendered {
136    body: String,
137    original_tokens: usize,
138    label: String,
139}
140
141fn render_overview(
142    dir: &Path,
143    repo: &repo_url::RepoRef,
144    max_tokens: usize,
145) -> Result<Rendered, String> {
146    let files = list_files(dir, None)?;
147    let total = files.len();
148    let top = top_level_summary(&files);
149    let readme = find_and_read_readme(dir).unwrap_or_default();
150
151    let mut body = format!(
152        "# {} ({} files)\n\nRef: {}\n\n## Top-level\n{}\n",
153        repo.project_path(),
154        total,
155        repo.git_ref.as_deref().unwrap_or("default (HEAD)"),
156        top
157    );
158    if !readme.is_empty() {
159        body.push_str("\n## README\n");
160        body.push_str(&readme);
161    }
162    let original_tokens = count_tokens(&body);
163    Ok(Rendered {
164        body: budget(&body, max_tokens),
165        original_tokens,
166        label: format!("{} overview", repo.project_path()),
167    })
168}
169
170fn render_tree(dir: &Path, subpath: Option<&str>) -> Result<Rendered, String> {
171    let files = list_files(dir, subpath)?;
172    let original_tokens = count_tokens(&files.join("\n"));
173    let shown: Vec<&String> = files.iter().take(MAX_TREE_LINES).collect();
174    let mut body = shown
175        .iter()
176        .map(|s| s.as_str())
177        .collect::<Vec<_>>()
178        .join("\n");
179    if files.len() > shown.len() {
180        body.push_str(&format!(
181            "\n… {} more file(s) (narrow with `path`)",
182            files.len() - shown.len()
183        ));
184    }
185    Ok(Rendered {
186        body,
187        original_tokens,
188        label: format!("tree {}", subpath.unwrap_or(".")),
189    })
190}
191
192fn render_read(
193    dir: &Path,
194    repo: &repo_url::RepoRef,
195    rel: &str,
196    max_tokens: usize,
197) -> Result<Rendered, String> {
198    let file = safe_join(dir, rel)?;
199    if file.is_dir() {
200        // A directory was requested in read mode — show its listing instead.
201        return render_tree(dir, Some(rel));
202    }
203    let content = std::fs::read_to_string(&file)
204        .map_err(|e| format!("cannot read {rel}: {e} (is it a text file?)"))?;
205    let header = format!(
206        "// {} @ {}\n",
207        rel,
208        repo.git_ref.as_deref().unwrap_or("HEAD")
209    );
210    let body = format!("{header}{content}");
211    let original_tokens = count_tokens(&body);
212    Ok(Rendered {
213        body: budget(&body, max_tokens),
214        original_tokens,
215        label: format!("{}:{}", repo.project_path(), rel),
216    })
217}
218
219fn render_grep(dir: &Path, query: &str, subpath: Option<&str>) -> Result<Rendered, String> {
220    let mut args: Vec<&str> = vec![
221        "grep",
222        "--no-color",
223        "-n",
224        "-I",
225        "-i",
226        "--heading",
227        "-e",
228        query,
229    ];
230    if let Some(p) = subpath {
231        args.push("--");
232        args.push(p);
233    }
234    let out = run_git(&args, dir, Duration::from_secs(30), &[])?;
235    // git grep exits 1 with no matches — treat that as an empty result, not error.
236    if !out.success && !out.stdout.is_empty() {
237        return Err(out.stderr.trim().to_string());
238    }
239    if out.stdout.trim().is_empty() {
240        return Ok(Rendered {
241            body: format!("No matches for '{query}'."),
242            original_tokens: 0,
243            label: format!("grep '{query}'"),
244        });
245    }
246    let original_tokens = count_tokens(&out.stdout);
247    let body: String = out
248        .stdout
249        .lines()
250        .take(MAX_GREP_LINES)
251        .collect::<Vec<_>>()
252        .join("\n");
253    Ok(Rendered {
254        body,
255        original_tokens,
256        label: format!("grep '{query}'"),
257    })
258}
259
260// ── helpers ─────────────────────────────────────────────────────────────────
261
262/// Tracked files (gitignore-aware), optionally scoped to a subpath.
263fn list_files(dir: &Path, subpath: Option<&str>) -> Result<Vec<String>, String> {
264    let mut args = vec!["ls-files"];
265    if let Some(p) = subpath {
266        args.push("--");
267        args.push(p);
268    }
269    let out = run_git(&args, dir, Duration::from_secs(20), &[])?.ok_stdout()?;
270    Ok(out
271        .lines()
272        .filter(|l| !l.trim().is_empty())
273        .map(str::to_string)
274        .collect())
275}
276
277fn top_level_summary(files: &[String]) -> String {
278    use std::collections::BTreeMap;
279    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
280    for f in files {
281        let top = f.split('/').next().unwrap_or(f);
282        let key = if top == f.as_str() {
283            top.to_string() // a top-level file
284        } else {
285            format!("{top}/")
286        };
287        *counts.entry(key).or_insert(0) += 1;
288    }
289    counts
290        .into_iter()
291        .take(40)
292        .map(|(k, n)| {
293            if k.ends_with('/') {
294                format!("- {k} ({n})")
295            } else {
296                format!("- {k}")
297            }
298        })
299        .collect::<Vec<_>>()
300        .join("\n")
301}
302
303fn find_and_read_readme(dir: &Path) -> Option<String> {
304    for name in [
305        "README.md",
306        "README.MD",
307        "Readme.md",
308        "README",
309        "README.txt",
310    ] {
311        let p = dir.join(name);
312        if p.is_file()
313            && let Ok(s) = std::fs::read_to_string(&p)
314        {
315            return Some(s);
316        }
317    }
318    None
319}
320
321/// Join `rel` under `base`, rejecting any path that escapes `base`.
322fn safe_join(base: &Path, rel: &str) -> Result<PathBuf, String> {
323    let rel = rel.trim_start_matches('/');
324    if rel.split('/').any(|seg| seg == "..") {
325        return Err("path may not contain '..'".to_string());
326    }
327    let joined = base.join(rel);
328    let canon_base = std::fs::canonicalize(base).map_err(|e| e.to_string())?;
329    match std::fs::canonicalize(&joined) {
330        Ok(canon) if canon.starts_with(&canon_base) => Ok(canon),
331        Ok(_) => Err("path escapes the repository".to_string()),
332        Err(e) => Err(format!("path not found: {rel} ({e})")),
333    }
334}
335
336fn budget(content: &str, max_tokens: usize) -> String {
337    let tokens = count_tokens(content);
338    if tokens <= max_tokens {
339        return content.to_string();
340    }
341    let ratio = max_tokens as f64 / tokens as f64;
342    let keep = ((content.chars().count() as f64 * ratio) as usize).max(1);
343    let truncated: String = content.chars().take(keep).collect();
344    format!("{truncated}\n\n…[truncated to ~{max_tokens} tokens]")
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn safe_join_blocks_parent_escape() {
353        let tmp = std::env::temp_dir();
354        assert!(safe_join(&tmp, "../etc/passwd").is_err());
355        assert!(safe_join(&tmp, "a/../../b").is_err());
356    }
357
358    #[test]
359    fn top_level_summary_groups_dirs_and_files() {
360        let files = vec![
361            "README.md".to_string(),
362            "src/a.rs".to_string(),
363            "src/b.rs".to_string(),
364            "tests/t.rs".to_string(),
365        ];
366        let s = top_level_summary(&files);
367        assert!(s.contains("- src/ (2)"));
368        assert!(s.contains("- tests/ (1)"));
369        assert!(s.contains("- README.md"));
370    }
371
372    #[test]
373    fn budget_truncates_oversized_content() {
374        let big = "word ".repeat(4000);
375        let out = budget(&big, 50);
376        assert!(out.contains("[truncated"));
377        assert!(count_tokens(&out) < count_tokens(&big));
378    }
379}