Skip to content

Commit bd88113

Browse files
authored
feat(mcp): add since_line parameter to get_tail for incremental polling
* feat(mcp): add since_line parameter to get_tail for incremental polling * fix(mcp): use saturating_add to prevent overflow in get_tail since_line
1 parent f28c44d commit bd88113

File tree

2 files changed

+96
-8
lines changed

2 files changed

+96
-8
lines changed

src/mcp/tools.rs

Lines changed: 90 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ impl LazyTailMcp {
285285
pub(crate) fn get_tail_impl(
286286
path: &Path,
287287
count: usize,
288+
since_line: Option<usize>,
288289
raw: bool,
289290
output: OutputFormat,
290291
) -> String {
@@ -300,10 +301,19 @@ impl LazyTailMcp {
300301
let index_reader = IndexReader::open(path);
301302

302303
let total = reader.total_lines();
303-
let start = total.saturating_sub(count);
304+
305+
let (start, end, has_more) = if let Some(since) = since_line {
306+
let start = since.saturating_add(1);
307+
let end = start.saturating_add(count).min(total);
308+
let has_more = end < total;
309+
(start, end, has_more)
310+
} else {
311+
let start = total.saturating_sub(count);
312+
(start, total, start > 0)
313+
};
304314

305315
let mut lines = Vec::new();
306-
for i in start..total {
316+
for i in start..end {
307317
if let Ok(Some(content)) = reader.get_line(i) {
308318
lines.push(LineInfo {
309319
line_number: i,
@@ -319,7 +329,7 @@ impl LazyTailMcp {
319329
let mut response = GetLinesResponse {
320330
lines,
321331
total_lines: total,
322-
has_more: start > 0,
332+
has_more,
323333
};
324334

325335
if !raw {
@@ -713,14 +723,14 @@ impl LazyTailMcp {
713723

714724
/// Fetch the last N lines from a lazytail source.
715725
#[tool(
716-
description = "Fetch the last N lines from a lazytail-captured log source. Useful for checking recent activity. Pass a source name from list_sources. Returns up to 1000 lines from the end of the file."
726+
description = "Fetch the last N lines from a lazytail-captured log source. Useful for checking recent activity. Pass a source name from list_sources. Returns up to 1000 lines from the end of the file. Supports incremental polling via since_line — pass the last line_number you received to get only new lines added after that point."
717727
)]
718728
fn get_tail(&self, #[tool(aggr)] req: GetTailRequest) -> String {
719729
let path = match source::resolve_source_for_context(&req.source, &self.discovery) {
720730
Ok(p) => p,
721731
Err(e) => return error_response(e),
722732
};
723-
Self::get_tail_impl(&path, req.count, req.raw, req.output)
733+
Self::get_tail_impl(&path, req.count, req.since_line, req.raw, req.output)
724734
}
725735

726736
/// Search for patterns in a lazytail source using plain text, regex, or structured query.
@@ -925,7 +935,7 @@ plain line with no escapes\n\
925935
#[test]
926936
fn get_tail_strips_ansi_by_default() {
927937
let f = write_ansi_tempfile();
928-
let result = LazyTailMcp::get_tail_impl(f.path(), 2, false, OutputFormat::Json);
938+
let result = LazyTailMcp::get_tail_impl(f.path(), 2, None, false, OutputFormat::Json);
929939
let resp: GetLinesResponse = serde_json::from_str(&result).unwrap();
930940
assert_eq!(resp.lines.len(), 2);
931941
assert_eq!(resp.lines[0].content, "plain line with no escapes");
@@ -935,11 +945,83 @@ plain line with no escapes\n\
935945
#[test]
936946
fn get_tail_preserves_ansi_when_raw() {
937947
let f = write_ansi_tempfile();
938-
let result = LazyTailMcp::get_tail_impl(f.path(), 5, true, OutputFormat::Json);
948+
let result = LazyTailMcp::get_tail_impl(f.path(), 5, None, true, OutputFormat::Json);
939949
let resp: GetLinesResponse = serde_json::from_str(&result).unwrap();
940950
assert!(resp.lines[0].content.contains("\x1b["));
941951
}
942952

953+
#[test]
954+
fn get_tail_since_line_returns_new_lines() {
955+
let mut f = NamedTempFile::new().unwrap();
956+
for i in 0..5 {
957+
writeln!(f, "line {i}").unwrap();
958+
}
959+
f.flush().unwrap();
960+
961+
let result = LazyTailMcp::get_tail_impl(f.path(), 100, Some(2), false, OutputFormat::Json);
962+
let resp: GetLinesResponse = serde_json::from_str(&result).unwrap();
963+
964+
assert_eq!(resp.lines.len(), 2);
965+
assert_eq!(resp.lines[0].line_number, 3);
966+
assert_eq!(resp.lines[0].content, "line 3");
967+
assert_eq!(resp.lines[1].line_number, 4);
968+
assert_eq!(resp.lines[1].content, "line 4");
969+
assert_eq!(resp.total_lines, 5);
970+
assert!(!resp.has_more);
971+
}
972+
973+
#[test]
974+
fn get_tail_since_line_with_count() {
975+
let mut f = NamedTempFile::new().unwrap();
976+
for i in 0..10 {
977+
writeln!(f, "line {i}").unwrap();
978+
}
979+
f.flush().unwrap();
980+
981+
let result = LazyTailMcp::get_tail_impl(f.path(), 2, Some(5), false, OutputFormat::Json);
982+
let resp: GetLinesResponse = serde_json::from_str(&result).unwrap();
983+
984+
assert_eq!(resp.lines.len(), 2);
985+
assert_eq!(resp.lines[0].line_number, 6);
986+
assert_eq!(resp.lines[0].content, "line 6");
987+
assert_eq!(resp.lines[1].line_number, 7);
988+
assert_eq!(resp.lines[1].content, "line 7");
989+
assert!(resp.has_more);
990+
}
991+
992+
#[test]
993+
fn get_tail_since_line_at_end() {
994+
let mut f = NamedTempFile::new().unwrap();
995+
for i in 0..5 {
996+
writeln!(f, "line {i}").unwrap();
997+
}
998+
f.flush().unwrap();
999+
1000+
let result = LazyTailMcp::get_tail_impl(f.path(), 100, Some(4), false, OutputFormat::Json);
1001+
let resp: GetLinesResponse = serde_json::from_str(&result).unwrap();
1002+
1003+
assert!(resp.lines.is_empty());
1004+
assert!(!resp.has_more);
1005+
assert_eq!(resp.total_lines, 5);
1006+
}
1007+
1008+
#[test]
1009+
fn get_tail_since_line_beyond_end() {
1010+
let mut f = NamedTempFile::new().unwrap();
1011+
for i in 0..5 {
1012+
writeln!(f, "line {i}").unwrap();
1013+
}
1014+
f.flush().unwrap();
1015+
1016+
let result =
1017+
LazyTailMcp::get_tail_impl(f.path(), 100, Some(100), false, OutputFormat::Json);
1018+
let resp: GetLinesResponse = serde_json::from_str(&result).unwrap();
1019+
1020+
assert!(resp.lines.is_empty());
1021+
assert!(!resp.has_more);
1022+
assert_eq!(resp.total_lines, 5);
1023+
}
1024+
9431025
// -- search tests --
9441026

9451027
#[test]
@@ -1181,7 +1263,7 @@ plain line with no escapes\n\
11811263
writeln!(f, "line 2").unwrap();
11821264
f.flush().unwrap();
11831265

1184-
let result = LazyTailMcp::get_tail_impl(f.path(), 2, false, OutputFormat::Text);
1266+
let result = LazyTailMcp::get_tail_impl(f.path(), 2, None, false, OutputFormat::Text);
11851267
assert!(result.starts_with("--- "));
11861268
assert!(result.contains("--- has_more: true\n"));
11871269
assert!(result.contains("[L1] line 1\n"));

src/mcp/types.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,12 @@ pub struct GetTailRequest {
154154
/// Number of lines to fetch from the end (default 100, max 1000)
155155
#[serde(default = "default_count")]
156156
pub count: usize,
157+
/// Only return lines after this line number (0-indexed, exclusive).
158+
/// Enables efficient incremental polling — pass the last line_number
159+
/// you received to get only new lines. When set, returns up to `count`
160+
/// lines starting from `since_line + 1`.
161+
#[serde(default)]
162+
pub since_line: Option<usize>,
157163
/// Return raw content with ANSI escape codes intact (default: false, strips ANSI)
158164
#[serde(default)]
159165
pub raw: bool,

0 commit comments

Comments
 (0)