1#![forbid(unsafe_code)]
26#![deny(missing_docs)]
27
28use rlg::log_format::LogFormat;
29use rlg::log_level::LogLevel;
30use rlg_cli::{Filter, parse_record, render};
31use serde::{Deserialize, Serialize};
32use std::collections::BTreeMap;
33use std::fs::File;
34use std::io::{BufRead, BufReader};
35use std::path::Path;
36
37pub fn tail_log(path: &Path, n: usize) -> std::io::Result<Vec<String>> {
47 let file = File::open(path)?;
48 let mut all = Vec::new();
49 for line in BufReader::new(file).lines() {
50 let line = line?;
51 if let Ok(record) = parse_record(&line) {
52 all.push(render(record, LogFormat::Logfmt));
53 }
54 }
55 let start = all.len().saturating_sub(n);
56 Ok(all.split_off(start))
57}
58
59pub fn filter_log(
65 path: &Path,
66 filter: &Filter,
67 format: LogFormat,
68) -> std::io::Result<Vec<String>> {
69 let file = File::open(path)?;
70 let mut out = Vec::new();
71 for line in BufReader::new(file).lines() {
72 let line = line?;
73 if let Ok(record) = parse_record(&line)
74 && filter.matches(&record)
75 {
76 out.push(render(record, format));
77 }
78 }
79 Ok(out)
80}
81
82pub fn summarize_errors(
87 path: &Path,
88) -> std::io::Result<BTreeMap<String, u64>> {
89 let file = File::open(path)?;
90 let mut buckets: BTreeMap<String, u64> = BTreeMap::new();
91 for line in BufReader::new(file).lines() {
92 let line = line?;
93 if let Ok(record) = parse_record(&line)
94 && record.level.to_numeric() >= LogLevel::ERROR.to_numeric()
95 {
96 *buckets
97 .entry(record.component.to_string())
98 .or_insert(0) += 1;
99 }
100 }
101 Ok(buckets)
102}
103
104#[derive(Debug, Deserialize)]
113pub struct Request {
114 pub jsonrpc: String,
116 #[serde(default)]
119 pub id: Option<serde_json::Value>,
120 pub method: String,
122 #[serde(default)]
125 pub params: serde_json::Value,
126}
127
128#[derive(Debug, Serialize)]
134pub struct Response {
135 pub jsonrpc: &'static str,
137 #[serde(skip_serializing_if = "Option::is_none")]
140 pub id: Option<serde_json::Value>,
141 #[serde(skip_serializing_if = "Option::is_none")]
143 pub result: Option<serde_json::Value>,
144 #[serde(skip_serializing_if = "Option::is_none")]
146 pub error: Option<ResponseError>,
147}
148
149#[derive(Debug, Serialize)]
155pub struct ResponseError {
156 pub code: i32,
159 pub message: String,
161}
162
163impl Response {
164 #[must_use]
166 pub const fn ok(
167 id: Option<serde_json::Value>,
168 result: serde_json::Value,
169 ) -> Self {
170 Self {
171 jsonrpc: "2.0",
172 id,
173 result: Some(result),
174 error: None,
175 }
176 }
177
178 #[must_use]
180 pub fn err(
181 id: Option<serde_json::Value>,
182 code: i32,
183 message: impl Into<String>,
184 ) -> Self {
185 Self {
186 jsonrpc: "2.0",
187 id,
188 result: None,
189 error: Some(ResponseError {
190 code,
191 message: message.into(),
192 }),
193 }
194 }
195}
196
197#[must_use]
202pub fn dispatch(req: &Request) -> Option<Response> {
203 let id = req.id.clone();
204 let result = match req.method.as_str() {
205 "initialize" => Ok(serde_json::json!({
206 "protocolVersion": "2025-06-18",
207 "capabilities": { "tools": {} },
208 "serverInfo": { "name": "rlg-mcp", "version": env!("CARGO_PKG_VERSION") }
209 })),
210 "tools/list" => Ok(serde_json::json!({
211 "tools": [
212 {
213 "name": "tail_log",
214 "description": "Return the last N parseable rlg records from a file (Logfmt).",
215 "inputSchema": {
216 "type": "object",
217 "properties": {
218 "path": { "type": "string" },
219 "n": { "type": "integer", "minimum": 1, "default": 100 }
220 },
221 "required": ["path"]
222 }
223 },
224 {
225 "name": "filter_log",
226 "description": "Filter records by level/component/attribute, render in any LogFormat.",
227 "inputSchema": {
228 "type": "object",
229 "properties": {
230 "path": { "type": "string" },
231 "min_level": { "type": "string", "enum": ["TRACE","DEBUG","VERBOSE","INFO","WARN","ERROR","FATAL","CRITICAL"] },
232 "component": { "type": "string" },
233 "format": { "type": "string", "default": "Logfmt" }
234 },
235 "required": ["path"]
236 }
237 },
238 {
239 "name": "summarize_errors",
240 "description": "Group ERROR-and-above records by component and count them.",
241 "inputSchema": {
242 "type": "object",
243 "properties": {
244 "path": { "type": "string" }
245 },
246 "required": ["path"]
247 }
248 }
249 ]
250 })),
251 "tools/call" => dispatch_tool_call(&req.params),
252 "notifications/initialized" | "notifications/cancelled" => {
253 return None;
255 }
256 other => Err(format!("unknown method: {other}")),
257 };
258
259 Some(match result {
260 Ok(v) => Response::ok(id, v),
261 Err(e) => Response::err(id, -32_603, e),
262 })
263}
264
265fn dispatch_tool_call(
266 params: &serde_json::Value,
267) -> Result<serde_json::Value, String> {
268 let name = params
269 .get("name")
270 .and_then(serde_json::Value::as_str)
271 .ok_or_else(|| "missing `name`".to_string())?;
272 let args = params.get("arguments").cloned().unwrap_or_default();
273
274 match name {
275 "tail_log" => {
276 let path = arg_path(&args)?;
277 let n = args
278 .get("n")
279 .and_then(serde_json::Value::as_u64)
280 .unwrap_or(100) as usize;
281 let lines =
282 tail_log(&path, n).map_err(|e| e.to_string())?;
283 Ok(wrap_text(lines.join("\n")))
284 }
285 "filter_log" => {
286 let path = arg_path(&args)?;
287 let mut filter = Filter::new();
288 if let Some(lvl) = args
289 .get("min_level")
290 .and_then(serde_json::Value::as_str)
291 {
292 let level: LogLevel =
293 lvl.parse().map_err(|e| format!("{e:?}"))?;
294 filter = filter.min_level(level);
295 }
296 if let Some(c) = args
297 .get("component")
298 .and_then(serde_json::Value::as_str)
299 {
300 filter = filter.component(c);
301 }
302 let format = args
303 .get("format")
304 .and_then(serde_json::Value::as_str)
305 .map_or(Ok(LogFormat::Logfmt), |s| {
306 s.parse::<LogFormat>().map_err(|e| e.to_string())
307 })?;
308 let lines = filter_log(&path, &filter, format)
309 .map_err(|e| e.to_string())?;
310 Ok(wrap_text(lines.join("\n")))
311 }
312 "summarize_errors" => {
313 let path = arg_path(&args)?;
314 let buckets =
315 summarize_errors(&path).map_err(|e| e.to_string())?;
316 Ok(serde_json::json!({
317 "content": [ {
318 "type": "text",
319 "text": serde_json::to_string_pretty(&buckets).unwrap_or_default()
320 } ]
321 }))
322 }
323 other => Err(format!("unknown tool: {other}")),
324 }
325}
326
327fn arg_path(
328 args: &serde_json::Value,
329) -> Result<std::path::PathBuf, String> {
330 args.get("path")
331 .and_then(serde_json::Value::as_str)
332 .map(std::path::PathBuf::from)
333 .ok_or_else(|| "missing `path`".to_string())
334}
335
336fn wrap_text(s: String) -> serde_json::Value {
337 serde_json::json!({
338 "content": [ { "type": "text", "text": s } ]
339 })
340}
341
342#[cfg(test)]
347mod tests {
348 use super::*;
349 use std::io::Write;
350
351 fn write_log(content: &str) -> tempfile::NamedTempFile {
352 let mut f = tempfile::NamedTempFile::new().unwrap();
353 f.write_all(content.as_bytes()).unwrap();
354 f
355 }
356
357 const INFO: &str = r#"{"session_id":1,"time":"t","level":"INFO","component":"svc","description":"hi","format":"JSON","attributes":{}}"#;
358 const ERROR: &str = r#"{"session_id":2,"time":"t","level":"ERROR","component":"db","description":"boom","format":"JSON","attributes":{}}"#;
359 const FATAL: &str = r#"{"session_id":3,"time":"t","level":"FATAL","component":"db","description":"down","format":"JSON","attributes":{}}"#;
360
361 #[test]
362 fn tail_log_returns_last_n() {
363 let f = write_log(&format!("{INFO}\n{ERROR}\n{FATAL}\n"));
364 let out = tail_log(f.path(), 2).unwrap();
365 assert_eq!(out.len(), 2);
366 assert!(out[0].contains("boom"));
367 assert!(out[1].contains("down"));
368 }
369
370 #[test]
371 fn tail_log_handles_short_files() {
372 let f = write_log(&format!("{INFO}\n"));
373 let out = tail_log(f.path(), 100).unwrap();
374 assert_eq!(out.len(), 1);
375 }
376
377 #[test]
378 fn filter_log_drops_below_min_level() {
379 let f = write_log(&format!("{INFO}\n{ERROR}\n{FATAL}\n"));
380 let filter = Filter::new().min_level(LogLevel::ERROR);
381 let out =
382 filter_log(f.path(), &filter, LogFormat::Logfmt).unwrap();
383 assert_eq!(out.len(), 2);
384 }
385
386 #[test]
387 fn summarize_errors_groups_by_component() {
388 let f = write_log(&format!("{INFO}\n{ERROR}\n{FATAL}\n"));
389 let buckets = summarize_errors(f.path()).unwrap();
390 assert_eq!(buckets.get("db"), Some(&2));
391 assert_eq!(buckets.get("svc"), None);
392 }
393
394 fn req(method: &str, params: serde_json::Value) -> Request {
395 Request {
396 jsonrpc: "2.0".to_string(),
397 id: Some(serde_json::json!(1)),
398 method: method.to_string(),
399 params,
400 }
401 }
402
403 #[test]
404 fn dispatch_initialize_returns_capabilities() {
405 let r = dispatch(&req("initialize", serde_json::json!({})))
406 .expect("response");
407 let result = r.result.unwrap();
408 assert_eq!(result["protocolVersion"], "2025-06-18");
409 assert_eq!(result["serverInfo"]["name"], "rlg-mcp");
410 }
411
412 #[test]
413 fn dispatch_tools_list_returns_three_tools() {
414 let r = dispatch(&req("tools/list", serde_json::json!({})))
415 .expect("response");
416 let tools =
417 r.result.unwrap()["tools"].as_array().unwrap().len();
418 assert_eq!(tools, 3);
419 }
420
421 #[test]
422 fn dispatch_unknown_method_returns_error() {
423 let r = dispatch(&req("nope", serde_json::json!({})))
424 .expect("response");
425 assert!(r.error.is_some());
426 }
427
428 #[test]
429 fn dispatch_notification_returns_none() {
430 let mut r =
431 req("notifications/initialized", serde_json::json!({}));
432 r.id = None;
433 assert!(dispatch(&r).is_none());
434 }
435
436 #[test]
437 fn dispatch_tools_call_tail_log() {
438 let f = write_log(&format!("{INFO}\n{ERROR}\n"));
439 let call = req(
440 "tools/call",
441 serde_json::json!({
442 "name": "tail_log",
443 "arguments": { "path": f.path().to_str().unwrap(), "n": 5 }
444 }),
445 );
446 let r = dispatch(&call).expect("response");
447 assert!(r.error.is_none());
448 let text = r.result.unwrap()["content"][0]["text"]
449 .as_str()
450 .unwrap()
451 .to_string();
452 assert!(text.contains("hi"));
453 assert!(text.contains("boom"));
454 }
455
456 #[test]
457 fn dispatch_tools_call_unknown_name_errors() {
458 let call = req(
459 "tools/call",
460 serde_json::json!({ "name": "nope", "arguments": {} }),
461 );
462 let r = dispatch(&call).expect("response");
463 assert!(r.error.is_some());
464 }
465
466 #[test]
467 fn dispatch_tools_call_missing_path_errors() {
468 let call = req(
469 "tools/call",
470 serde_json::json!({ "name": "tail_log", "arguments": {} }),
471 );
472 let r = dispatch(&call).expect("response");
473 assert!(r.error.is_some());
474 }
475
476 #[test]
477 fn dispatch_tools_call_filter_log_full_args() {
478 let f = write_log(&format!("{INFO}\n{ERROR}\n{FATAL}\n"));
479 let call = req(
480 "tools/call",
481 serde_json::json!({
482 "name": "filter_log",
483 "arguments": {
484 "path": f.path().to_str().unwrap(),
485 "min_level": "ERROR",
486 "component": "db",
487 "format": "JSON"
488 }
489 }),
490 );
491 let r = dispatch(&call).expect("response");
492 assert!(r.error.is_none());
493 let text = r.result.unwrap()["content"][0]["text"]
494 .as_str()
495 .unwrap()
496 .to_string();
497 assert!(text.contains("boom") || text.contains("down"));
498 assert!(!text.contains("hello"));
499 }
500
501 #[test]
502 fn dispatch_tools_call_filter_log_rejects_bad_level() {
503 let f = write_log(INFO);
504 let call = req(
505 "tools/call",
506 serde_json::json!({
507 "name": "filter_log",
508 "arguments": {
509 "path": f.path().to_str().unwrap(),
510 "min_level": "NOT_A_LEVEL"
511 }
512 }),
513 );
514 let r = dispatch(&call).expect("response");
515 assert!(r.error.is_some());
516 }
517
518 #[test]
519 fn dispatch_tools_call_filter_log_rejects_bad_format() {
520 let f = write_log(INFO);
521 let call = req(
522 "tools/call",
523 serde_json::json!({
524 "name": "filter_log",
525 "arguments": {
526 "path": f.path().to_str().unwrap(),
527 "format": "NotAFormat"
528 }
529 }),
530 );
531 let r = dispatch(&call).expect("response");
532 assert!(r.error.is_some());
533 }
534
535 #[test]
536 fn dispatch_tools_call_summarize_errors() {
537 let f = write_log(&format!("{INFO}\n{ERROR}\n{FATAL}\n"));
538 let call = req(
539 "tools/call",
540 serde_json::json!({
541 "name": "summarize_errors",
542 "arguments": { "path": f.path().to_str().unwrap() }
543 }),
544 );
545 let r = dispatch(&call).expect("response");
546 assert!(r.error.is_none());
547 let text = r.result.unwrap()["content"][0]["text"]
548 .as_str()
549 .unwrap()
550 .to_string();
551 assert!(text.contains("db"));
552 }
553
554 #[test]
555 fn response_ok_and_err_round_trip_json() {
556 let ok = Response::ok(
557 Some(serde_json::json!(1)),
558 serde_json::json!({"a": 1}),
559 );
560 let s = serde_json::to_string(&ok).unwrap();
561 assert!(s.contains("\"jsonrpc\":\"2.0\""));
562 assert!(s.contains("\"result\""));
563
564 let err = Response::err(None, -32_700, "parse failed");
565 let s = serde_json::to_string(&err).unwrap();
566 assert!(s.contains("\"error\""));
567 assert!(s.contains("parse failed"));
568 }
569}