Skip to main content

keel_cli/
mcp.rs

1//! `keel mcp` — the CLI doubles as an MCP server for coding agents (dx-spec §5).
2//!
3//! A hand-rolled, dependency-free JSON-RPC 2.0 loop over stdio (one
4//! newline-delimited message per line) speaking the Model Context Protocol:
5//! `initialize`, `ping`, `tools/list`, `tools/call`. Exactly the six
6//! spec-promised tools are exposed — `get_status`, `get_doctor_report`,
7//! `propose_policy` (returns a keel.toml *diff*), `get_trace`, `list_flows`,
8//! `explain_error` — and each is a thin wrapper over the same library producer
9//! as the corresponding CLI command, so a tool's text result is **byte-identical
10//! to that command's `--json` output** (golden-tested). An agent that already
11//! understands `keel status --json` can diff the MCP result against it and see
12//! no change.
13//!
14//! No daemon (dx-spec §3 invariant 3): the server is client-launched, owns no
15//! port, and exits on stdin EOF. Determinism as courtesy (dx-spec §5): responses
16//! serialize through [`serde_json::Value`] (sorted keys), the tool catalog is a
17//! fixed alphabetical list, and no wall-clock value reaches any response.
18
19use std::io::{BufRead, Write};
20use std::path::PathBuf;
21
22use serde_json::{Value, json};
23
24use crate::render::json_string;
25use crate::{EXIT_FAILURE, EXIT_OK, Rendered, doctor, explain, flows, init, status};
26
27/// The MCP protocol revision this server speaks natively (offered when the
28/// client requests a revision we do not recognize).
29const LATEST_PROTOCOL: &str = "2025-06-18";
30
31/// Revisions we recognize and echo back unchanged, per the MCP version
32/// negotiation rule ("if the server supports the requested version, it MUST
33/// respond with the same version").
34const SUPPORTED_PROTOCOLS: [&str; 3] = ["2024-11-05", "2025-03-26", "2025-06-18"];
35
36/// Server usage notes surfaced to the client at `initialize` time.
37const INSTRUCTIONS: &str = "Keel adds production-grade resilience (retry, backoff, timeout, breaker, \
38rate limit, cache) and durable flows to this project with zero code changes; policy lives in keel.toml. \
39Every tool's text result is byte-identical to the matching CLI --json output: \
40get_status = `keel status --json`, get_doctor_report = `keel doctor --json`, \
41propose_policy = `keel init --diff --json` (an applyable keel.toml patch — never writes), \
42list_flows = `keel flows --json`, get_trace = `keel trace <flow> --json`, \
43explain_error = `keel explain <code> --json`. Outputs are deterministic \
44(sorted keys, no timestamps), so two calls can be diffed to see real change. \
45get_doctor_report also returns a ranked follow_ups list (rank 1 = least Keel-verifiable, \
46investigate first) — work it top-down before proposing policy, and read its `boundaries` \
47object for what the report could not parse. For an evaluate/adopt/review task, drive these \
48tools through the keel skill's protocol rather than calling one alone — they are diagnostic \
49primitives, and get_doctor_report is static Python/JS-TS evidence that cannot see project \
50governance (CLAUDE.md/AGENTS.md) or shell/CI orchestration.";
51
52// JSON-RPC 2.0 error codes.
53const PARSE_ERROR: i64 = -32700;
54const INVALID_REQUEST: i64 = -32600;
55const METHOD_NOT_FOUND: i64 = -32601;
56const INVALID_PARAMS: i64 = -32602;
57
58/// A JSON-RPC protocol-level failure (distinct from a *tool* failure, which is
59/// reported inside a successful `tools/call` result with `isError: true`).
60struct RpcError {
61    code: i64,
62    message: String,
63}
64
65/// Build an [`RpcError`].
66fn rpc_error(code: i64, message: impl Into<String>) -> RpcError {
67    RpcError {
68        code,
69        message: message.into(),
70    }
71}
72
73/// The stdio MCP server: the project it reports on plus an injected clock
74/// (boxed so tests can capture a per-test reading; only human-facing views
75/// consume it — no wall-clock value ever reaches a JSON response).
76pub struct Server {
77    project: PathBuf,
78    now_ms: Box<dyn Fn() -> i64>,
79}
80
81impl std::fmt::Debug for Server {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("Server")
84            .field("project", &self.project)
85            .field("now_ms", &"<fn>")
86            .finish()
87    }
88}
89
90impl Server {
91    /// A server for `project`, dating any age computations via `now_ms`.
92    #[must_use]
93    pub fn new(project: PathBuf, now_ms: impl Fn() -> i64 + 'static) -> Self {
94        Self {
95            project,
96            now_ms: Box::new(now_ms),
97        }
98    }
99
100    /// Run the loop: one JSON-RPC message per line in, one per line out
101    /// (notifications get no reply). Returns the process exit code —
102    /// [`EXIT_OK`] on clean EOF, [`EXIT_FAILURE`] on an I/O error.
103    pub fn serve<R: BufRead, W: Write>(&self, input: R, mut output: W) -> i32 {
104        for line in input.lines() {
105            let Ok(line) = line else {
106                return EXIT_FAILURE;
107            };
108            if line.trim().is_empty() {
109                continue;
110            }
111            let Some(response) = self.handle_line(&line) else {
112                continue;
113            };
114            let Ok(text) = serde_json::to_string(&response) else {
115                return EXIT_FAILURE;
116            };
117            if writeln!(output, "{text}")
118                .and_then(|()| output.flush())
119                .is_err()
120            {
121                return EXIT_FAILURE;
122            }
123        }
124        EXIT_OK
125    }
126
127    /// Handle one raw input line. `None` means no response is due (a
128    /// notification, or a response frame we never asked for).
129    fn handle_line(&self, line: &str) -> Option<Value> {
130        let Ok(message) = serde_json::from_str::<Value>(line) else {
131            return Some(error_response(
132                &Value::Null,
133                PARSE_ERROR,
134                "Parse error: the line is not valid JSON. Send one JSON-RPC 2.0 message per line.",
135            ));
136        };
137        self.handle_message(&message)
138    }
139
140    /// Dispatch one parsed JSON-RPC message.
141    fn handle_message(&self, message: &Value) -> Option<Value> {
142        let Some(frame) = message.as_object() else {
143            return Some(error_response(
144                &Value::Null,
145                INVALID_REQUEST,
146                "Invalid request: expected a JSON-RPC 2.0 object (batches are not supported).",
147            ));
148        };
149        let id = frame.get("id").cloned();
150        let Some(method) = frame.get("method").and_then(Value::as_str) else {
151            // No method: a response frame (we never send requests) or garbage.
152            // Answer only when an id makes the failure addressable.
153            return id.map(|id| {
154                error_response(
155                    &id,
156                    INVALID_REQUEST,
157                    "Invalid request: missing `method`. This server accepts initialize, ping, tools/list, and tools/call.",
158                )
159            });
160        };
161        let params = frame.get("params").cloned().unwrap_or(Value::Null);
162        // A notification (no id) never gets a response; unknown ones are ignored.
163        let id = id?;
164        let outcome = match method {
165            "initialize" => Ok(initialize_result(&params)),
166            "ping" => Ok(json!({})),
167            "tools/call" => self.tools_call(&params),
168            "tools/list" => Ok(json!({ "tools": tool_catalog() })),
169            other => Err(rpc_error(
170                METHOD_NOT_FOUND,
171                format!(
172                    "Method not found: {other:?}. This server supports initialize, ping, tools/list, and tools/call."
173                ),
174            )),
175        };
176        Some(match outcome {
177            Ok(result) => json!({ "id": id, "jsonrpc": "2.0", "result": result }),
178            Err(e) => error_response(&id, e.code, &e.message),
179        })
180    }
181
182    /// `tools/call`: run one of the six tools and wrap its `--json` twin as the
183    /// text content. A tool that renders a failure (non-zero exit) is a
184    /// *successful* call carrying `isError: true` — protocol errors are only
185    /// for unknown tools and malformed arguments.
186    fn tools_call(&self, params: &Value) -> Result<Value, RpcError> {
187        let Some(name) = params.get("name").and_then(Value::as_str) else {
188            return Err(rpc_error(
189                INVALID_PARAMS,
190                "tools/call requires a string `name` parameter naming the tool.",
191            ));
192        };
193        let args = params
194            .get("arguments")
195            .cloned()
196            .unwrap_or_else(|| json!({}));
197        let rendered = self.call_tool(name, &args)?;
198        Ok(json!({
199            "content": [ { "text": json_string(&rendered.json), "type": "text" } ],
200            "isError": rendered.exit != EXIT_OK,
201        }))
202    }
203
204    /// Map a tool name to the library producer behind the same-named CLI
205    /// command. The [`Rendered`] comes back whole, so the text content is the
206    /// exact bytes `keel <cmd> --json` would print.
207    fn call_tool(&self, name: &str, args: &Value) -> Result<Rendered, RpcError> {
208        match name {
209            "explain_error" => Ok(explain::run(require_str(args, "code", name)?)),
210            "get_doctor_report" => Ok(doctor::run(&self.project)),
211            "get_status" => Ok(status::run(&self.project, (self.now_ms)())),
212            "get_trace" => Ok(flows::trace(
213                &self.project,
214                require_str(args, "flow", name)?,
215            )),
216            "list_flows" => Ok(flows::flows(
217                &self.project,
218                optional_bool(args, "dead", name)?,
219                (self.now_ms)(),
220            )),
221            "propose_policy" => Ok(init::run(
222                &self.project,
223                init::InitOptions {
224                    diff: true,
225                    stamp: false,
226                    agents: false,
227                },
228            )),
229            other => Err(rpc_error(
230                INVALID_PARAMS,
231                format!(
232                    "Unknown tool: {other:?}. Available tools: explain_error, get_doctor_report, get_status, get_trace, list_flows, propose_policy."
233                ),
234            )),
235        }
236    }
237}
238
239/// The `initialize` result: negotiated protocol version, capabilities, server
240/// identity, and usage instructions.
241fn initialize_result(params: &Value) -> Value {
242    let requested = params
243        .get("protocolVersion")
244        .and_then(Value::as_str)
245        .unwrap_or(LATEST_PROTOCOL);
246    let version = if SUPPORTED_PROTOCOLS.contains(&requested) {
247        requested
248    } else {
249        LATEST_PROTOCOL
250    };
251    json!({
252        "capabilities": { "tools": {} },
253        "instructions": INSTRUCTIONS,
254        "protocolVersion": version,
255        "serverInfo": { "name": "keel", "version": env!("CARGO_PKG_VERSION") },
256    })
257}
258
259/// The six tool names, alphabetical — the single source [`tool_catalog`] and
260/// anything cross-checking against it (the packaged Claude Code Skill's
261/// consistency test, `crates/keel-cli/tests/cli.rs`) both read.
262pub const TOOL_NAMES: [&str; 6] = [
263    "explain_error",
264    "get_doctor_report",
265    "get_status",
266    "get_trace",
267    "list_flows",
268    "propose_policy",
269];
270
271/// The fixed tool catalog, alphabetical by name. Schemas are deterministic
272/// values (sorted keys on serialization), so `tools/list` is byte-stable.
273fn tool_catalog() -> Value {
274    json!([
275        {
276            "description": "Explain a KEEL-E0NN error code: what happened, why, and what to do next. Byte-identical to `keel explain <code> --json`.",
277            "inputSchema": {
278                "properties": {
279                    "code": { "description": "The error code, e.g. \"KEEL-E014\".", "type": "string" }
280                },
281                "required": ["code"],
282                "type": "object"
283            },
284            "name": "explain_error"
285        },
286        {
287            "description": "The honesty report: what is wrapped, what is visible but unwrapped and why, adapter pins, policy validity, the journal backend, and a `boundaries` object naming what this report could not read — findings carry applyable fixes where possible. Static evidence, not a verdict: for evaluate/adopt/review work drive it through the keel skill's protocol. Byte-identical to `keel doctor --json`.",
288            "inputSchema": { "properties": {}, "type": "object" },
289            "name": "get_doctor_report"
290        },
291        {
292            "description": "One screen of what Keel is doing for this project: coverage, calls, retries saved, breaker opens, cache hit rate, and durable-flow counts. Byte-identical to `keel status --json`.",
293            "inputSchema": { "properties": {}, "type": "object" },
294            "name": "get_status"
295        },
296        {
297            "description": "Trace one durable (Tier 2) flow step by step: outcomes, attempts, timings. Byte-identical to `keel trace <flow> --json`.",
298            "inputSchema": {
299                "properties": {
300                    "flow": { "description": "A flow_id, or a substring of an id/entrypoint that names exactly one flow.", "type": "string" }
301                },
302                "required": ["flow"],
303                "type": "object"
304            },
305            "name": "get_trace"
306        },
307        {
308            "description": "List durable (Tier 2) flows: id, entrypoint, status, steps done/total. Byte-identical to `keel flows --json`.",
309            "inputSchema": {
310                "properties": {
311                    "dead": { "description": "List only dead flows (those that exhausted their resume cap).", "type": "boolean" }
312                },
313                "type": "object"
314            },
315            "name": "list_flows"
316        },
317        {
318            "description": "Propose policy changes as a keel.toml diff from static + observed evidence (never writes): an applyable unified patch plus structured changes. Byte-identical to `keel init --diff --json`.",
319            "inputSchema": { "properties": {}, "type": "object" },
320            "name": "propose_policy"
321        }
322    ])
323}
324
325/// A JSON-RPC error response frame.
326fn error_response(id: &Value, code: i64, message: &str) -> Value {
327    json!({
328        "error": { "code": code, "message": message },
329        "id": id,
330        "jsonrpc": "2.0",
331    })
332}
333
334/// A required string argument, or the invalid-params error naming the tool.
335fn require_str<'a>(args: &'a Value, key: &str, tool: &str) -> Result<&'a str, RpcError> {
336    args.get(key).and_then(Value::as_str).ok_or_else(|| {
337        rpc_error(
338            INVALID_PARAMS,
339            format!("{tool} requires a string `{key}` argument."),
340        )
341    })
342}
343
344/// An optional boolean argument (absent/null → `false`); a non-boolean value is
345/// an invalid-params error, never a silent coercion.
346fn optional_bool(args: &Value, key: &str, tool: &str) -> Result<bool, RpcError> {
347    match args.get(key) {
348        None | Some(Value::Null) => Ok(false),
349        Some(Value::Bool(b)) => Ok(*b),
350        Some(_) => Err(rpc_error(
351            INVALID_PARAMS,
352            format!("{tool}'s `{key}` argument must be a boolean."),
353        )),
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    const T0: i64 = 1_783_728_000_000;
362
363    fn t0() -> i64 {
364        T0
365    }
366
367    fn server_in(dir: &std::path::Path) -> Server {
368        Server::new(dir.to_path_buf(), t0)
369    }
370
371    fn empty_project() -> tempfile::TempDir {
372        tempfile::TempDir::new().unwrap()
373    }
374
375    /// Drive one request line and unwrap the response.
376    fn respond(server: &Server, line: &str) -> Value {
377        server.handle_line(line).expect("a response is due")
378    }
379
380    #[test]
381    fn initialize_echoes_a_supported_version_and_names_the_server() {
382        let dir = empty_project();
383        let r = respond(
384            &server_in(dir.path()),
385            r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}"#,
386        );
387        assert_eq!(r["id"], 1);
388        assert_eq!(r["result"]["protocolVersion"], "2025-03-26");
389        assert_eq!(r["result"]["serverInfo"]["name"], "keel");
390        assert_eq!(
391            r["result"]["serverInfo"]["version"],
392            env!("CARGO_PKG_VERSION")
393        );
394        assert!(r["result"]["capabilities"]["tools"].is_object());
395        assert!(
396            r["result"]["instructions"]
397                .as_str()
398                .unwrap()
399                .contains("byte-identical")
400        );
401    }
402
403    #[test]
404    fn initialize_with_an_unknown_version_offers_the_latest_supported() {
405        let dir = empty_project();
406        let r = respond(
407            &server_in(dir.path()),
408            r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2099-01-01"}}"#,
409        );
410        assert_eq!(r["result"]["protocolVersion"], LATEST_PROTOCOL);
411    }
412
413    #[test]
414    fn notifications_get_no_response() {
415        let dir = empty_project();
416        let s = server_in(dir.path());
417        assert!(
418            s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#)
419                .is_none()
420        );
421        // Unknown notifications are ignored too, per JSON-RPC 2.0.
422        assert!(
423            s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/whatever"}"#)
424                .is_none()
425        );
426    }
427
428    #[test]
429    fn parse_error_answers_with_null_id() {
430        let dir = empty_project();
431        let r = respond(&server_in(dir.path()), "{not json");
432        assert_eq!(r["error"]["code"], PARSE_ERROR);
433        assert!(r["id"].is_null());
434    }
435
436    #[test]
437    fn non_object_frames_are_invalid_requests() {
438        let dir = empty_project();
439        let r = respond(&server_in(dir.path()), "[1,2,3]");
440        assert_eq!(r["error"]["code"], INVALID_REQUEST);
441    }
442
443    #[test]
444    fn unknown_method_is_method_not_found() {
445        let dir = empty_project();
446        let r = respond(
447            &server_in(dir.path()),
448            r#"{"jsonrpc":"2.0","id":7,"method":"resources/list"}"#,
449        );
450        assert_eq!(r["error"]["code"], METHOD_NOT_FOUND);
451        assert_eq!(r["id"], 7);
452    }
453
454    #[test]
455    fn ping_answers_an_empty_object() {
456        let dir = empty_project();
457        let r = respond(
458            &server_in(dir.path()),
459            r#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#,
460        );
461        assert_eq!(r["result"], json!({}));
462    }
463
464    #[test]
465    fn tools_list_is_the_six_spec_tools_alphabetically() {
466        let dir = empty_project();
467        let r = respond(
468            &server_in(dir.path()),
469            r#"{"jsonrpc":"2.0","id":3,"method":"tools/list"}"#,
470        );
471        let names: Vec<&str> = r["result"]["tools"]
472            .as_array()
473            .unwrap()
474            .iter()
475            .map(|t| t["name"].as_str().unwrap())
476            .collect();
477        assert_eq!(
478            names,
479            [
480                "explain_error",
481                "get_doctor_report",
482                "get_status",
483                "get_trace",
484                "list_flows",
485                "propose_policy",
486            ]
487        );
488        // Every tool declares an object input schema.
489        for tool in r["result"]["tools"].as_array().unwrap() {
490            assert_eq!(tool["inputSchema"]["type"], "object");
491            assert!(tool["description"].as_str().unwrap().contains("--json"));
492        }
493    }
494
495    #[test]
496    fn unknown_tool_is_invalid_params() {
497        let dir = empty_project();
498        let r = respond(
499            &server_in(dir.path()),
500            r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get_everything"}}"#,
501        );
502        assert_eq!(r["error"]["code"], INVALID_PARAMS);
503        assert!(
504            r["error"]["message"]
505                .as_str()
506                .unwrap()
507                .contains("Available tools")
508        );
509    }
510
511    #[test]
512    fn missing_required_argument_is_invalid_params() {
513        let dir = empty_project();
514        let s = server_in(dir.path());
515        let r = respond(
516            &s,
517            r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"get_trace"}}"#,
518        );
519        assert_eq!(r["error"]["code"], INVALID_PARAMS);
520        assert!(r["error"]["message"].as_str().unwrap().contains("`flow`"));
521        let r = respond(
522            &s,
523            r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"explain_error","arguments":{}}}"#,
524        );
525        assert_eq!(r["error"]["code"], INVALID_PARAMS);
526        assert!(r["error"]["message"].as_str().unwrap().contains("`code`"));
527    }
528
529    #[test]
530    fn mistyped_dead_argument_is_invalid_params_not_coerced() {
531        let dir = empty_project();
532        let r = respond(
533            &server_in(dir.path()),
534            r#"{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"list_flows","arguments":{"dead":"yes"}}}"#,
535        );
536        assert_eq!(r["error"]["code"], INVALID_PARAMS);
537        assert!(r["error"]["message"].as_str().unwrap().contains("boolean"));
538    }
539
540    #[test]
541    fn explain_error_text_is_byte_identical_to_the_json_twin() {
542        let dir = empty_project();
543        let r = respond(
544            &server_in(dir.path()),
545            r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"explain_error","arguments":{"code":"KEEL-E014"}}}"#,
546        );
547        assert_eq!(r["result"]["isError"], false);
548        assert_eq!(
549            r["result"]["content"][0]["text"].as_str().unwrap(),
550            json_string(&explain::run("KEEL-E014").json)
551        );
552        assert_eq!(r["result"]["content"][0]["type"], "text");
553    }
554
555    #[test]
556    fn a_failing_tool_is_a_result_with_is_error_not_a_protocol_error() {
557        let dir = empty_project();
558        let r = respond(
559            &server_in(dir.path()),
560            r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"explain_error","arguments":{"code":"KEEL-E999"}}}"#,
561        );
562        assert!(r.get("error").is_none(), "tool failures are not RPC errors");
563        assert_eq!(r["result"]["isError"], true);
564        assert_eq!(
565            r["result"]["content"][0]["text"].as_str().unwrap(),
566            json_string(&explain::run("KEEL-E999").json)
567        );
568    }
569
570    #[test]
571    fn list_flows_defaults_dead_to_false_and_accepts_true() {
572        let dir = empty_project();
573        let s = server_in(dir.path());
574        let r = respond(
575            &s,
576            r#"{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"list_flows"}}"#,
577        );
578        let text = r["result"]["content"][0]["text"].as_str().unwrap();
579        assert_eq!(text, json_string(&flows::flows(dir.path(), false, T0).json));
580        let r = respond(
581            &s,
582            r#"{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"list_flows","arguments":{"dead":true}}}"#,
583        );
584        let text = r["result"]["content"][0]["text"].as_str().unwrap();
585        assert_eq!(text, json_string(&flows::flows(dir.path(), true, T0).json));
586    }
587
588    #[test]
589    fn serve_skips_blank_lines_and_exits_ok_on_eof() {
590        let dir = empty_project();
591        let script = "\n\
592            {\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n\
593            \n\
594            {\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n";
595        let mut out = Vec::new();
596        let code = server_in(dir.path()).serve(std::io::Cursor::new(script), &mut out);
597        assert_eq!(code, EXIT_OK);
598        let lines: Vec<&str> = std::str::from_utf8(&out).unwrap().lines().collect();
599        assert_eq!(lines.len(), 1, "one request → one response line");
600        let v: Value = serde_json::from_str(lines[0]).unwrap();
601        assert_eq!(v["id"], 1);
602    }
603}