Skip to main content

rlg_mcp/
lib.rs

1// lib.rs
2// Copyright © 2024-2026 RustLogs (RLG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! MCP (Model Context Protocol) tool implementations and the
6//! minimal JSON-RPC 2.0 dispatcher that wires them to a Server.
7//!
8//! The wire format follows the
9//! [MCP specification](https://modelcontextprotocol.io/specification/2025-06-18)'s
10//! stdio transport: one JSON-RPC request per line on stdin, one
11//! response per line on stdout.
12//!
13//! The three tools (`tail_log`, `filter_log`, `summarize_errors`)
14//! are pure functions over a file path so they can be unit-tested
15//! without spinning up the transport loop.
16//!
17//! # Example
18//!
19//! ```no_run
20//! use std::path::Path;
21//! let recent = rlg_mcp::tail_log(Path::new("/var/log/app.ndjson"), 10).unwrap();
22//! for line in recent { println!("{line}"); }
23//! ```
24
25#![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
37// ---------------------------------------------------------------------------
38// Tool implementations — pure functions over a file path.
39// ---------------------------------------------------------------------------
40
41/// Return the last `n` parseable records from `path`, rendered in
42/// `Logfmt`. Unparseable lines are skipped.
43///
44/// # Errors
45/// Returns `io::Error` if the file cannot be opened or read.
46pub 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
59/// Apply `filter` to every record in `path` and return matches
60/// rendered in `format`.
61///
62/// # Errors
63/// Returns `io::Error` if the file cannot be opened or read.
64pub 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
82/// Count error+ records grouped by component.
83///
84/// # Errors
85/// Returns `io::Error` if the file cannot be opened or read.
86pub 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// ---------------------------------------------------------------------------
105// JSON-RPC 2.0 transport (minimal subset of MCP).
106// ---------------------------------------------------------------------------
107
108/// JSON-RPC 2.0 request envelope.
109///
110/// One `Request` corresponds to a single line on the stdio transport
111/// under MCP's `2025-06-18` protocol revision.
112#[derive(Debug, Deserialize)]
113pub struct Request {
114    /// JSON-RPC version marker. Must be the string `"2.0"`.
115    pub jsonrpc: String,
116    /// Correlation identifier. Absent for notifications; present for
117    /// method calls that expect a paired [`Response`].
118    #[serde(default)]
119    pub id: Option<serde_json::Value>,
120    /// Fully-qualified method name (e.g. `"tools/list"`).
121    pub method: String,
122    /// Method parameters. Shape is method-specific; unused methods
123    /// receive `Value::Null` after deserialisation.
124    #[serde(default)]
125    pub params: serde_json::Value,
126}
127
128/// JSON-RPC 2.0 response envelope.
129///
130/// Constructed via [`Response::ok`] or [`Response::err`]; direct
131/// field access is public so downstream code can inspect and log
132/// responses without going through the constructors.
133#[derive(Debug, Serialize)]
134pub struct Response {
135    /// JSON-RPC version marker. Always the literal `"2.0"`.
136    pub jsonrpc: &'static str,
137    /// Correlation identifier echoed back from the paired
138    /// [`Request`]. `None` for responses to notifications.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub id: Option<serde_json::Value>,
141    /// Success payload. Mutually exclusive with [`Self::error`].
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub result: Option<serde_json::Value>,
144    /// Error payload. Mutually exclusive with [`Self::result`].
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub error: Option<ResponseError>,
147}
148
149/// JSON-RPC 2.0 error object.
150///
151/// Populates [`Response::error`] when a method call fails. Codes
152/// follow the JSON-RPC 2.0 reserved range (e.g. `-32603` for
153/// internal errors).
154#[derive(Debug, Serialize)]
155pub struct ResponseError {
156    /// Numeric error code. See the JSON-RPC 2.0 spec for reserved
157    /// ranges; rlg-mcp returns `-32_603` for handler failures.
158    pub code: i32,
159    /// Human-readable error message.
160    pub message: String,
161}
162
163impl Response {
164    /// Construct a successful response.
165    #[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    /// Construct an error response.
179    #[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/// Dispatch one JSON-RPC request to the appropriate handler.
198///
199/// Returns `None` for *notifications* (requests without an `id`) —
200/// MCP requires no response for notifications.
201#[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            // MCP notifications — no response required.
254            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// ---------------------------------------------------------------------------
343// Tests.
344// ---------------------------------------------------------------------------
345
346#[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}