lean_ctx_server/tools/registered/
ctx_read_dispatch.rs1use super::{
5 CtxReadTool, ErrorData, Map, McpTool, Tool, ToolContext, ToolOutput, Value, engine, get_str,
6 record_attribution_result, require_resolved_path,
7};
8
9impl McpTool for CtxReadTool {
10 fn name(&self) -> &'static str {
11 "ctx_read"
12 }
13
14 fn produces_machine_readable(&self, args: Option<&Map<String, Value>>) -> bool {
15 crate::server::native_receipts::requested("ctx_read", args)
18 }
19
20 fn tool_def(&self) -> Tool {
21 super::schema::ctx_read_tool_def()
22 }
23
24 fn handle(
25 &self,
26 args: &Map<String, Value>,
27 ctx: &ToolContext,
28 ) -> Result<ToolOutput, ErrorData> {
29 let engine_interface_v1 = engine::interface_v1_requested(args)?;
30 engine::validate_v1_request_shape(args, engine_interface_v1)?;
31 if args
34 .get("paths")
35 .and_then(|v| v.as_array())
36 .is_some_and(|a| !a.is_empty())
37 {
38 let result = super::super::ctx_read_batch::batch_read(args, ctx);
39 if let Ok(output) = &result {
40 record_attribution_result(ctx, "ctx_read batch".to_string(), output);
41 }
42 return result;
43 }
44
45 let path = if let Some(repo) = get_str(args, "repo") {
46 let root = lean_ctx::core::multi_repo::resolve_repo_root(&repo).ok_or_else(|| {
47 let known = lean_ctx::core::multi_repo::known_aliases().join(", ");
48 let known = if known.is_empty() {
49 "none registered — use ctx_multi_repo add_root".to_string()
50 } else {
51 known
52 };
53 ErrorData::invalid_params(
54 format!("unknown repo alias: {repo} (known: {known})"),
55 None,
56 )
57 })?;
58 let rel = get_str(args, "path").unwrap_or_else(|| ".".to_string());
59 lean_ctx::core::path_resolve::resolve_tool_path(Some(&root), None, &rel)
60 .map_err(|e| ErrorData::invalid_params(e, None))?
61 } else {
62 require_resolved_path(ctx, args, "path")?
63 };
64
65 let byte_exact = engine_interface_v1
71 || get_str(args, "mode").as_deref() == Some("raw")
72 || args.get("raw").and_then(Value::as_bool) == Some(true);
73 let (path, substituted) = match (!byte_exact)
74 .then(|| {
75 lean_ctx::core::path_suggest::unambiguous_match_in(
76 std::path::Path::new(&path),
77 std::path::Path::new(&ctx.project_root),
78 )
79 })
80 .flatten()
81 .and_then(|found| ctx.resolve_path_sync(&found.to_string_lossy()).ok())
82 {
83 Some(found) => (found, Some(path)),
84 None => (path, None),
85 };
86
87 let alias_args = super::window::apply_lines_alias(args);
88 let args = alias_args.as_ref().unwrap_or(args);
89
90 let symbol_args;
93 let mut symbol_note = None;
94 let args = match get_str(args, "symbol").filter(|s| !s.trim().is_empty()) {
95 None => args,
96 Some(symbol) => {
97 if byte_exact {
98 return Err(ErrorData::invalid_params(
99 "symbol= cannot combine with raw or engine_interface",
100 None,
101 ));
102 }
103 let (rewritten, note) = symbol_request(args, &path, &symbol)?;
104 symbol_args = rewritten;
105 symbol_note = note;
106 &symbol_args
107 }
108 };
109
110 let result = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
111 self.handle_inner(args, ctx, &path, engine_interface_v1)
112 })) {
113 Ok(result) => result,
114 Err(_) => Err(ErrorData::internal_error(
115 format!(
116 "ctx_read panicked while processing '{path}'. This is a bug — please report it."
117 ),
118 None,
119 )),
120 };
121 let result = result.map(|mut output| {
124 if let Some(requested) = &substituted {
125 output.text = format!(
126 "{}\n[lean-ctx: {requested} does not exist — read {path} instead]",
127 output.text.trim_end_matches('\n')
128 );
129 }
130 if let Some(note) = &symbol_note {
131 output.text = format!("{}\n{note}", output.text.trim_end_matches('\n'));
132 }
133 output
134 });
135 if let Ok(output) = &result {
136 record_attribution_result(ctx, format!("ctx_read {path}"), output);
137 }
138 result
139 }
140
141 fn publishes_every_source(&self, _args: Option<&Map<String, Value>>) -> bool {
159 true
160 }
161}
162
163fn symbol_request(
167 args: &Map<String, Value>,
168 path: &str,
169 symbol: &str,
170) -> Result<(Map<String, Value>, Option<String>), ErrorData> {
171 if lean_ctx::core::policy::runtime::is_active() {
172 return Err(ErrorData::invalid_params(
173 "symbol= is unavailable under an active context policy; use mode=\"lines:N-M\"",
174 None,
175 ));
176 }
177 let content = std::fs::read_to_string(path)
178 .map_err(|e| ErrorData::invalid_params(format!("cannot read {path}: {e}"), None))?;
179 let anchored = get_str(args, "mode").is_some_and(|m| m.starts_with("anchored"));
180 let (mode, note) = match super::symbol::resolve(&content, path, symbol, anchored)
181 .map_err(|e| ErrorData::invalid_params(e, None))?
182 {
183 super::symbol::SymbolRead::Window { mode, note } => (mode, note),
184 super::symbol::SymbolRead::NotFound { note, hits } if hits.is_empty() => {
188 ("reference".to_string(), Some(note))
189 }
190 super::symbol::SymbolRead::NotFound { note, hits } => {
191 let lines: Vec<String> = hits.iter().map(usize::to_string).collect();
192 (format!("lines:{}", lines.join(",")), Some(note))
193 }
194 };
195 let mut rewritten = args.clone();
196 for key in ["symbol", "start_line", "offset", "limit"] {
197 rewritten.remove(key);
198 }
199 rewritten.insert("mode".into(), Value::String(mode));
200 Ok((rewritten, note))
201}