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
18pub 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 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 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
261fn 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() } 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
322fn 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}