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 "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 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 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
267fn 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() } 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
328fn 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}