Skip to content

Commit 3426e98

Browse files
authored
Merge pull request #26 from flolibio/fix/mcp-review-findings
fix: address external code review findings (fold into v1.1.0 draft)
2 parents b42376f + 12682a5 commit 3426e98

7 files changed

Lines changed: 163 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Changelog
22

3-
## [v1.1.0] - 2026-08-16
3+
## [v1.1.0] - 2026-08-18
44

55
**MCP — the AI log access layer.** tailr is now both a human log viewer and an MCP server: AI agents (Claude Code, Cursor, Codex, OpenCode, any MCP client) can list, search, count, and read server logs directly over `/mcp` — no SSH, no copy-paste. Single binary, zero new runtime dependencies.
66

@@ -21,6 +21,12 @@
2121
- **`tail_log` byte cap no longer truncates silently** — responses carry `truncated: true` when the 64MB read cap is hit.
2222
- **Token comparison is constant-time** (hand-rolled, zero new dependencies) for both the Bearer header and the WS query-param fallback.
2323
- **Disabled `/mcp` returns a deterministic 404** instead of falling into the SPA fallback (which 500s when no frontend dist is built).
24+
- **`tail_log` dropped empty lines and shifted line numbers** (external review) — the tail reader filtered all empty split segments, so files with blank lines got line numbers drifting from the scanner's. Empty lines now occupy their real line numbers; protocol-level regression test added.
25+
- **`tail_log` could report wrong line numbers from a partial line count** (external review) — if the line-count scan hit its time budget, the partial total was used as exact. Responses now carry `incomplete: true` in that case and the tool description tells agents to treat line numbers as approximate.
26+
- **Stats caching (mtime + size keyed)** — completed line/level counts are cached; repeated `tail_log`/`get_log_stats` calls on append-only logs no longer rescan the whole file. Cache hits skip the scan semaphore; file growth invalidates.
27+
- **Startup warning when MCP is enabled without a token** — the endpoint adds unauthenticated expensive-scan capability on top of the existing no-auth REST default; the combination is now logged loudly.
28+
- **LevelDetector no longer panics on keywords longer than the 256-byte scan window** (external review) — `limit - len` underflowed (debug panic / release slice OOB). Oversized keywords are now skipped safely.
29+
- **`list_log_files` signals truncation** — hitting the 5000-entry cap now returns `truncated: true` instead of silently dropping files; `search_logs` description clarifies that multi-page `count_only` results must be summed across pages.
2430

2531
### Dependencies
2632

crates/core/src/query.rs

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
//!
99
//! 本模块是纯同步 `fn`:无 HTTP、无 async、无运行时假设,符合 core 边界。
1010
11+
use std::collections::HashMap;
1112
use std::fmt;
12-
use std::path::Path;
13+
use std::path::{Path, PathBuf};
1314
use std::sync::atomic::AtomicBool;
14-
use std::time::Duration;
15+
use std::sync::Mutex;
16+
use std::time::{Duration, SystemTime};
1517

1618
use tailr_search_engine::{
1719
file_stats, scan_file, FileStats, LevelDetector, ScanParams, ScanResult, DEFAULT_CONTEXT,
@@ -107,9 +109,18 @@ impl std::error::Error for QueryError {
107109
}
108110
}
109111

112+
/// 已完成统计的缓存条目:追加型日志的 (mtime, size) 命中率极高,
113+
/// 一次全扫的精确行数可服务后续所有 stats/tail 调用。
114+
struct CachedStats {
115+
mtime: SystemTime,
116+
size: u64,
117+
stats: FileStats,
118+
}
119+
110120
pub struct QueryService {
111121
semaphore: tokio::sync::Semaphore,
112122
caps: QueryCaps,
123+
stats_cache: Mutex<HashMap<PathBuf, CachedStats>>,
113124
}
114125

