claude_code_acp/mcp/tools/
read.rs1use async_trait::async_trait;
6use serde::Deserialize;
7use serde_json::json;
8
9use super::base::{Tool, ToolKind};
10use crate::mcp::registry::{ToolContext, ToolResult};
11
12const MAX_FILE_SIZE: u64 = 100 * 1024 * 1024;
14const MAX_OUTPUT_SIZE: usize = 50_000;
16
17#[derive(Debug, Default)]
19pub struct ReadTool;
20
21#[derive(Debug, Deserialize)]
23struct ReadInput {
24 file_path: String,
26 #[serde(default)]
28 offset: Option<usize>,
29 #[serde(default)]
31 limit: Option<usize>,
32}
33
34impl ReadTool {
35 pub fn new() -> Self {
37 Self
38 }
39
40 fn safe_truncate(s: &mut String, max_len: usize) {
42 if s.len() > max_len {
43 if max_len == 0 {
45 s.clear();
46 s.push_str("... (output truncated due to size)");
47 return;
48 }
49
50 let mut truncate_at = max_len;
52 while truncate_at > 0 && !s.is_char_boundary(truncate_at) {
53 truncate_at -= 1;
54 }
55 s.truncate(truncate_at);
56 s.push_str("\n... (output truncated due to size)");
57 }
58 }
59}
60
61#[async_trait]
62impl Tool for ReadTool {
63 fn name(&self) -> &str {
64 "Read"
65 }
66
67 fn description(&self) -> &str {
68 "Read the contents of a file from the filesystem. Supports reading specific line ranges with offset and limit parameters."
69 }
70
71 fn input_schema(&self) -> serde_json::Value {
72 json!({
73 "type": "object",
74 "required": ["file_path"],
75 "properties": {
76 "file_path": {
77 "type": "string",
78 "description": "The absolute path to the file to read"
79 },
80 "offset": {
81 "type": "integer",
82 "description": "Line number to start reading from (1-indexed). Defaults to 1."
83 },
84 "limit": {
85 "type": "integer",
86 "description": "Maximum number of lines to read. Defaults to reading entire file."
87 }
88 }
89 })
90 }
91
92 fn kind(&self) -> ToolKind {
93 ToolKind::Read
94 }
95
96 fn requires_permission(&self) -> bool {
97 false }
99
100 async fn execute(&self, input: serde_json::Value, context: &ToolContext) -> ToolResult {
101 let params: ReadInput = match serde_json::from_value(input) {
103 Ok(p) => p,
104 Err(e) => return ToolResult::error(format!("Invalid input: {}", e)),
105 };
106
107 let path = if std::path::Path::new(¶ms.file_path).is_absolute() {
109 std::path::PathBuf::from(¶ms.file_path)
110 } else {
111 context.cwd.join(¶ms.file_path)
112 };
113
114 if !path.exists() {
116 return ToolResult::error(format!("File not found: {}", path.display()));
117 }
118
119 if !path.is_file() {
121 return ToolResult::error(format!("Not a file: {}", path.display()));
122 }
123
124 let metadata = match tokio::fs::metadata(&path).await {
126 Ok(m) => m,
127 Err(e) => {
128 return ToolResult::error(format!("Failed to get file metadata: {}", e));
129 }
130 };
131
132 let file_size = metadata.len();
133 if file_size > MAX_FILE_SIZE {
134 #[allow(clippy::cast_precision_loss)]
135 let file_size_mb = file_size as f64 / 1024.0 / 1024.0;
136 #[allow(clippy::cast_precision_loss)]
137 let max_file_size_mb = MAX_FILE_SIZE as f64 / 1024.0 / 1024.0;
138 return ToolResult::error(format!(
139 "File too large ({:.1}MB). Maximum supported size is {:.1}MB. Use offset/limit parameters to read portions of the file.",
140 file_size_mb, max_file_size_mb
141 ));
142 }
143
144 let read_start = std::time::Instant::now();
146 let content = match tokio::fs::read_to_string(&path).await {
147 Ok(c) => c,
148 Err(e) => {
149 let read_duration = read_start.elapsed();
150 return ToolResult::error(format!(
151 "Failed to read file: {} (elapsed: {}ms)",
152 e,
153 read_duration.as_millis()
154 ));
155 }
156 };
157 let read_duration = read_start.elapsed();
158
159 tracing::debug!(
160 file_path = %path.display(),
161 file_size_bytes = content.len(),
162 read_duration_ms = read_duration.as_millis(),
163 "File read completed"
164 );
165
166 let lines: Vec<&str> = content.lines().collect();
168 let total_lines = lines.len();
169
170 let offset = params.offset.unwrap_or(1).saturating_sub(1); let limit = params.limit.unwrap_or(lines.len());
172
173 if offset >= lines.len() {
174 return ToolResult::success("").with_metadata(json!({
175 "total_lines": total_lines,
176 "returned_lines": 0
177 }));
178 }
179
180 let selected_lines: Vec<String> = lines
181 .iter()
182 .skip(offset)
183 .take(limit)
184 .enumerate()
185 .map(|(i, line)| format!("{:6}→{}", offset + i + 1, line))
186 .collect();
187
188 let returned_lines = selected_lines.len();
189
190 let display_path = if let Ok(rel) = path.strip_prefix(&context.cwd) {
194 let rel_str = rel.to_string_lossy();
195 if rel_str.is_empty() {
196 path.display().to_string()
198 } else if rel_str.contains('/') {
199 rel_str.to_string()
201 } else {
202 format!("./{}", rel_str)
204 }
205 } else {
206 path.display().to_string()
208 };
209
210 let header = format!(
212 "File: {} (lines {}-{} of {}, total {} lines)\n{}\n",
213 display_path,
214 offset + 1,
215 offset + returned_lines.min(total_lines),
216 total_lines,
217 total_lines,
218 "-".repeat(60)
219 );
220
221 let mut result = format!("{}\n{}", header, selected_lines.join("\n"));
222
223 Self::safe_truncate(&mut result, MAX_OUTPUT_SIZE);
225
226 tracing::info!(
227 file_path = %path.display(),
228 total_lines = total_lines,
229 returned_lines = returned_lines,
230 offset = offset + 1,
231 "File read successfully"
232 );
233
234 ToolResult::success(result).with_metadata(json!({
235 "total_lines": total_lines,
236 "returned_lines": returned_lines,
237 "offset": offset + 1,
238 "path": path.display().to_string(),
239 "read_duration_ms": read_duration.as_millis(),
240 "file_size_bytes": content.len()
241 }))
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use std::io::Write;
249 use tempfile::TempDir;
250
251 #[tokio::test]
252 async fn test_read_file() {
253 let temp_dir = TempDir::new().unwrap();
254 let file_path = temp_dir.path().join("test.txt");
255
256 let mut file = std::fs::File::create(&file_path).unwrap();
257 writeln!(file, "Line 1").unwrap();
258 writeln!(file, "Line 2").unwrap();
259 writeln!(file, "Line 3").unwrap();
260
261 let tool = ReadTool::new();
262 let context = ToolContext::new("test", temp_dir.path());
263
264 let result = tool
265 .execute(json!({"file_path": file_path.to_str().unwrap()}), &context)
266 .await;
267
268 assert!(!result.is_error);
269 assert!(result.content.contains("Line 1"));
270 assert!(result.content.contains("Line 2"));
271 assert!(result.content.contains("Line 3"));
272 }
273
274 #[tokio::test]
275 async fn test_read_with_offset_and_limit() {
276 let temp_dir = TempDir::new().unwrap();
277 let file_path = temp_dir.path().join("test.txt");
278
279 let mut file = std::fs::File::create(&file_path).unwrap();
280 for i in 1..=10 {
281 writeln!(file, "Line {}", i).unwrap();
282 }
283
284 let tool = ReadTool::new();
285 let context = ToolContext::new("test", temp_dir.path());
286
287 let result = tool
288 .execute(
289 json!({
290 "file_path": file_path.to_str().unwrap(),
291 "offset": 3,
292 "limit": 2
293 }),
294 &context,
295 )
296 .await;
297
298 assert!(!result.is_error);
299 assert!(result.content.contains("Line 3"));
300 assert!(result.content.contains("Line 4"));
301 assert!(!result.content.contains("Line 5"));
302 }
303
304 #[tokio::test]
305 async fn test_read_file_not_found() {
306 let temp_dir = TempDir::new().unwrap();
307 let tool = ReadTool::new();
308 let context = ToolContext::new("test", temp_dir.path());
309
310 let result = tool
311 .execute(json!({"file_path": "/nonexistent/file.txt"}), &context)
312 .await;
313
314 assert!(result.is_error);
315 assert!(result.content.contains("not found"));
316 }
317
318 #[test]
319 fn test_read_tool_properties() {
320 let tool = ReadTool::new();
321 assert_eq!(tool.name(), "Read");
322 assert_eq!(tool.kind(), ToolKind::Read);
323 assert!(!tool.requires_permission());
324 }
325}