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