115126
impl QueryService {
@@ -118,6 +129,7 @@ impl QueryService {
118129
Self {
119130
semaphore: tokio::sync::Semaphore::new(permits),
120131
caps,
132+
stats_cache: Mutex::new(HashMap::new()),
121133
}
122134
}
123135

@@ -180,17 +192,46 @@ impl QueryService {
180192
}
181193

182194
/// 文件统计。同样占并发闸(全文件扫描同样是重活)。
195+
///
196+
/// 缓存策略:以 (mtime, size) 为键缓存**已完成**的统计——追加型日志
197+
/// 的连续 tail/stats 调用不再每次全扫;文件增长自动失效。未完成的
198+
/// 部分计数不缓存。缓存命中不占并发闸(无扫描发生)。
183199
pub fn stats(
184200
&self,
185201
path: &Path,
186202
detector: Option<&LevelDetector>,
187203
cancel: Option<&AtomicBool>,
188204
) -> Result<FileStats, QueryError> {
205+
let meta = std::fs::metadata(path).map_err(QueryError::Io)?;
206+
let (meta_mtime, meta_size) = (meta.modified().map_err(QueryError::Io)?, meta.len());
207+
208+
if let Ok(cache) = self.stats_cache.lock() {
209+
if let Some(hit) = cache.get(path) {
210+
if hit.mtime == meta_mtime && hit.size == meta_size {
211+
return Ok(hit.stats.clone());
212+
}
213+
}
214+
}
215+
189216
let _permit = self
190217
.semaphore
191218
.try_acquire()
192219
.map_err(|_| QueryError::Busy)?;
193-
file_stats(path, detector, self.caps.max_time_budget, cancel).map_err(QueryError::Io)
220+
let stats =
221+
file_stats(path, detector, self.caps.max_time_budget, cancel).map_err(QueryError::Io)?;
222+
if stats.completed {
223+
if let Ok(mut cache) = self.stats_cache.lock() {
224+
cache.insert(
225+
path.to_path_buf(),
226+
CachedStats {
227+
mtime: meta_mtime,
228+
size: meta_size,
229+
stats: stats.clone(),
230+
},
231+
);
232+
}
233+
}
234+
Ok(stats)
194235
}
195236
}
196237

@@ -271,6 +312,29 @@ mod tests {
271312
assert!(matches!(err, QueryError::KeywordTooLong(256)));
272313
}
273314

