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