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, 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
52const PARSE_ERROR: i64 = -32700;
54const INVALID_REQUEST: i64 = -32600;
55const METHOD_NOT_FOUND: i64 = -32601;
56const INVALID_PARAMS: i64 = -32602;
57
58struct RpcError {
61 code: i64,
62 message: String,
63}
64
65fn rpc_error(code: i64, message: impl Into<String>) -> RpcError {
67 RpcError {
68 code,
69 message: message.into(),
70 }
71}
72
73pub 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 #[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 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 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 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 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 let id = id?;
164 let outcome = match method {
165 "initialize" => Ok(initialize_result(¶ms)),
166 "ping" => Ok(json!({})),
167 "tools/call" => self.tools_call(¶ms),
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 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 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
239fn 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
259pub 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
271fn 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
325fn 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
334fn 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
344fn 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 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 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 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}