1use 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
27const LATEST_PROTOCOL: &str = "2025-06-18";
30
31const SUPPORTED_PROTOCOLS: [&str; 3] = ["2024-11-05", "2025-03-26", "2025-06-18"];
35
36const 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
48const PARSE_ERROR: i64 = -32700;
50const INVALID_REQUEST: i64 = -32600;
51const METHOD_NOT_FOUND: i64 = -32601;
52const INVALID_PARAMS: i64 = -32602;
53
54struct RpcError {
57 code: i64,
58 message: String,
59}
60
61fn rpc_error(code: i64, message: impl Into<String>) -> RpcError {
63 RpcError {
64 code,
65 message: message.into(),
66 }
67}
68
69#[derive(Debug)]
73pub struct Server {
74 project: PathBuf,
75 now_ms: fn() -> i64,
76}
77
78impl Server {
79 #[must_use]
81 pub fn new(project: PathBuf, now_ms: fn() -> i64) -> Self {
82 Self { project, now_ms }
83 }
84
85 pub fn serve<R: BufRead, W: Write>(&self, input: R, mut output: W) -> i32 {
89 for line in input.lines() {
90 let Ok(line) = line else {
91 return EXIT_FAILURE;
92 };
93 if line.trim().is_empty() {
94 continue;
95 }
96 let Some(response) = self.handle_line(&line) else {
97 continue;
98 };
99 let Ok(text) = serde_json::to_string(&response) else {
100 return EXIT_FAILURE;
101 };
102 if writeln!(output, "{text}")
103 .and_then(|()| output.flush())
104 .is_err()
105 {
106 return EXIT_FAILURE;
107 }
108 }
109 EXIT_OK
110 }
111
112 fn handle_line(&self, line: &str) -> Option<Value> {
115 let Ok(message) = serde_json::from_str::<Value>(line) else {
116 return Some(error_response(
117 &Value::Null,
118 PARSE_ERROR,
119 "Parse error: the line is not valid JSON. Send one JSON-RPC 2.0 message per line.",
120 ));
121 };
122 self.handle_message(&message)
123 }
124
125 fn handle_message(&self, message: &Value) -> Option<Value> {
127 let Some(frame) = message.as_object() else {
128 return Some(error_response(
129 &Value::Null,
130 INVALID_REQUEST,
131 "Invalid request: expected a JSON-RPC 2.0 object (batches are not supported).",
132 ));
133 };
134 let id = frame.get("id").cloned();
135 let Some(method) = frame.get("method").and_then(Value::as_str) else {
136 return id.map(|id| {
139 error_response(
140 &id,
141 INVALID_REQUEST,
142 "Invalid request: missing `method`. This server accepts initialize, ping, tools/list, and tools/call.",
143 )
144 });
145 };
146 let params = frame.get("params").cloned().unwrap_or(Value::Null);
147 let id = id?;
149 let outcome = match method {
150 "initialize" => Ok(initialize_result(¶ms)),
151 "ping" => Ok(json!({})),
152 "tools/call" => self.tools_call(¶ms),
153 "tools/list" => Ok(json!({ "tools": tool_catalog() })),
154 other => Err(rpc_error(
155 METHOD_NOT_FOUND,
156 format!(
157 "Method not found: {other:?}. This server supports initialize, ping, tools/list, and tools/call."
158 ),
159 )),
160 };
161 Some(match outcome {
162 Ok(result) => json!({ "id": id, "jsonrpc": "2.0", "result": result }),
163 Err(e) => error_response(&id, e.code, &e.message),
164 })
165 }
166
167 fn tools_call(&self, params: &Value) -> Result<Value, RpcError> {
172 let Some(name) = params.get("name").and_then(Value::as_str) else {
173 return Err(rpc_error(
174 INVALID_PARAMS,
175 "tools/call requires a string `name` parameter naming the tool.",
176 ));
177 };
178 let args = params
179 .get("arguments")
180 .cloned()
181 .unwrap_or_else(|| json!({}));
182 let rendered = self.call_tool(name, &args)?;
183 Ok(json!({
184 "content": [ { "text": json_string(&rendered.json), "type": "text" } ],
185 "isError": rendered.exit != EXIT_OK,
186 }))
187 }
188
189 fn call_tool(&self, name: &str, args: &Value) -> Result<Rendered, RpcError> {
193 match name {
194 "explain_error" => Ok(explain::run(require_str(args, "code", name)?)),
195 "get_doctor_report" => Ok(doctor::run(&self.project)),
196 "get_status" => Ok(status::run(&self.project, (self.now_ms)())),
197 "get_trace" => Ok(flows::trace(
198 &self.project,
199 require_str(args, "flow", name)?,
200 )),
201 "list_flows" => Ok(flows::flows(
202 &self.project,
203 optional_bool(args, "dead", name)?,
204 (self.now_ms)(),
205 )),
206 "propose_policy" => Ok(init::run(
207 &self.project,
208 init::InitOptions {
209 diff: true,
210 stamp: false,
211 agents: false,
212 },
213 )),
214 other => Err(rpc_error(
215 INVALID_PARAMS,
216 format!(
217 "Unknown tool: {other:?}. Available tools: explain_error, get_doctor_report, get_status, get_trace, list_flows, propose_policy."
218 ),
219 )),
220 }
221 }
222}
223
224fn initialize_result(params: &Value) -> Value {
227 let requested = params
228 .get("protocolVersion")
229 .and_then(Value::as_str)
230 .unwrap_or(LATEST_PROTOCOL);
231 let version = if SUPPORTED_PROTOCOLS.contains(&requested) {
232 requested
233 } else {
234 LATEST_PROTOCOL
235 };
236 json!({
237 "capabilities": { "tools": {} },
238 "instructions": INSTRUCTIONS,
239 "protocolVersion": version,
240 "serverInfo": { "name": "keel", "version": env!("CARGO_PKG_VERSION") },
241 })
242}
243
244pub const TOOL_NAMES: [&str; 6] = [
248 "explain_error",
249 "get_doctor_report",
250 "get_status",
251 "get_trace",
252 "list_flows",
253 "propose_policy",
254];
255
256fn tool_catalog() -> Value {
259 json!([
260 {
261 "description": "Explain a KEEL-E0NN error code: what happened, why, and what to do next. Byte-identical to `keel explain <code> --json`.",
262 "inputSchema": {
263 "properties": {
264 "code": { "description": "The error code, e.g. \"KEEL-E014\".", "type": "string" }
265 },
266 "required": ["code"],
267 "type": "object"
268 },
269 "name": "explain_error"
270 },
271 {
272 "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`.",
273 "inputSchema": { "properties": {}, "type": "object" },
274 "name": "get_doctor_report"
275 },
276 {
277 "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`.",
278 "inputSchema": { "properties": {}, "type": "object" },
279 "name": "get_status"
280 },
281 {
282 "description": "Trace one durable (Tier 2) flow step by step: outcomes, attempts, timings. Byte-identical to `keel trace <flow> --json`.",
283 "inputSchema": {
284 "properties": {
285 "flow": { "description": "A flow_id, or a substring of an id/entrypoint that names exactly one flow.", "type": "string" }
286 },
287 "required": ["flow"],
288 "type": "object"
289 },
290 "name": "get_trace"
291 },
292 {
293 "description": "List durable (Tier 2) flows: id, entrypoint, status, steps done/total. Byte-identical to `keel flows --json`.",
294 "inputSchema": {
295 "properties": {
296 "dead": { "description": "List only dead flows (those that exhausted their resume cap).", "type": "boolean" }
297 },
298 "type": "object"
299 },
300 "name": "list_flows"
301 },
302 {
303 "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`.",
304 "inputSchema": { "properties": {}, "type": "object" },
305 "name": "propose_policy"
306 }
307 ])
308}
309
310fn error_response(id: &Value, code: i64, message: &str) -> Value {
312 json!({
313 "error": { "code": code, "message": message },
314 "id": id,
315 "jsonrpc": "2.0",
316 })
317}
318
319fn require_str<'a>(args: &'a Value, key: &str, tool: &str) -> Result<&'a str, RpcError> {
321 args.get(key).and_then(Value::as_str).ok_or_else(|| {
322 rpc_error(
323 INVALID_PARAMS,
324 format!("{tool} requires a string `{key}` argument."),
325 )
326 })
327}
328
329fn optional_bool(args: &Value, key: &str, tool: &str) -> Result<bool, RpcError> {
332 match args.get(key) {
333 None | Some(Value::Null) => Ok(false),
334 Some(Value::Bool(b)) => Ok(*b),
335 Some(_) => Err(rpc_error(
336 INVALID_PARAMS,
337 format!("{tool}'s `{key}` argument must be a boolean."),
338 )),
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 const T0: i64 = 1_783_728_000_000;
347
348 fn t0() -> i64 {
349 T0
350 }
351
352 fn server_in(dir: &std::path::Path) -> Server {
353 Server::new(dir.to_path_buf(), t0)
354 }
355
356 fn empty_project() -> tempfile::TempDir {
357 tempfile::TempDir::new().unwrap()
358 }
359
360 fn respond(server: &Server, line: &str) -> Value {
362 server.handle_line(line).expect("a response is due")
363 }
364
365 #[test]
366 fn initialize_echoes_a_supported_version_and_names_the_server() {
367 let dir = empty_project();
368 let r = respond(
369 &server_in(dir.path()),
370 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}"#,
371 );
372 assert_eq!(r["id"], 1);
373 assert_eq!(r["result"]["protocolVersion"], "2025-03-26");
374 assert_eq!(r["result"]["serverInfo"]["name"], "keel");
375 assert_eq!(
376 r["result"]["serverInfo"]["version"],
377 env!("CARGO_PKG_VERSION")
378 );
379 assert!(r["result"]["capabilities"]["tools"].is_object());
380 assert!(
381 r["result"]["instructions"]
382 .as_str()
383 .unwrap()
384 .contains("byte-identical")
385 );
386 }
387
388 #[test]
389 fn initialize_with_an_unknown_version_offers_the_latest_supported() {
390 let dir = empty_project();
391 let r = respond(
392 &server_in(dir.path()),
393 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2099-01-01"}}"#,
394 );
395 assert_eq!(r["result"]["protocolVersion"], LATEST_PROTOCOL);
396 }
397
398 #[test]
399 fn notifications_get_no_response() {
400 let dir = empty_project();
401 let s = server_in(dir.path());
402 assert!(
403 s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#)
404 .is_none()
405 );
406 assert!(
408 s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/whatever"}"#)
409 .is_none()
410 );
411 }
412
413 #[test]
414 fn parse_error_answers_with_null_id() {
415 let dir = empty_project();
416 let r = respond(&server_in(dir.path()), "{not json");
417 assert_eq!(r["error"]["code"], PARSE_ERROR);
418 assert!(r["id"].is_null());
419 }
420
421 #[test]
422 fn non_object_frames_are_invalid_requests() {
423 let dir = empty_project();
424 let r = respond(&server_in(dir.path()), "[1,2,3]");
425 assert_eq!(r["error"]["code"], INVALID_REQUEST);
426 }
427
428 #[test]
429 fn unknown_method_is_method_not_found() {
430 let dir = empty_project();
431 let r = respond(
432 &server_in(dir.path()),
433 r#"{"jsonrpc":"2.0","id":7,"method":"resources/list"}"#,
434 );
435 assert_eq!(r["error"]["code"], METHOD_NOT_FOUND);
436 assert_eq!(r["id"], 7);
437 }
438
439 #[test]
440 fn ping_answers_an_empty_object() {
441 let dir = empty_project();
442 let r = respond(
443 &server_in(dir.path()),
444 r#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#,
445 );
446 assert_eq!(r["result"], json!({}));
447 }
448
449 #[test]
450 fn tools_list_is_the_six_spec_tools_alphabetically() {
451 let dir = empty_project();
452 let r = respond(
453 &server_in(dir.path()),
454 r#"{"jsonrpc":"2.0","id":3,"method":"tools/list"}"#,
455 );
456 let names: Vec<&str> = r["result"]["tools"]
457 .as_array()
458 .unwrap()
459 .iter()
460 .map(|t| t["name"].as_str().unwrap())
461 .collect();
462 assert_eq!(
463 names,
464 [
465 "explain_error",
466 "get_doctor_report",
467 "get_status",
468 "get_trace",
469 "list_flows",
470 "propose_policy",
471 ]
472 );
473 for tool in r["result"]["tools"].as_array().unwrap() {
475 assert_eq!(tool["inputSchema"]["type"], "object");
476 assert!(tool["description"].as_str().unwrap().contains("--json"));
477 }
478 }
479
480 #[test]
481 fn unknown_tool_is_invalid_params() {
482 let dir = empty_project();
483 let r = respond(
484 &server_in(dir.path()),
485 r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get_everything"}}"#,
486 );
487 assert_eq!(r["error"]["code"], INVALID_PARAMS);
488 assert!(
489 r["error"]["message"]
490 .as_str()
491 .unwrap()
492 .contains("Available tools")
493 );
494 }
495
496 #[test]
497 fn missing_required_argument_is_invalid_params() {
498 let dir = empty_project();
499 let s = server_in(dir.path());
500 let r = respond(
501 &s,
502 r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"get_trace"}}"#,
503 );
504 assert_eq!(r["error"]["code"], INVALID_PARAMS);
505 assert!(r["error"]["message"].as_str().unwrap().contains("`flow`"));
506 let r = respond(
507 &s,
508 r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"explain_error","arguments":{}}}"#,
509 );
510 assert_eq!(r["error"]["code"], INVALID_PARAMS);
511 assert!(r["error"]["message"].as_str().unwrap().contains("`code`"));
512 }
513
514 #[test]
515 fn mistyped_dead_argument_is_invalid_params_not_coerced() {
516 let dir = empty_project();
517 let r = respond(
518 &server_in(dir.path()),
519 r#"{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"list_flows","arguments":{"dead":"yes"}}}"#,
520 );
521 assert_eq!(r["error"]["code"], INVALID_PARAMS);
522 assert!(r["error"]["message"].as_str().unwrap().contains("boolean"));
523 }
524
525 #[test]
526 fn explain_error_text_is_byte_identical_to_the_json_twin() {
527 let dir = empty_project();
528 let r = respond(
529 &server_in(dir.path()),
530 r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"explain_error","arguments":{"code":"KEEL-E014"}}}"#,
531 );
532 assert_eq!(r["result"]["isError"], false);
533 assert_eq!(
534 r["result"]["content"][0]["text"].as_str().unwrap(),
535 json_string(&explain::run("KEEL-E014").json)
536 );
537 assert_eq!(r["result"]["content"][0]["type"], "text");
538 }
539
540 #[test]
541 fn a_failing_tool_is_a_result_with_is_error_not_a_protocol_error() {
542 let dir = empty_project();
543 let r = respond(
544 &server_in(dir.path()),
545 r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"explain_error","arguments":{"code":"KEEL-E999"}}}"#,
546 );
547 assert!(r.get("error").is_none(), "tool failures are not RPC errors");
548 assert_eq!(r["result"]["isError"], true);
549 assert_eq!(
550 r["result"]["content"][0]["text"].as_str().unwrap(),
551 json_string(&explain::run("KEEL-E999").json)
552 );
553 }
554
555 #[test]
556 fn list_flows_defaults_dead_to_false_and_accepts_true() {
557 let dir = empty_project();
558 let s = server_in(dir.path());
559 let r = respond(
560 &s,
561 r#"{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"list_flows"}}"#,
562 );
563 let text = r["result"]["content"][0]["text"].as_str().unwrap();
564 assert_eq!(text, json_string(&flows::flows(dir.path(), false, T0).json));
565 let r = respond(
566 &s,
567 r#"{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"list_flows","arguments":{"dead":true}}}"#,
568 );
569 let text = r["result"]["content"][0]["text"].as_str().unwrap();
570 assert_eq!(text, json_string(&flows::flows(dir.path(), true, T0).json));
571 }
572
573 #[test]
574 fn serve_skips_blank_lines_and_exits_ok_on_eof() {
575 let dir = empty_project();
576 let script = "\n\
577 {\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n\
578 \n\
579 {\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n";
580 let mut out = Vec::new();
581 let code = server_in(dir.path()).serve(std::io::Cursor::new(script), &mut out);
582 assert_eq!(code, EXIT_OK);
583 let lines: Vec<&str> = std::str::from_utf8(&out).unwrap().lines().collect();
584 assert_eq!(lines.len(), 1, "one request → one response line");
585 let v: Value = serde_json::from_str(lines[0]).unwrap();
586 assert_eq!(v["id"], 1);
587 }
588}