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
69pub 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 #[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 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 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 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 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 let id = id?;
160 let outcome = match method {
161 "initialize" => Ok(initialize_result(¶ms)),
162 "ping" => Ok(json!({})),
163 "tools/call" => self.tools_call(¶ms),
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 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 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
235fn 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
255pub 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
267fn 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
321fn 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
330fn 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
340fn 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 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 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 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}