1use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use rmcp::ErrorData;
6use rmcp::model::Tool;
7use serde_json::{Map, Value, json};
8
9use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_int, get_str};
10use lean_ctx::core::git::{clone, repo_url, run_git};
11use lean_ctx::core::protocol::append_savings;
12use lean_ctx::core::tokens::count_tokens;
13use lean_ctx::tool_defs::tool_def;
14
15const DEFAULT_MAX_TOKENS: usize = 6000;
16const MAX_TREE_LINES: usize = 400;
17const MAX_GREP_LINES: usize = 200;
18const TOOL: &str = "ctx_git_read";
20
21pub struct CtxGitReadTool;
24
25impl McpTool for CtxGitReadTool {
26 fn name(&self) -> &'static str {
27 "ctx_git_read"
28 }
29
30 fn tool_def(&self) -> Tool {
31 tool_def(
32 "ctx_git_read",
33 "Read remote git repos via cached shallow clone (not HTML scraping).\n\
34 modes: overview (tree + README) | tree (file list) | read (file content) | grep (search).\n\
35 Accepts repo URLs and GitHub/GitLab blob/tree links (ref+path auto-detected).\n\
36 https-only, SSRF-guarded. Prefer over ctx_url_read for whole-repo access.",
37 json!({
38 "type": "object",
39 "properties": {
40 "url": { "type": "string", "description": "https repo URL (blob/tree links auto-detect ref+path)" },
41 "mode": {
42 "type": "string",
43 "enum": ["overview", "tree", "read", "grep"],
44 "description": "overview|tree|read|grep"
45 },
46 "path": { "type": "string", "description": "Path within repo" },
47 "ref": { "type": "string", "description": "Branch/tag/commit (overrides URL ref)" },
48 "query": { "type": "string", "description": "Search term for grep mode" },
49 "max_tokens": { "type": "integer", "description": "Token budget (default: 6000)" },
50 "timeout_secs": { "type": "integer", "description": "Timeout seconds (default: 90, max: 300)" }
51 },
52 "allOf": [
53 {
54 "if": { "properties": { "mode": { "const": "grep" } }, "required": ["mode"] },
55 "then": { "required": ["mode", "query"] }
56 }
57 ],
58 "required": ["url"]
59 }),
60 )
61 }
62
63 fn handle(
64 &self,
65 args: &Map<String, Value>,
66 _ctx: &ToolContext,
67 ) -> Result<ToolOutput, ErrorData> {
68 let url = get_str(args, "url")
69 .ok_or_else(|| ErrorData::invalid_params("url is required", None))?;
70
71 let mut repo = repo_url::parse(&url).ok_or_else(|| {
72 ErrorData::invalid_params(
73 "not a recognized https repo URL (expected https://<host>/<owner>/<repo>[/blob|tree/<ref>/<path>])",
74 None,
75 )
76 })?;
77 if let Some(r) = get_str(args, "ref") {
78 repo.git_ref = Some(r);
79 }
80
81 let path = get_str(args, "path").or_else(|| repo.subpath.clone());
82 let mode = get_str(args, "mode").unwrap_or_else(|| {
83 if path.is_some() {
84 "read".to_string()
85 } else {
86 "overview".to_string()
87 }
88 });
89 let max_tokens = get_int(args, "max_tokens")
90 .map_or(DEFAULT_MAX_TOKENS, |n| n.clamp(200, 50_000) as usize);
91 let timeout = Duration::from_secs(
92 get_int(args, "timeout_secs").map_or(clone::DEFAULT_CLONE_TIMEOUT_SECS, |n| {
93 n.clamp(5, 300) as u64
94 }),
95 );
96 let query = get_str(args, "query");
97
98 let result = tokio::task::block_in_place(|| {
99 let dir = clone::ensure_repo(&repo, timeout)?;
100 match mode.as_str() {
101 "overview" => render_overview(&dir, &repo, max_tokens),
102 "tree" => render_tree(&dir, path.as_deref()),
103 "read" => {
104 let p = path
105 .as_deref()
106 .ok_or_else(|| "read mode requires 'path'".to_string())?;
107 render_read(&dir, &repo, p, max_tokens)
108 }
109 "grep" => {
110 let q = query
111 .as_deref()
112 .ok_or_else(|| "grep mode requires 'query'".to_string())?;
113 render_grep(&dir, q, path.as_deref())
114 }
115 other => Err(format!(
116 "invalid mode '{other}' (use: overview, tree, read, grep)"
117 )),
118 }
119 });
120
121 match result {
122 Ok(rendered) => {
123 let sent = count_tokens(&rendered.body);
124 let saved = rendered.original_tokens.saturating_sub(sent);
125 let text = append_savings(&rendered.body, rendered.original_tokens, sent);
126 Ok(ToolOutput {
127 text,
128 original_tokens: rendered.original_tokens,
129 saved_tokens: saved,
130 mode: Some(mode),
131 path: Some(rendered.label),
132 changed: false,
133 shell_outcome: None,
134 content_blocks: None,
135 })
136 }
137 Err(e) => Err(ErrorData::invalid_params(
138 format!("ctx_git_read failed: {e}"),
139 None,
140 )),
141 }
142 }
143}
144
145struct Rendered {
146 body: String,
147 original_tokens: usize,
148 label: String,
149}
150
151fn render_overview(
152 dir: &Path,
153 repo: &repo_url::RepoRef,
154 max_tokens: usize,
155) -> Result<Rendered, String> {
156 let files = list_files(dir, None)?;
157 let total = files.len();
158 let top = top_level_summary(&files);
159
160 let mut body = format!(
161 "# {} ({} files)\n\nRef: {}\n\n## Top-level\n{}\n",
162 repo.project_path(),
163 total,
164 repo.git_ref.as_deref().unwrap_or("default (HEAD)"),
165 top
166 );
167 match find_and_read_readme(dir) {
168 Some((_, Ok(readme))) if !readme.is_empty() => {
169 body.push_str("\n## README\n");
170 body.push_str(&readme);
171 }
172 Some((name, Err(reason))) => {
173 body.push_str("\n## README\n");
176 body.push_str(&crate::tools::admitted_sources::withheld_line(
177 name, &reason,
178 ));
179 }
180 Some((_, Ok(_))) | None => {}
181 }
182 let original_tokens = count_tokens(&body);
183 Ok(Rendered {
184 body: budget(&body, max_tokens),
185 original_tokens,
186 label: format!("{} overview", repo.project_path()),
187 })
188}
189
190fn render_tree(dir: &Path, subpath: Option<&str>) -> Result<Rendered, String> {
191 let files = list_files(dir, subpath)?;
192 let original_tokens = count_tokens(&files.join("\n"));
193 let shown: Vec<&String> = files.iter().take(MAX_TREE_LINES).collect();
194 let mut body = shown
195 .iter()
196 .map(|s| s.as_str())
197 .collect::<Vec<_>>()
198 .join("\n");
199 if files.len() > shown.len() {
200 body.push_str(&format!(
201 "\n… {} more file(s) (narrow with `path`)",
202 files.len() - shown.len()
203 ));
204 }
205 Ok(Rendered {
206 body,
207 original_tokens,
208 label: format!("tree {}", subpath.unwrap_or(".")),
209 })
210}
211
212fn render_read(
213 dir: &Path,
214 repo: &repo_url::RepoRef,
215 rel: &str,
216 max_tokens: usize,
217) -> Result<Rendered, String> {
218 let file = safe_join(dir, rel)?;
219 if file.is_dir() {
220 return render_tree(dir, Some(rel));
222 }
223 let content = admit_clone_file(dir, rel)
226 .map_err(|reason| format!("{rel}: {reason}"))?
227 .text;
228 let header = format!(
229 "// {} @ {}\n",
230 rel,
231 repo.git_ref.as_deref().unwrap_or("HEAD")
232 );
233 let body = format!("{header}{content}");
234 let original_tokens = count_tokens(&body);
235 Ok(Rendered {
236 body: budget(&body, max_tokens),
237 original_tokens,
238 label: format!("{}:{}", repo.project_path(), rel),
239 })
240}
241
242fn render_grep(dir: &Path, query: &str, subpath: Option<&str>) -> Result<Rendered, String> {
243 let mut args: Vec<&str> = vec![
244 "grep",
245 "--no-color",
246 "-n",
247 "-I",
248 "-i",
249 "--heading",
250 "-e",
251 query,
252 ];
253 if let Some(p) = subpath {
254 args.push("--");
255 args.push(p);
256 }
257 let out = run_git(&args, dir, Duration::from_secs(30), &[])?;
258 if !out.success && !out.stdout.is_empty() {
260 return Err(out.stderr.trim().to_string());
261 }
262 let scope = list_files(dir, subpath)?;
265 let original_tokens = count_tokens(&out.stdout);
266 Ok(Rendered {
267 body: admitted_grep(dir, query, &out.stdout, &scope),
268 original_tokens,
269 label: format!("grep '{query}'"),
270 })
271}
272
273const MAX_GREP_SCOPE_FILES: usize = 2000;
275const MAX_GREP_SCOPE_BYTES: u64 = 64 * 1024 * 1024;
277
278fn admitted_grep(dir: &Path, query: &str, stdout: &str, scope: &[String]) -> String {
291 let matched: std::collections::HashSet<&str> = stdout
292 .lines()
293 .filter(|line| matched_line_number(line).is_none())
294 .collect();
295
296 let mut admitted: std::collections::HashMap<&str, Vec<String>> =
297 std::collections::HashMap::new();
298 let mut withheld = 0usize;
299 let mut covered = 0usize;
300 let mut bytes_left = MAX_GREP_SCOPE_BYTES;
301 for file in scope.iter().take(MAX_GREP_SCOPE_FILES) {
302 let Ok(bytes) = read_clone_text_bytes(dir, file) else {
303 covered += 1;
306 continue;
307 };
308 if bytes.len() as u64 > bytes_left {
309 break;
310 }
311 bytes_left -= bytes.len() as u64;
312 covered += 1;
313 match crate::tools::admitted_sources::admit_remote_file(bytes, file, TOOL) {
314 Ok(source) => {
315 if matched.contains(file.as_str()) {
316 admitted.insert(
317 file.as_str(),
318 source.text.lines().map(str::to_string).collect(),
319 );
320 }
321 }
322 Err(_) => withheld += 1,
323 }
324 }
325
326 let mut rendered: Vec<String> = Vec::new();
327 let mut current: Option<&Vec<String>> = None;
329 for line in stdout.lines() {
330 if rendered.len() >= MAX_GREP_LINES {
331 break;
332 }
333 if let Some(number) = matched_line_number(line) {
334 if let Some(text) = current.and_then(|lines| lines.get(number - 1)) {
337 rendered.push(format!("{number}:{text}"));
338 }
339 continue;
340 }
341 current = admitted.get(line);
342 if current.is_some() {
343 rendered.push(line.to_string());
344 }
345 }
346
347 let mut body = if rendered.is_empty() {
348 format!("No matches for '{query}'.")
349 } else {
350 rendered.join("\n")
351 };
352 if withheld > 0 {
353 body.push_str(&format!("\n({withheld} sources withheld under policy)"));
355 }
356 if covered < scope.len() {
357 body.push_str(&format!(
358 "\n(search covered the first {covered} of {} files — narrow with `path`)",
359 scope.len()
360 ));
361 }
362 body
363}
364
365fn matched_line_number(line: &str) -> Option<usize> {
367 let (number, _) = line.split_once(':')?;
368 number.parse::<usize>().ok().filter(|number| *number > 0)
369}
370
371fn read_clone_text_bytes(dir: &Path, rel: &str) -> Result<Vec<u8>, String> {
375 let file = safe_join(dir, rel)?;
376 let cap = lean_ctx::core::limits::max_read_bytes() as u64;
377 let size = std::fs::metadata(&file)
378 .map_err(|e| format!("cannot read file: {e}"))?
379 .len();
380 if size > cap {
381 return Err(format!(
382 "file too large ({size} bytes, limit {cap} bytes via LCTX_MAX_READ_BYTES)"
383 ));
384 }
385 let bytes = std::fs::read(&file).map_err(|e| format!("cannot read file: {e}"))?;
386 if lean_ctx::core::binary_detect::has_binary_extension(rel)
387 || lean_ctx::core::text_decode::looks_binary(&bytes[..bytes.len().min(8192)])
388 {
389 return Err("source format is not inspectable as text".to_owned());
390 }
391 Ok(bytes)
392}
393
394fn admit_clone_file(
397 dir: &Path,
398 rel: &str,
399) -> Result<lean_ctx::core::context_admission::SourceAdmission, String> {
400 let bytes = read_clone_text_bytes(dir, rel)?;
401 crate::tools::admitted_sources::admit_remote_file(bytes, rel, TOOL)
402}
403
404fn list_files(dir: &Path, subpath: Option<&str>) -> Result<Vec<String>, String> {
408 let mut args = vec!["ls-files"];
409 if let Some(p) = subpath {
410 args.push("--");
411 args.push(p);
412 }
413 let out = run_git(&args, dir, Duration::from_secs(20), &[])?.ok_stdout()?;
414 Ok(out
415 .lines()
416 .filter(|l| !l.trim().is_empty())
417 .map(str::to_string)
418 .collect())
419}
420
421fn top_level_summary(files: &[String]) -> String {
422 use std::collections::BTreeMap;
423 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
424 for f in files {
425 let top = f.split('/').next().unwrap_or(f);
426 let key = if top == f.as_str() {
427 top.to_string() } else {
429 format!("{top}/")
430 };
431 *counts.entry(key).or_insert(0) += 1;
432 }
433 counts
434 .into_iter()
435 .take(40)
436 .map(|(k, n)| {
437 if k.ends_with('/') {
438 format!("- {k} ({n})")
439 } else {
440 format!("- {k}")
441 }
442 })
443 .collect::<Vec<_>>()
444 .join("\n")
445}
446
447fn find_and_read_readme(dir: &Path) -> Option<(&'static str, Result<String, String>)> {
450 for name in [
451 "README.md",
452 "README.MD",
453 "Readme.md",
454 "README",
455 "README.txt",
456 ] {
457 if dir.join(name).is_file() {
458 return Some((
459 name,
460 admit_clone_file(dir, name).map(|admitted| admitted.text),
461 ));
462 }
463 }
464 None
465}
466
467fn safe_join(base: &Path, rel: &str) -> Result<PathBuf, String> {
469 let rel = rel.trim_start_matches('/');
470 if rel.split('/').any(|seg| seg == "..") {
471 return Err("path may not contain '..'".to_string());
472 }
473 let joined = base.join(rel);
474 let canon_base = std::fs::canonicalize(base).map_err(|e| e.to_string())?;
475 match std::fs::canonicalize(&joined) {
476 Ok(canon) if canon.starts_with(&canon_base) => Ok(canon),
477 Ok(_) => Err("path escapes the repository".to_string()),
478 Err(e) => Err(format!("path not found: {rel} ({e})")),
479 }
480}
481
482fn budget(content: &str, max_tokens: usize) -> String {
483 let tokens = count_tokens(content);
484 if tokens <= max_tokens {
485 return content.to_string();
486 }
487 let ratio = max_tokens as f64 / tokens as f64;
488 let keep = ((content.chars().count() as f64 * ratio) as usize).max(1);
489 let truncated: String = content.chars().take(keep).collect();
490 lean_ctx::core::recovery::with_original_pointer(
491 "ctx_git_read",
492 "budget",
493 content,
494 format!("{truncated}\n\n…[truncated to ~{max_tokens} tokens]"),
495 )
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 #[test]
503 fn safe_join_blocks_parent_escape() {
504 let tmp = std::env::temp_dir();
505 assert!(safe_join(&tmp, "../etc/passwd").is_err());
506 assert!(safe_join(&tmp, "a/../../b").is_err());
507 }
508
509 #[test]
510 fn top_level_summary_groups_dirs_and_files() {
511 let files = vec![
512 "README.md".to_string(),
513 "src/a.rs".to_string(),
514 "src/b.rs".to_string(),
515 "tests/t.rs".to_string(),
516 ];
517 let s = top_level_summary(&files);
518 assert!(s.contains("- src/ (2)"));
519 assert!(s.contains("- tests/ (1)"));
520 assert!(s.contains("- README.md"));
521 }
522
523 const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
524
525 fn clone_file(dir: &Path, rel: &str, content: &str) {
526 let path = dir.join(rel);
527 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
528 std::fs::write(path, content).unwrap();
529 }
530
531 fn repo() -> repo_url::RepoRef {
532 repo_url::parse("https://github.com/acme/widgets").expect("repo url")
533 }
534
535 fn blocking_policy() -> lean_ctx::core::policy::ResolvedPolicy {
536 lean_ctx::core::policy::load(
537 "name = \"ctx-git-read-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
538 [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
539 )
540 .unwrap()
541 }
542
543 #[test]
548 fn a_remote_file_is_admitted_before_it_is_rendered() {
549 let _data = lean_ctx::core::data_dir::isolated_data_dir();
550 let clone = tempfile::tempdir().unwrap();
551 let clean = "pub fn widget() -> u8 {\n 7\n}\n";
552 clone_file(clone.path(), "src/lib.rs", clean);
553 clone_file(
554 clone.path(),
555 "deploy/settings.py",
556 &format!("AWS_ACCESS_KEY_ID = \"{AWS_KEY}\"\nREGION = \"eu-central-2\"\n"),
557 );
558 clone_file(
559 clone.path(),
560 "docs/plan.md",
561 "CONFIDENTIAL\nroadmap-marker for the launch\n",
562 );
563 {
564 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
565 let rendered = render_read(clone.path(), &repo(), "src/lib.rs", 6000).unwrap();
566 assert_eq!(rendered.body, format!("// src/lib.rs @ HEAD\n{clean}"));
567
568 let rendered = render_read(clone.path(), &repo(), "deploy/settings.py", 6000).unwrap();
569 assert!(!rendered.body.contains(AWS_KEY), "{}", rendered.body);
570 assert!(rendered.body.contains("eu-central-2"), "{}", rendered.body);
571 }
572 let _policy =
573 lean_ctx::core::policy::runtime::TestPolicyOverride::set(Some(blocking_policy()));
574 let Err(reason) = render_read(clone.path(), &repo(), "docs/plan.md", 6000) else {
575 panic!("a blocked remote file must not be rendered");
576 };
577 assert!(reason.contains("withheld"), "{reason}");
578 assert!(!reason.contains("roadmap-marker"), "{reason}");
579 }
580
581 #[test]
585 fn overview_readme_and_grep_lines_come_from_admitted_sources() {
586 let _data = lean_ctx::core::data_dir::isolated_data_dir();
587 let clone = tempfile::tempdir().unwrap();
588 clone_file(
589 clone.path(),
590 "notes.txt",
591 "alpha widget\nbeta\ngamma widget\n",
592 );
593 clone_file(
594 clone.path(),
595 "deploy/settings.py",
596 &format!("# widget deploy\nAWS_ACCESS_KEY_ID = \"{AWS_KEY}\" # widget\n"),
597 );
598 clone_file(
599 clone.path(),
600 "docs/plan.md",
601 "CONFIDENTIAL\nwidget roadmap-marker\n",
602 );
603 clone_file(clone.path(), "README.md", "CONFIDENTIAL\nreadme-marker\n");
604 let git_output = format!(
606 "deploy/settings.py\n1:# widget deploy\n2:AWS_ACCESS_KEY_ID = \"{AWS_KEY}\" # widget\n\
607 docs/plan.md\n2:widget roadmap-marker\n\
608 notes.txt\n1:alpha widget\n3:gamma widget\n"
609 );
610 let scope: Vec<String> = [
612 "README.md",
613 "deploy/settings.py",
614 "docs/plan.md",
615 "notes.txt",
616 ]
617 .map(String::from)
618 .to_vec();
619 {
620 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
622 let clean_output = "notes.txt\n1:alpha widget\n3:gamma widget\n";
623 assert_eq!(
624 admitted_grep(clone.path(), "widget", clean_output, &scope),
625 clean_output.trim_end()
626 );
627 let body = admitted_grep(clone.path(), "widget", &git_output, &scope);
628 assert!(!body.contains(AWS_KEY), "{body}");
629 assert!(body.contains("1:# widget deploy"), "{body}");
630 }
631 let _policy =
632 lean_ctx::core::policy::runtime::TestPolicyOverride::set(Some(blocking_policy()));
633 let body = admitted_grep(clone.path(), "widget", &git_output, &scope);
634 assert!(!body.contains("roadmap-marker"), "{body}");
635 assert!(
636 !body.contains("docs/plan.md"),
637 "a withheld file is not named: {body}"
638 );
639 assert!(
640 body.contains("notes.txt\n1:alpha widget\n3:gamma widget"),
641 "{body}"
642 );
643 let keyed = admit_clone_file(clone.path(), "deploy/settings.py")
649 .expect("a source with a masked key is delivered under a pack");
650 assert_eq!(
651 keyed.text,
652 "# widget deploy\nAWS_ACCESS_KEY_ID = \"[REDACTED:AWS key]\" # widget\n"
653 );
654 assert!(
655 body.contains(
656 "deploy/settings.py\n1:# widget deploy\n\
657 2:AWS_ACCESS_KEY_ID = \"[REDACTED:AWS key]\" # widget"
658 ),
659 "{body}"
660 );
661 assert!(!body.contains(AWS_KEY), "{body}");
662 assert!(
664 body.ends_with("\n(2 sources withheld under policy)"),
665 "{body}"
666 );
667
668 let only_withheld = admitted_grep(
671 clone.path(),
672 "q",
673 "docs/plan.md\n2:widget roadmap-marker\n",
674 &scope,
675 );
676 let nothing = admitted_grep(clone.path(), "q", "", &scope);
677 assert_eq!(only_withheld, nothing);
678 assert_eq!(
679 nothing,
680 "No matches for 'q'.\n(2 sources withheld under policy)"
681 );
682
683 let (name, readme) = find_and_read_readme(clone.path()).expect("README present");
684 assert_eq!(name, "README.md");
685 let reason = readme.expect_err("a blocked README is withheld");
686 assert!(!reason.contains("readme-marker"), "{reason}");
687 }
688
689 #[test]
690 fn budget_truncates_oversized_content() {
691 let big = "word ".repeat(4000);
692 let out = budget(&big, 50);
693 assert!(out.contains("[truncated"));
694 assert!(count_tokens(&out) < count_tokens(&big));
695 }
696}