1use std::path::{Path, PathBuf};
2use std::time::Duration;
3
4use rmcp::model::Tool;
5use rmcp::ErrorData;
6use serde_json::{json, Map, Value};
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::{get_int, get_str, McpTool, ToolContext, ToolOutput};
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 a remote git repository via a cached shallow clone (not HTML scraping).\n\
31 modes: overview (tree + README) | tree (file list) | read (a file) | grep (search).\n\
32 Accepts repo URLs and GitHub/GitLab blob/tree links (ref + path auto-detected). https-only, SSRF-guarded, bounded.\n\
33 Use instead of ctx_url_read when you need a whole repo's files/structure.",
34 json!({
35 "type": "object",
36 "properties": {
37 "url": { "type": "string", "description": "https repo URL, optionally a blob/tree link carrying ref + path" },
38 "mode": {
39 "type": "string",
40 "enum": ["overview", "tree", "read", "grep"],
41 "description": "Default: read when a path is present, else overview"
42 },
43 "path": { "type": "string", "description": "File (read) or directory (tree/grep) within the repo" },
44 "ref": { "type": "string", "description": "Branch/tag/commit (overrides any ref in the URL)" },
45 "query": { "type": "string", "description": "Search term for grep mode" },
46 "max_tokens": { "type": "integer", "description": "Token budget for returned content (default: 6000)" },
47 "timeout_secs": { "type": "integer", "description": "Clone/fetch timeout (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 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 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
260fn 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() } 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 if let Ok(s) = std::fs::read_to_string(&p) {
314 return Some(s);
315 }
316 }
317 }
318 None
319}
320
321fn 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}