11//! MCP tool implementations for log file analysis.
2+ //!
3+ //! Note on blocking: Tool handlers are synchronous as required by rmcp. The `search` tool
4+ //! spawns a filter thread and blocks waiting for results. This is acceptable because:
5+ //! 1. MCP stdio transport processes requests sequentially (one at a time)
6+ //! 2. The actual filtering work runs on a dedicated thread, not blocking the tokio runtime
7+ //! 3. Only the channel recv() blocks, which is waiting on real work
8+ //!
9+ //! If concurrent request handling is needed in the future, consider wrapping heavy
10+ //! operations in `tokio::task::spawn_blocking`.
211
312use super :: types:: * ;
413use crate :: filter:: { cancel:: CancelToken , engine:: FilterProgress , streaming_filter} ;
@@ -10,8 +19,15 @@ use memmap2::Mmap;
1019use rmcp:: model:: { Implementation , ServerCapabilities , ServerInfo } ;
1120use rmcp:: { tool, tool_box, ServerHandler } ;
1221use std:: fs:: File ;
22+ use std:: path:: Path ;
1323use std:: sync:: Arc ;
1424
25+ /// Create a JSON error response string.
26+ fn error_response ( message : impl std:: fmt:: Display ) -> String {
27+ serde_json:: to_string ( & serde_json:: json!( { "error" : message. to_string( ) } ) )
28+ . unwrap_or_else ( |_| r#"{"error": "Failed to serialize error"}"# . to_string ( ) )
29+ }
30+
1531/// LazyTail MCP server providing log file analysis tools.
1632#[ derive( Clone ) ]
1733pub struct LazyTailMcp ;
@@ -37,14 +53,14 @@ impl LazyTailMcp {
3753 fn get_lines ( & self , #[ tool( aggr) ] req : GetLinesRequest ) -> String {
3854 let count = req. count . min ( 1000 ) ;
3955
40- let reader_result = FileReader :: new ( & req. file ) ;
41- let mut reader = match reader_result {
56+ let mut reader = match FileReader :: new ( & req. file ) {
4257 Ok ( r) => r,
4358 Err ( e) => {
44- return serde_json:: to_string ( & serde_json:: json!( {
45- "error" : format!( "Failed to open file '{}': {}" , req. file. display( ) , e)
46- } ) )
47- . unwrap_or_else ( |_| "Error serializing response" . to_string ( ) ) ;
59+ return error_response ( format ! (
60+ "Failed to open file '{}': {}" ,
61+ req. file. display( ) ,
62+ e
63+ ) )
4864 }
4965 } ;
5066
@@ -76,14 +92,14 @@ impl LazyTailMcp {
7692 fn get_tail ( & self , #[ tool( aggr) ] req : GetTailRequest ) -> String {
7793 let count = req. count . min ( 1000 ) ;
7894
79- let reader_result = FileReader :: new ( & req. file ) ;
80- let mut reader = match reader_result {
95+ let mut reader = match FileReader :: new ( & req. file ) {
8196 Ok ( r) => r,
8297 Err ( e) => {
83- return serde_json:: to_string ( & serde_json:: json!( {
84- "error" : format!( "Failed to open file '{}': {}" , req. file. display( ) , e)
85- } ) )
86- . unwrap_or_else ( |_| "Error serializing response" . to_string ( ) ) ;
98+ return error_response ( format ! (
99+ "Failed to open file '{}': {}" ,
100+ req. file. display( ) ,
101+ e
102+ ) )
87103 }
88104 } ;
89105
@@ -123,27 +139,25 @@ impl LazyTailMcp {
123139 SearchMode :: Plain => Arc :: new ( StringFilter :: new ( & req. pattern , req. case_sensitive ) ) ,
124140 SearchMode :: Regex => match RegexFilter :: new ( & req. pattern , req. case_sensitive ) {
125141 Ok ( f) => Arc :: new ( f) ,
126- Err ( e) => {
127- return serde_json:: to_string ( & serde_json:: json!( {
128- "error" : format!( "Invalid regex pattern: {}" , e)
129- } ) )
130- . unwrap_or_else ( |_| "Error serializing response" . to_string ( ) ) ;
131- }
142+ Err ( e) => return error_response ( format ! ( "Invalid regex pattern: {}" , e) ) ,
132143 } ,
133144 } ;
134145
135- // Run streaming filter (grep-like performance)
146+ // Run streaming filter (grep-like performance).
147+ // The filter runs on a dedicated thread; we block here waiting for results.
148+ // See module doc for why this is acceptable in the current MCP design.
136149 let rx = match streaming_filter:: run_streaming_filter (
137150 req. file . clone ( ) ,
138151 filter,
139152 CancelToken :: new ( ) ,
140153 ) {
141154 Ok ( rx) => rx,
142155 Err ( e) => {
143- return serde_json:: to_string ( & serde_json:: json!( {
144- "error" : format!( "Failed to search file '{}': {}" , req. file. display( ) , e)
145- } ) )
146- . unwrap_or_else ( |_| "Error serializing response" . to_string ( ) ) ;
156+ return error_response ( format ! (
157+ "Failed to search file '{}': {}" ,
158+ req. file. display( ) ,
159+ e
160+ ) )
147161 }
148162 } ;
149163
@@ -170,12 +184,7 @@ impl LazyTailMcp {
170184 FilterProgress :: Processing ( n) => {
171185 lines_searched = n;
172186 }
173- FilterProgress :: Error ( e) => {
174- return serde_json:: to_string ( & serde_json:: json!( {
175- "error" : format!( "Search error: {}" , e)
176- } ) )
177- . unwrap_or_else ( |_| "Error serializing response" . to_string ( ) ) ;
178- }
187+ FilterProgress :: Error ( e) => return error_response ( format ! ( "Search error: {}" , e) ) ,
179188 }
180189 }
181190
@@ -189,12 +198,7 @@ impl LazyTailMcp {
189198 } else {
190199 match Self :: get_lines_content ( & req. file , & matching_indices, context_lines) {
191200 Ok ( m) => m,
192- Err ( e) => {
193- return serde_json:: to_string ( & serde_json:: json!( {
194- "error" : format!( "Failed to read line content: {}" , e)
195- } ) )
196- . unwrap_or_else ( |_| "Error serializing response" . to_string ( ) ) ;
197- }
201+ Err ( e) => return error_response ( format ! ( "Failed to read line content: {}" , e) ) ,
198202 }
199203 } ;
200204
@@ -209,9 +213,19 @@ impl LazyTailMcp {
209213 . unwrap_or_else ( |e| format ! ( "Error serializing response: {}" , e) )
210214 }
211215
212- /// Get line content and context using mmap (no index building required)
216+ /// Fetch line content and context for search matches using a single-pass mmap scan.
217+ ///
218+ /// This is a specialized batch operation that differs from `FileReader`:
219+ /// - `FileReader`: Builds a full line index, optimized for random access to any line
220+ /// - This function: Single sequential pass, only extracts specific lines + context
221+ ///
222+ /// For search results with context, this approach is more efficient because:
223+ /// 1. We know exactly which lines we need upfront (matches + context)
224+ /// 2. Single pass through file up to the last needed line, then early exit
225+ /// 3. No index structure overhead - just a BTreeSet of needed line numbers
226+ /// 4. Handles overlapping context ranges efficiently via deduplication
213227 fn get_lines_content (
214- path : & std :: path :: Path ,
228+ path : & Path ,
215229 line_indices : & [ usize ] ,
216230 context_lines : usize ,
217231 ) -> anyhow:: Result < Vec < SearchMatch > > {
@@ -220,6 +234,9 @@ impl LazyTailMcp {
220234 }
221235
222236 let file = File :: open ( path) ?;
237+ // SAFETY: The file handle is kept open for the lifetime of the mmap.
238+ // We only perform read operations on the mapped memory.
239+ // The file is opened read-only and we don't modify it.
223240 let mmap = unsafe { Mmap :: map ( & file) ? } ;
224241 let data = & mmap[ ..] ;
225242
@@ -307,24 +324,24 @@ impl LazyTailMcp {
307324 let before_count = req. before . min ( 50 ) ;
308325 let after_count = req. after . min ( 50 ) ;
309326
310- let reader_result = FileReader :: new ( & req. file ) ;
311- let mut reader = match reader_result {
327+ let mut reader = match FileReader :: new ( & req. file ) {
312328 Ok ( r) => r,
313329 Err ( e) => {
314- return serde_json:: to_string ( & serde_json:: json!( {
315- "error" : format!( "Failed to open file '{}': {}" , req. file. display( ) , e)
316- } ) )
317- . unwrap_or_else ( |_| "Error serializing response" . to_string ( ) ) ;
330+ return error_response ( format ! (
331+ "Failed to open file '{}': {}" ,
332+ req. file. display( ) ,
333+ e
334+ ) )
318335 }
319336 } ;
320337
321338 let total = reader. total_lines ( ) ;
322339
323340 if req. line_number >= total {
324- return serde_json :: to_string ( & serde_json :: json! ( {
325- "error" : format! ( " Line {} does not exist (file has {} lines)", req . line_number , total )
326- } ) )
327- . unwrap_or_else ( |_| "Error serializing response" . to_string ( ) ) ;
341+ return error_response ( format ! (
342+ "Line {} does not exist (file has {} lines)" ,
343+ req . line_number , total
344+ ) ) ;
328345 }
329346
330347 // Get before lines
@@ -342,12 +359,7 @@ impl LazyTailMcp {
342359 // Get target line
343360 let target_content = match reader. get_line ( req. line_number ) {
344361 Ok ( Some ( c) ) => c,
345- _ => {
346- return serde_json:: to_string ( & serde_json:: json!( {
347- "error" : "Failed to read target line"
348- } ) )
349- . unwrap_or_else ( |_| "Error serializing response" . to_string ( ) ) ;
350- }
362+ _ => return error_response ( "Failed to read target line" ) ,
351363 } ;
352364 let target_line = LineInfo {
353365 line_number : req. line_number ,
@@ -384,24 +396,13 @@ impl LazyTailMcp {
384396 fn list_sources ( & self , #[ tool( aggr) ] _req : ListSourcesRequest ) -> String {
385397 let data_dir = match source:: data_dir ( ) {
386398 Some ( dir) => dir,
387- None => {
388- return serde_json:: to_string ( & serde_json:: json!( {
389- "error" : "Could not determine data directory"
390- } ) )
391- . unwrap_or_else ( |_| "Error serializing response" . to_string ( ) ) ;
392- }
399+ None => return error_response ( "Could not determine data directory" ) ,
393400 } ;
394401
395402 // Get discovered sources
396403 let discovered = match source:: discover_sources ( ) {
397404 Ok ( sources) => sources,
398- Err ( e) => {
399- return serde_json:: to_string ( & serde_json:: json!( {
400- "error" : format!( "Failed to discover sources: {}" , e) ,
401- "data_directory" : data_dir. display( ) . to_string( )
402- } ) )
403- . unwrap_or_else ( |_| "Error serializing response" . to_string ( ) ) ;
404- }
405+ Err ( e) => return error_response ( format ! ( "Failed to discover sources: {}" , e) ) ,
405406 } ;
406407
407408 let mut sources = Vec :: new ( ) ;
0 commit comments