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
12#[derive(Debug, Default)]
14pub struct ReadTool;
15
16#[derive(Debug, Deserialize)]
18struct ReadInput {
19 file_path: String,
21 #[serde(default)]
23 offset: Option<usize>,
24 #[serde(default)]
26 limit: Option<usize>,
27}
28
29impl ReadTool {
30 pub fn new() -> Self {
32 Self
33 }
34}
35
36#[async_trait]
37impl Tool for ReadTool {
38 fn name(&self) -> &str {
39 "Read"
40 }
41
42 fn description(&self) -> &str {
43 "Read the contents of a file from the filesystem. Supports reading specific line ranges with offset and limit parameters."
44 }
45
46 fn input_schema(&self) -> serde_json::Value {
47 json!({
48 "type": "object",
49 "required": ["file_path"],
50 "properties": {
51 "file_path": {
52 "type": "string",
53 "description": "The absolute path to the file to read"
54 },
55 "offset": {
56 "type": "integer",
57 "description": "Line number to start reading from (1-indexed). Defaults to 1."
58 },
59 "limit": {
60 "type": "integer",
61 "description": "Maximum number of lines to read. Defaults to reading entire file."
62 }
63 }
64 })
65 }
66
67 fn kind(&self) -> ToolKind {
68 ToolKind::Read
69 }
70
71 fn requires_permission(&self) -> bool {
72 false }
74
75 async fn execute(&self, input: serde_json::Value, context: &ToolContext) -> ToolResult {
76 let params: ReadInput = match serde_json::from_value(input) {
78 Ok(p) => p,
79 Err(e) => return ToolResult::error(format!("Invalid input: {}", e)),
80 };
81
82 let path = if std::path::Path::new(¶ms.file_path).is_absolute() {
84 std::path::PathBuf::from(¶ms.file_path)
85 } else {
86 context.cwd.join(¶ms.file_path)
87 };
88
89 if !path.exists() {
91 return ToolResult::error(format!("File not found: {}", path.display()));
92 }
93
94 if !path.is_file() {
96 return ToolResult::error(format!("Not a file: {}", path.display()));
97 }
98
99 let read_start = std::time::Instant::now();
101 let content = match tokio::fs::read_to_string(&path).await {
102 Ok(c) => c,
103 Err(e) => {
104 let read_duration = read_start.elapsed();
105 return ToolResult::error(format!(
106 "Failed to read file: {} (elapsed: {}ms)",
107 e,
108 read_duration.as_millis()
109 ));
110 }
111 };
112 let read_duration = read_start.elapsed();
113
114 tracing::debug!(
115 file_path = %path.display(),
116 file_size_bytes = content.len(),
117 read_duration_ms = read_duration.as_millis(),
118 "File read completed"
119 );
120
121 let lines: Vec<&str> = content.lines().collect();
123 let total_lines = lines.len();
124
125 let offset = params.offset.unwrap_or(1).saturating_sub(1); let limit = params.limit.unwrap_or(lines.len());
127
128 if offset >= lines.len() {
129 return ToolResult::success("").with_metadata(json!({
130 "total_lines": total_lines,
131 "returned_lines": 0
132 }));
133 }
134
135 let selected_lines: Vec<String> = lines
136 .iter()
137 .skip(offset)
138 .take(limit)
139 .enumerate()
140 .map(|(i, line)| format!("{:6}→{}", offset + i + 1, line))
141 .collect();
142
143 let returned_lines = selected_lines.len();
144
145 let display_path = if let Ok(rel) = path.strip_prefix(&context.cwd) {
149 let rel_str = rel.to_string_lossy();
150 if rel_str.is_empty() {
151 path.display().to_string()
153 } else if rel_str.contains('/') {
154 rel_str.to_string()
156 } else {
157 format!("./{}", rel_str)
159 }
160 } else {
161 path.display().to_string()
163 };
164
165 let header = format!(
167 "File: {} (lines {}-{} of {}, total {} lines)\n{}\n",
168 display_path,
169 offset + 1,
170 offset + returned_lines.min(total_lines),
171 total_lines,
172 total_lines,
173 "-".repeat(60)
174 );
175
176 let result = format!("{}\n{}", header, selected_lines.join("\n"));
177
178 tracing::info!(
179 file_path = %path.display(),
180 total_lines = total_lines,
181 returned_lines = returned_lines,
182 offset = offset + 1,
183 "File read successfully"
184 );
185
186 ToolResult::success(result).with_metadata(json!({
187 "total_lines": total_lines,
188 "returned_lines": returned_lines,
189 "offset": offset + 1,
190 "path": path.display().to_string(),
191 "read_duration_ms": read_duration.as_millis(),
192 "file_size_bytes": content.len()
193 }))
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use std::io::Write;
201 use tempfile::TempDir;
202
203 #[tokio::test]
204 async fn test_read_file() {
205 let temp_dir = TempDir::new().unwrap();
206 let file_path = temp_dir.path().join("test.txt");
207
208 let mut file = std::fs::File::create(&file_path).unwrap();
209 writeln!(file, "Line 1").unwrap();
210 writeln!(file, "Line 2").unwrap();
211 writeln!(file, "Line 3").unwrap();
212
213 let tool = ReadTool::new();
214 let context = ToolContext::new("test", temp_dir.path());
215
216 let result = tool
217 .execute(json!({"file_path": file_path.to_str().unwrap()}), &context)
218 .await;
219
220 assert!(!result.is_error);
221 assert!(result.content.contains("Line 1"));
222 assert!(result.content.contains("Line 2"));
223 assert!(result.content.contains("Line 3"));
224 }
225
226 #[tokio::test]
227 async fn test_read_with_offset_and_limit() {
228 let temp_dir = TempDir::new().unwrap();
229 let file_path = temp_dir.path().join("test.txt");
230
231 let mut file = std::fs::File::create(&file_path).unwrap();
232 for i in 1..=10 {
233 writeln!(file, "Line {}", i).unwrap();
234 }
235
236 let tool = ReadTool::new();
237 let context = ToolContext::new("test", temp_dir.path());
238
239 let result = tool
240 .execute(
241 json!({
242 "file_path": file_path.to_str().unwrap(),
243 "offset": 3,
244 "limit": 2
245 }),
246 &context,
247 )
248 .await;
249
250 assert!(!result.is_error);
251 assert!(result.content.contains("Line 3"));
252 assert!(result.content.contains("Line 4"));
253 assert!(!result.content.contains("Line 5"));
254 }
255
256 #[tokio::test]
257 async fn test_read_file_not_found() {
258 let temp_dir = TempDir::new().unwrap();
259 let tool = ReadTool::new();
260 let context = ToolContext::new("test", temp_dir.path());
261
262 let result = tool
263 .execute(json!({"file_path": "/nonexistent/file.txt"}), &context)
264 .await;
265
266 assert!(result.is_error);
267 assert!(result.content.contains("not found"));
268 }
269
270 #[test]
271 fn test_read_tool_properties() {
272 let tool = ReadTool::new();
273 assert_eq!(tool.name(), "Read");
274 assert_eq!(tool.kind(), ToolKind::Read);
275 assert!(!tool.requires_permission());
276 }
277}