315+
#[test]
316+
fn stats_cache_invalidates_on_file_change() {
317+
let mut f = tempfile::NamedTempFile::new().unwrap();
318+
use std::io::Write;
319+
writeln!(f, "one").unwrap();
320+
f.flush().unwrap();
321+
let svc = QueryService::new(QueryCaps::default());
322+
323+
let s1 = svc.stats(f.path(), None, None).unwrap();
324+
assert_eq!(s1.total_lines, 1);
325+
326+
// 追加后 (mtime,size) 变化 → 缓存失效 → 重新扫描
327+
writeln!(f, "two").unwrap();
328+
f.flush().unwrap();
329+
std::thread::sleep(std::time::Duration::from_millis(10));
330+
let s2 = svc.stats(f.path(), None, None).unwrap();
331+
assert_eq!(s2.total_lines, 2);
332+
333+
// 未变化 → 命中缓存返回同一结果
334+
let s3 = svc.stats(f.path(), None, None).unwrap();
335+
assert_eq!(s3.total_lines, 2);
336+
}
337+
274338
#[test]
275339
fn search_and_stats_passthrough() {
276340
let f = write_log("INFO a\nERROR b\n");

crates/search-engine/src/detector.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ impl LevelDetector {
5151
if line_lower_bytes.len() < keyword_bytes.len() {
5252
continue;
5353
}
54+
// 关键词长于扫描窗口(256B)时 limit - len 下溢:跳过该
55+
// 关键词防御,否则 debug panic / release 切片越界 panic。
56+
if keyword_bytes.len() > limit {
57+
continue;
58+
}
5459
for i in 0..=limit - keyword_bytes.len() {
5560
if line_lower_bytes[i..i + keyword_bytes.len()]
5661
.iter()
@@ -92,6 +97,25 @@ mod tests {
9297
}
9398
}
9499

100+
#[test]
101+
fn test_keyword_longer_than_scan_window_does_not_panic() {
102+
// >256 字节的关键词 + 足够长的行:曾因 limit - len 下溢 panic。
103+
let long_keyword = "k".repeat(300);
104+
let line = format!("x{}y", long_keyword);
105+
let detector = crate::LevelDetector::from_config(&LogLevelConfig {
106+
preset: "custom".to_string(),
107+
levels: vec![LevelDef {
108+
name: "WEIRD".to_string(),
109+
keywords: vec![long_keyword],
110+
color_light: "#000000".to_string(),
111+
color_dark: "#ffffff".to_string(),
112+
}],
113+
});
114+
// 不 panic、返回 UNKNOWN(超窗关键词被安全跳过)即通过
115+
assert_eq!(detector.detect(&line), "UNKNOWN".to_string());
116+
assert_eq!(detector.detect_ref(&line), "UNKNOWN");
117+
}
118+
95119
#[test]
96120
fn test_detect_basic() {
97121
let config = make_config("general", vec![

crates/search-engine/src/scanner.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Cursor 式日志扫描器(MCP search_logs / read 场景的地基)
22
//!
3-
//! 设计要点(见知识库 tailr-MCP-AI日志访问层设计 2026-08-14 决策)
3+
//! 设计要点:
44
//! - 原语是字节偏移游标,不是行号索引——无预建索引,多 GB 文件零额外内存
55
//! - 纯同步计算(`fn`),调用方(Web/MCP 层)自行包 `spawn_blocking`
66
//! - 分块扫描(4MB),每块边界检查时间预算与取消标志;预算耗尽返回

crates/server/src/lib.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,16 @@ pub fn app(
278278
// heavy work is already gated by QueryService's scan semaphore. HTTP-level
279279
// 429 storms on legitimate agents would be worse than the abuse they'd
280280
// prevent (see mcp.rs module docs). Optionally disabled via [mcp] enabled=false.
281+
// MCP 新增的是「无认证也能触发多 GB 全扫」的能力(REST 默认开放是
282+
// 既有立场,但 REST 没有这种昂贵操作)。该组合显式告警。
283+
if mcp.enabled && state.token.is_empty() {
284+
tracing::warn!(
285+
"MCP endpoint is enabled with NO auth token — anyone who can \
286+
reach this port can read all configured logs and trigger \
287+
full-file scans; set `token` or disable via [mcp] enabled=false"
288+
);
289+
}
290+
281291
let mcp_router = if mcp.enabled {
282292
mcp::routes(state.clone())
283293
.route_layer(middleware::from_fn_with_state(state.clone(), auth_middleware))

crates/server/src/mcp.rs

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! MCP (Model Context Protocol) 服务端 — `/mcp` streamable HTTP 端点。
22
//!
33
//! 让 AI agent(Claude Code / Cursor 等)直接检索服务器日志:搜索、读尾部、
4-
//! 读任意游标、级别统计。设计决策见知识库《tailr-MCP-AI日志访问层设计》
4+
//! 读任意游标、级别统计。关键设计决策(自足摘要,勿引用外部文档)
55
//!
66
//! - **token 防护**:所有 tool 的输出都经 QueryService 预算钳制(命中数/
77
//! 字节/超时),响应自带 `resumeCursor` 分页语义;tool description 与
@@ -250,20 +250,22 @@ impl McpTools {
250250
#[tool(description = "List the log files accessible on this server. Returns name/path/size/isDir for each entry. Start here to discover what you can query — the `path` values are the exact identifiers the other tools expect.")]
251251
async fn list_log_files(&self) -> Result<CallToolResult, McpError> {
252252
let state = self.state.clone();
253-
let entries = tokio::task::spawn_blocking(move || list_files_blocking(&state))
254-
.await
255-
.map_err(|e| McpError::internal_error(format!("listing task failed: {e}"), None))?;
253+
let (entries, truncated) =
254+
tokio::task::spawn_blocking(move || list_files_blocking(&state))
255+
.await
256+
.map_err(|e| McpError::internal_error(format!("listing task failed: {e}"), None))?;
256257

257258
Ok(CallToolResult::success(vec![Content::text(
258259
json!({
259260
"host": self.state.host_name,
260261
"files": entries,
262+
"truncated": truncated,
261263
})
262264
.to_string(),
263265
)]))
264266
}
265267

266-
#[tool(description = "Read the LAST N lines of a log file (default 100, max 5000) with absolute line numbers. Good for a quick look at recent activity; prefer get_log_stats + search_logs for anything bigger. If truncated is true, the tail exceeded the byte cap — request fewer lines.")]
268+
#[tool(description = "Read the LAST N lines of a log file (default 100, max 5000) with absolute line numbers. Good for a quick look at recent activity; prefer get_log_stats + search_logs for anything bigger. If truncated is true, the tail exceeded the byte cap — request fewer lines. If incomplete is true, line numbers are approximate (the line count did not finish scanning).")]
267269
async fn tail_log(
268270
&self,
269271
Parameters(args): Parameters<TailLogArgs>,
@@ -305,10 +307,16 @@ impl McpTools {
305307
.map_err(query_io)?;
306308
let file_len = std::fs::metadata(&path_buf).map(|m| m.len()).unwrap_or(0);
307309
let tail_truncated = file_len.saturating_sub(tail.start_byte) > read as u64;
310+
// 只丢弃末尾换行产生的空元素;中间的空行是真实行,
311+
// 必须保留并占一个行号——否则与 scanner 的行号体系错位
312+
// (空行后所有行号整体漂移)。
313+
let mut parts: Vec<&[u8]> = buf.split(|&b| b == b'\n').collect();
314+
if parts.last() == Some(&&[][..]) {
315+
parts.pop();
316+
}
308317
let mut line_no = start_line;
309-
let lines: Vec<serde_json::Value> = buf
310-
.split(|&b| b == b'\n')
311-
.filter(|l| !l.is_empty())
318+
let lines: Vec<serde_json::Value> = parts
319+
.into_iter()
312320
.map(|l| {
313321
let mut end = l.len();
314322
if end > 0 && l[end - 1] == b'\r' {
@@ -325,6 +333,9 @@ impl McpTools {
325333
"totalLines": total_lines,
326334
"sizeBytes": stats.total_bytes,
327335
"truncated": tail_truncated,
336+
// 行数统计未扫完时 start_line/line 是按部分计数推的,
337+
// 只能当近似值——显式透传,别让 AI 当精确数用。
338+
"incomplete": !stats.completed,
328339
}))
329340
})
330341
.await
@@ -341,7 +352,7 @@ impl McpTools {
341352
)]))
342353
}
343354

344-
#[tool(description = "Search a log file for lines containing ALL keywords (AND, case-insensitive). For 'how many/how often' questions set count_only=true (fast, counts without content). For inspection, returns matching lines merged into context windows (± context_lines). Workflow: get_log_stats first, then search with 2-3 specific keywords. If more/truncated is true, continue with resumeCursor (never restart); a timeout also returns partial results + resumeCursor — just continue. When reporting results, state the raw matched LINE count first, then any higher-level grouping you derive (e.g. request-pairs) — users usually think in lines.")]
355+
#[tool(description = "Search a log file for lines containing ALL keywords (AND, case-insensitive). For 'how many/how often' questions set count_only=true (fast, counts without content). For inspection, returns matching lines merged into context windows (± context_lines). Workflow: get_log_stats first, then search with 2-3 specific keywords. If more/truncated is true, continue with resumeCursor (never restart); a timeout also returns partial results + resumeCursor — just continue. When count_only spans several pages, sum matchedLines across all pages for the total. When reporting results, state the raw matched LINE count first, then any higher-level grouping you derive (e.g. request-pairs) — users usually think in lines.")]
345356
async fn search_logs(
346357
&self,
347358
Parameters(args): Parameters<SearchLogsArgs>,
@@ -437,7 +448,9 @@ impl ServerHandler for McpTools {
437448

438449
/// 同步列文件(spawn_blocking 里跑):配置的 log_files 平铺 + log_dirs
439450
/// 递归(深度 ≤ MCP_LIST_DEPTH),只留文本文件,平铺输出(AI 不要树)。
440-
fn list_files_blocking(state: &AppState) -> Vec<serde_json::Value> {
451+
/// 返回 (条目, 是否因 MAX_LIST_ENTRIES 截断)——截断必须显式告知,
452+
/// 否则 AI 会以为列全了。
453+
fn list_files_blocking(state: &AppState) -> (Vec<serde_json::Value>, bool) {
441454
let mut out = Vec::new();
442455

443456
for file in &state.log_files {
@@ -459,7 +472,7 @@ fn list_files_blocking(state: &AppState) -> Vec<serde_json::Value> {
459472
collect_text_files(dir, MCP_LIST_DEPTH, &mut out, &mut total);
460473
}
461474

462-
out
475+
(out, total >= crate::api::MAX_LIST_ENTRIES)
463476
}
464477

465478
fn collect_text_files(

crates/server/tests/mcp_integration.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,36 @@ async fn full_protocol_roundtrip() {
193193
assert_eq!(v["incomplete"], false);
194194
}
195195

196+
#[tokio::test]
197+
async fn tail_log_preserves_empty_line_numbering() {
198+
// 回归:空行是真实行,必须占行号——曾经被 filter 掉导致行号整体漂移。
199+
let dir = fixture_dir();
200+
std::fs::write(dir.join("blank.log"), "aaa\n\nccc\n").unwrap();
201+
let path = dir.join("blank.log").display().to_string();
202+
let url = spawn_app(McpConfig::default(), dir).await;
203+
let client = connect(&url, Some(TOKEN)).await.expect("handshake");
204+
let args = json!({ "path": path, "lines": 3 }).as_object().cloned().unwrap();
205+
let resp = client
206+
.peer()
207+
.call_tool(rmcp::model::CallToolRequestParam {
208+
name: "tail_log".into(),
209+
arguments: Some(args),
210+
})
211+
.await
212+
.unwrap();
213+
let v = tool_json(&resp);
214+
assert_eq!(v["totalLines"], 3);
215+
assert_eq!(v["incomplete"], false);
216+
let lines = v["lines"].as_array().unwrap();
217+
assert_eq!(lines.len(), 3);
218+
assert_eq!(lines[0]["line"], 1);
219+
assert_eq!(lines[0]["text"], "aaa");
220+
assert_eq!(lines[1]["line"], 2);
221+
assert_eq!(lines[1]["text"], "");
222+
assert_eq!(lines[2]["line"], 3);
223+
assert_eq!(lines[2]["text"], "ccc");
224+
}
225+
196226
#[tokio::test]
197227
async fn unauthenticated_client_is_rejected_until_token_given() {
198228
let dir = fixture_dir();

0 commit comments

Comments
 (0)