Skip to main content

lanekeep_server/
mcp.rs

1//! The Model Context Protocol surface.
2//!
3//! Three tools, one per thing the CLI already does: run the checks, list the configured
4//! rules, explain one. An agent host launches `lanekeep server --protocol mcp` and calls
5//! them; the transport is the same JSON-RPC this crate already speaks, line-delimited
6//! instead of header-delimited.
7//!
8//! # What the tools return
9//!
10//! The `agent` reporter's text, unchanged. That format exists precisely for this consumer —
11//! it groups by rule, states the remediation once rather than per occurrence, and shows a
12//! good and bad example — and re-rendering violations into a bespoke JSON shape here would
13//! be a second answer to a question §11 already answered.
14//!
15//! # A failing tool is not a failing call
16//!
17//! MCP separates the two, and the distinction is load-bearing. A rule that throws, or a
18//! config that will not load, is a *result* the model should see and act on — it comes back
19//! as `isError: true` with the message as content. A JSON-RPC error is for the host, not the
20//! model: no such tool, malformed arguments. Reporting a rule failure as a JSON-RPC error
21//! hides it from the thing best placed to fix it.
22
23use std::io::{BufRead, Write};
24
25use serde_json::{Value, json};
26
27use crate::jsonrpc::{self, Framing, Incoming, Outgoing, codes};
28
29/// The protocol revision this server implements.
30///
31/// A client asking for a different one is answered with this rather than refused: the
32/// specification has the server state what it speaks, and a host that cannot work with it
33/// will say so. Refusing outright would turn a version skew into a dead session.
34const PROTOCOL_VERSION: &str = "2024-11-05";
35
36/// What the host can ask lanekeep to do.
37///
38/// A trait rather than an engine, for the same reason the LSP loop takes a closure: this
39/// crate stays protocol-only, and its tests need no project on disk.
40pub trait Tools {
41    /// Run the project's rules and describe what they found.
42    ///
43    /// # Errors
44    ///
45    /// Returns the message to show the model when the run could not happen at all.
46    fn check(&mut self) -> Result<String, String>;
47
48    /// List the rules the project has configured.
49    ///
50    /// # Errors
51    ///
52    /// As [`Tools::check`].
53    fn rules(&mut self) -> Result<String, String>;
54
55    /// Explain one rule: what it checks and what to do about it.
56    ///
57    /// # Errors
58    ///
59    /// As [`Tools::check`], including when no such rule is configured.
60    fn explain(&mut self, rule: &str) -> Result<String, String>;
61}
62
63/// The tool catalogue, as `tools/list` returns it.
64#[must_use]
65pub fn catalogue() -> Value {
66    json!({
67        "tools": [
68            {
69                "name": "lanekeep_check",
70                "description": "Check the project against its architectural rules. \
71                                Returns every violation, grouped by rule, with the \
72                                remediation for each and a good and bad example. Run this \
73                                after editing code to find conventions the change broke.",
74                "inputSchema": { "type": "object", "properties": {} },
75            },
76            {
77                "name": "lanekeep_rules",
78                "description": "List the rules this project has configured, with what each \
79                                one enforces. Use it to find out which conventions apply \
80                                here before writing code, rather than after.",
81                "inputSchema": { "type": "object", "properties": {} },
82            },
83            {
84                "name": "lanekeep_explain",
85                "description": "Explain one rule: what it checks, why, and what to do \
86                                instead, with a good and bad example. Call it with the id \
87                                from a violation to find out how to fix it.",
88                "inputSchema": {
89                    "type": "object",
90                    "properties": {
91                        "rule": {
92                            "type": "string",
93                            "description": "Namespaced rule id, as it appears in a \
94                                            violation — for example `lanekeep/no-default-export`.",
95                        },
96                    },
97                    "required": ["rule"],
98                },
99            },
100        ],
101    })
102}
103
104/// A tool result, successful or not.
105///
106/// Both are a *successful* JSON-RPC reply; `isError` is what tells the model which it got.
107#[must_use]
108pub fn content(text: &str, failed: bool) -> Value {
109    json!({
110        "content": [{ "type": "text", "text": text }],
111        "isError": failed,
112    })
113}
114
115/// Serve MCP against `input` and `output` until the host disconnects.
116///
117/// # Errors
118///
119/// Propagates an I/O failure on the transport. A failing tool is not one — see the module
120/// docs.
121pub fn serve(
122    input: &mut impl BufRead,
123    output: &mut impl Write,
124    tools: &mut impl Tools,
125) -> std::io::Result<()> {
126    while let Some(raw) = jsonrpc::read(input, Framing::Lines)? {
127        let Ok(message) = serde_json::from_str::<Incoming>(&raw) else {
128            jsonrpc::write(
129                output,
130                Framing::Lines,
131                &Outgoing::error(None, codes::PARSE_ERROR, "not a JSON-RPC message"),
132            )?;
133            continue;
134        };
135
136        let outcome = match message.method.as_str() {
137            "initialize" => Some(Ok(json!({
138                "protocolVersion": PROTOCOL_VERSION,
139                "capabilities": { "tools": {} },
140                "serverInfo": {
141                    "name": "lanekeep",
142                    "version": env!("CARGO_PKG_VERSION"),
143                },
144            }))),
145
146            // Acknowledgements and keepalives.
147            "notifications/initialized" | "initialized" => None,
148            "ping" => Some(Ok(json!({}))),
149
150            "tools/list" => Some(Ok(catalogue())),
151
152            "tools/call" => Some(call(&message.params, tools)),
153
154            other => Some(Err((
155                codes::METHOD_NOT_FOUND,
156                format!("no method `{other}`"),
157            ))),
158        };
159
160        let Some(outcome) = outcome else { continue };
161        if !message.expects_reply() {
162            continue;
163        }
164
165        let response = match outcome {
166            Ok(result) => Outgoing::result(message.id.clone(), result),
167            Err((code, text)) => Outgoing::error(message.id.clone(), code, text),
168        };
169        jsonrpc::write(output, Framing::Lines, &response)?;
170    }
171
172    Ok(())
173}
174
175/// Dispatch one `tools/call`.
176fn call(params: &Value, tools: &mut impl Tools) -> Result<Value, (i32, String)> {
177    let Some(name) = params["name"].as_str() else {
178        return Err((codes::INVALID_PARAMS, "`name` is required".to_owned()));
179    };
180
181    let outcome = match name {
182        "lanekeep_check" => tools.check(),
183        "lanekeep_rules" => tools.rules(),
184        "lanekeep_explain" => {
185            // A missing argument is the host's mistake, not something the model should be
186            // shown as a rule failure — so it is a JSON-RPC error rather than `isError`.
187            let Some(rule) = params["arguments"]["rule"].as_str() else {
188                return Err((
189                    codes::INVALID_PARAMS,
190                    "`lanekeep_explain` needs a `rule` argument".to_owned(),
191                ));
192            };
193            tools.explain(rule)
194        }
195        other => {
196            return Err((codes::INVALID_PARAMS, format!("no tool `{other}`")));
197        }
198    };
199
200    Ok(match outcome {
201        Ok(text) => content(&text, false),
202        Err(text) => content(&text, true),
203    })
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    /// A stand-in that records what it was asked and answers as told.
211    struct Fake {
212        answer: Result<String, String>,
213        explained: Option<String>,
214        called: Vec<&'static str>,
215    }
216
217    impl Fake {
218        fn ok() -> Self {
219            Self {
220                answer: Ok("nothing found".to_owned()),
221                explained: None,
222                called: Vec::new(),
223            }
224        }
225
226        fn failing() -> Self {
227            Self {
228                answer: Err("rule threw".to_owned()),
229                explained: None,
230                called: Vec::new(),
231            }
232        }
233    }
234
235    impl Tools for Fake {
236        fn check(&mut self) -> Result<String, String> {
237            self.called.push("check");
238            self.answer.clone()
239        }
240
241        fn rules(&mut self) -> Result<String, String> {
242            self.called.push("rules");
243            self.answer.clone()
244        }
245
246        fn explain(&mut self, rule: &str) -> Result<String, String> {
247            self.called.push("explain");
248            self.explained = Some(rule.to_owned());
249            self.answer.clone()
250        }
251    }
252
253    fn exchange(messages: &[Value], tools: &mut impl Tools) -> Vec<Value> {
254        use std::fmt::Write as _;
255
256        let mut wire = String::new();
257        for message in messages {
258            let _ = writeln!(wire, "{message}");
259        }
260        let mut input = std::io::BufReader::new(wire.as_bytes());
261        let mut output = Vec::new();
262        serve(&mut input, &mut output, tools).expect("serves");
263
264        String::from_utf8(output)
265            .expect("utf-8")
266            .lines()
267            .map(|line| serde_json::from_str(line).expect("parses"))
268            .collect()
269    }
270
271    #[test]
272    fn initialize_states_the_protocol_version_and_the_tools_capability() {
273        let replies = exchange(
274            &[json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {} })],
275            &mut Fake::ok(),
276        );
277        assert_eq!(replies.len(), 1);
278        assert_eq!(replies[0]["result"]["protocolVersion"], PROTOCOL_VERSION);
279        assert!(replies[0]["result"]["capabilities"]["tools"].is_object());
280        assert_eq!(replies[0]["result"]["serverInfo"]["name"], "lanekeep");
281    }
282
283    #[test]
284    fn the_catalogue_lists_three_tools_each_with_a_schema() {
285        let replies = exchange(
286            &[json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" })],
287            &mut Fake::ok(),
288        );
289        let tools = replies[0]["result"]["tools"].as_array().expect("an array");
290        assert_eq!(tools.len(), 3);
291
292        for tool in tools {
293            assert!(tool["name"].is_string(), "{tool}");
294            // The description is what a model reads to decide whether to call it, so an
295            // empty one makes the tool invisible in practice.
296            let description = tool["description"].as_str().expect("a description");
297            assert!(
298                description.len() > 40,
299                "too terse to choose by: {description}"
300            );
301            assert_eq!(tool["inputSchema"]["type"], "object", "{tool}");
302        }
303    }
304
305    #[test]
306    fn explain_declares_its_required_argument() {
307        let replies = exchange(
308            &[json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" })],
309            &mut Fake::ok(),
310        );
311        let explain = replies[0]["result"]["tools"]
312            .as_array()
313            .expect("an array")
314            .iter()
315            .find(|tool| tool["name"] == "lanekeep_explain")
316            .expect("present");
317        assert_eq!(explain["inputSchema"]["required"][0], "rule");
318    }
319
320    #[test]
321    fn calling_check_returns_its_text_as_content() {
322        let mut tools = Fake::ok();
323        let replies = exchange(
324            &[json!({
325                "jsonrpc": "2.0", "id": 1, "method": "tools/call",
326                "params": { "name": "lanekeep_check", "arguments": {} }
327            })],
328            &mut tools,
329        );
330        assert_eq!(tools.called, ["check"]);
331        assert_eq!(replies[0]["result"]["content"][0]["type"], "text");
332        assert_eq!(replies[0]["result"]["content"][0]["text"], "nothing found");
333        assert_eq!(replies[0]["result"]["isError"], false);
334    }
335
336    #[test]
337    fn explain_receives_the_rule_it_was_given() {
338        let mut tools = Fake::ok();
339        exchange(
340            &[json!({
341                "jsonrpc": "2.0", "id": 1, "method": "tools/call",
342                "params": {
343                    "name": "lanekeep_explain",
344                    "arguments": { "rule": "lanekeep/no-default-export" }
345                }
346            })],
347            &mut tools,
348        );
349        assert_eq!(
350            tools.explained.as_deref(),
351            Some("lanekeep/no-default-export")
352        );
353    }
354
355    #[test]
356    fn a_failing_tool_is_a_successful_call_marked_as_an_error() {
357        // The distinction that matters: a rule that threw is a result the model should see
358        // and act on. Reporting it as a JSON-RPC error hides it from the thing best placed
359        // to fix it.
360        let replies = exchange(
361            &[json!({
362                "jsonrpc": "2.0", "id": 1, "method": "tools/call",
363                "params": { "name": "lanekeep_check", "arguments": {} }
364            })],
365            &mut Fake::failing(),
366        );
367        assert!(
368            replies[0].get("error").is_none(),
369            "should not be a protocol error: {}",
370            replies[0]
371        );
372        assert_eq!(replies[0]["result"]["isError"], true);
373        assert_eq!(replies[0]["result"]["content"][0]["text"], "rule threw");
374    }
375
376    #[test]
377    fn a_missing_argument_is_a_protocol_error_not_a_tool_error() {
378        // The host built the call wrong. That is not something the model can fix by writing
379        // different code, so it goes back as a JSON-RPC error.
380        let replies = exchange(
381            &[json!({
382                "jsonrpc": "2.0", "id": 1, "method": "tools/call",
383                "params": { "name": "lanekeep_explain", "arguments": {} }
384            })],
385            &mut Fake::ok(),
386        );
387        assert_eq!(replies[0]["error"]["code"], codes::INVALID_PARAMS);
388    }
389
390    #[test]
391    fn an_unknown_tool_is_refused() {
392        let replies = exchange(
393            &[json!({
394                "jsonrpc": "2.0", "id": 1, "method": "tools/call",
395                "params": { "name": "lanekeep_deploy", "arguments": {} }
396            })],
397            &mut Fake::ok(),
398        );
399        assert_eq!(replies[0]["error"]["code"], codes::INVALID_PARAMS);
400    }
401
402    #[test]
403    fn an_unknown_method_is_refused() {
404        let replies = exchange(
405            &[json!({ "jsonrpc": "2.0", "id": 1, "method": "resources/list" })],
406            &mut Fake::ok(),
407        );
408        assert_eq!(replies[0]["error"]["code"], codes::METHOD_NOT_FOUND);
409    }
410
411    #[test]
412    fn the_initialized_notification_is_not_answered() {
413        let replies = exchange(
414            &[json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })],
415            &mut Fake::ok(),
416        );
417        assert!(replies.is_empty(), "{replies:?}");
418    }
419
420    #[test]
421    fn ping_is_answered() {
422        let replies = exchange(
423            &[json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" })],
424            &mut Fake::ok(),
425        );
426        assert_eq!(replies.len(), 1);
427        assert!(replies[0]["result"].is_object());
428    }
429
430    #[test]
431    fn a_malformed_line_is_answered_and_the_session_continues() {
432        let wire = "not json\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n";
433        let mut input = std::io::BufReader::new(wire.as_bytes());
434        let mut output = Vec::new();
435        serve(&mut input, &mut output, &mut Fake::ok()).expect("serves");
436
437        let replies: Vec<Value> = String::from_utf8(output)
438            .expect("utf-8")
439            .lines()
440            .map(|line| serde_json::from_str(line).expect("parses"))
441            .collect();
442        assert_eq!(replies.len(), 2);
443        assert_eq!(replies[0]["error"]["code"], codes::PARSE_ERROR);
444        assert_eq!(replies[1]["id"], 1);
445    }
446
447    #[test]
448    fn every_reply_is_one_line() {
449        // Line-delimited framing: a reply containing a raw newline would be read as two
450        // messages, and every message after it would be off by one.
451        let mut tools = Fake::ok();
452        tools.answer = Ok("two\nlines".to_owned());
453        let wire = json!({
454            "jsonrpc": "2.0", "id": 1, "method": "tools/call",
455            "params": { "name": "lanekeep_check", "arguments": {} }
456        })
457        .to_string()
458            + "\n";
459
460        let mut input = std::io::BufReader::new(wire.as_bytes());
461        let mut output = Vec::new();
462        serve(&mut input, &mut output, &mut tools).expect("serves");
463
464        let text = String::from_utf8(output).expect("utf-8");
465        assert_eq!(text.trim_end().lines().count(), 1, "{text}");
466        let parsed: Value = serde_json::from_str(text.trim_end()).expect("parses");
467        assert_eq!(parsed["result"]["content"][0]["text"], "two\nlines");
468    }
469}