1use firstpass_core::{DeferredVerdict, Score, Verdict};
10use serde_json::{Value, json};
11
12use crate::store;
13
14const PROTOCOL_VERSION: &str = "2024-11-05";
16
17fn tool_schemas() -> Value {
20 json!([
21 {
22 "name": "list_traces",
23 "description": "List the most recent Firstpass audit traces (routing decisions + receipts).",
24 "inputSchema": {
25 "type": "object",
26 "properties": { "limit": { "type": "integer", "description": "Max traces to return (default 20)." } }
27 }
28 },
29 {
30 "name": "get_trace",
31 "description": "Fetch a single audit trace by id, with any deferred verdicts merged in.",
32 "inputSchema": {
33 "type": "object",
34 "properties": { "trace_id": { "type": "string" } },
35 "required": ["trace_id"]
36 }
37 },
38 {
39 "name": "get_savings",
40 "description": "Aggregate spend vs the always-top counterfactual from this deployment's own receipts — measured savings, not a marketing number.",
41 "inputSchema": { "type": "object", "properties": {} }
42 },
43 {
44 "name": "get_evals",
45 "description": "Per-gate verdict rates, escalation count, and serve-by-rung distribution computed from receipts — the live eval suite.",
46 "inputSchema": { "type": "object", "properties": {} }
47 },
48 {
49 "name": "submit_feedback",
50 "description": "Attach a downstream outcome (deferred verdict) to a past decision, closing the feedback loop.",
51 "inputSchema": {
52 "type": "object",
53 "properties": {
54 "trace_id": { "type": "string" },
55 "gate_id": { "type": "string" },
56 "verdict": { "type": "string", "enum": ["pass", "fail", "abstain"] },
57 "score": { "type": "number" },
58 "reporter": { "type": "string" }
59 },
60 "required": ["trace_id", "gate_id", "verdict", "reporter"]
61 }
62 }
63 ])
64}
65
66#[must_use]
70pub fn handle_rpc(req: &Value, db_path: &str, tenant: &str) -> Option<Value> {
71 let is_notification = req.get("id").is_none();
72 let id = req.get("id").cloned().unwrap_or(Value::Null);
73 let method = req
74 .get("method")
75 .and_then(Value::as_str)
76 .unwrap_or_default();
77
78 match method {
79 "initialize" => Some(ok(
80 id,
81 json!({
82 "protocolVersion": PROTOCOL_VERSION,
83 "capabilities": { "tools": {} },
84 "serverInfo": { "name": "firstpass", "version": env!("CARGO_PKG_VERSION") }
85 }),
86 )),
87 "tools/list" => Some(ok(id, json!({ "tools": tool_schemas() }))),
88 "tools/call" => Some(handle_tool_call(id, req.get("params"), db_path, tenant)),
89 "ping" => Some(ok(id, json!({}))),
90 _ if is_notification => None,
92 other => Some(err(id, -32601, &format!("method not found: {other}"))),
93 }
94}
95
96fn handle_tool_call(id: Value, params: Option<&Value>, db_path: &str, tenant: &str) -> Value {
99 let name = params
100 .and_then(|p| p.get("name"))
101 .and_then(Value::as_str)
102 .unwrap_or_default();
103 let args = params
104 .and_then(|p| p.get("arguments"))
105 .cloned()
106 .unwrap_or_else(|| json!({}));
107
108 let result = match name {
109 "list_traces" => tool_list_traces(&args, db_path, tenant),
110 "get_savings" => tool_get_savings(db_path, tenant),
111 "get_evals" => tool_get_evals(db_path, tenant),
112 "get_trace" => tool_get_trace(&args, db_path, tenant),
113 "submit_feedback" => tool_submit_feedback(&args, db_path, tenant),
114 other => Err(format!("unknown tool: {other}")),
115 };
116
117 match result {
118 Ok(text) => ok(id, json!({ "content": [{ "type": "text", "text": text }] })),
119 Err(msg) => ok(
120 id,
121 json!({ "content": [{ "type": "text", "text": msg }], "isError": true }),
122 ),
123 }
124}
125
126fn tool_list_traces(args: &Value, db_path: &str, tenant: &str) -> Result<String, String> {
127 let limit = args
128 .get("limit")
129 .and_then(Value::as_u64)
130 .map_or(20, |n| n as usize);
131 let all = store::load_tenant_traces(db_path, tenant).unwrap_or_default();
135 let start = all.len().saturating_sub(limit);
136 let recent = &all[start..];
137 serde_json::to_string(recent).map_err(|e| format!("encode error: {e}"))
138}
139
140fn tool_get_savings(db_path: &str, tenant: &str) -> Result<String, String> {
141 let traces = store::load_tenant_traces(db_path, tenant).unwrap_or_default();
143 serde_json::to_string(&crate::cli::summarize_savings(&traces))
144 .map_err(|e| format!("encode error: {e}"))
145}
146
147fn tool_get_evals(db_path: &str, tenant: &str) -> Result<String, String> {
148 let traces = store::load_tenant_traces(db_path, tenant).unwrap_or_default();
149 serde_json::to_string(&crate::cli::summarize_evals(&traces))
150 .map_err(|e| format!("encode error: {e}"))
151}
152
153fn tool_get_trace(args: &Value, db_path: &str, tenant: &str) -> Result<String, String> {
154 let trace_id = args
155 .get("trace_id")
156 .and_then(Value::as_str)
157 .ok_or("missing `trace_id`")?;
158 match store::load_trace_view(db_path, tenant, trace_id)
159 .map_err(|e| format!("store error: {e}"))?
160 {
161 Some(trace) => serde_json::to_string(&trace).map_err(|e| format!("encode error: {e}")),
162 None => Err(format!("unknown trace_id {trace_id:?}")),
163 }
164}
165
166fn tool_submit_feedback(args: &Value, db_path: &str, tenant: &str) -> Result<String, String> {
167 let trace_id = args
168 .get("trace_id")
169 .and_then(Value::as_str)
170 .ok_or("missing `trace_id`")?;
171 let gate_id = args
172 .get("gate_id")
173 .and_then(Value::as_str)
174 .ok_or("missing `gate_id`")?;
175 let reporter = args
176 .get("reporter")
177 .and_then(Value::as_str)
178 .ok_or("missing `reporter`")?;
179 let verdict = match args.get("verdict").and_then(Value::as_str) {
180 Some("pass") => Verdict::Pass,
181 Some("fail") => Verdict::Fail,
182 Some("abstain") => Verdict::Abstain,
183 other => return Err(format!("invalid verdict {other:?}")),
184 };
185 let score = match args.get("score") {
186 None | Some(Value::Null) => None,
187 Some(v) => {
188 let s = v.as_f64().ok_or("score must be a number")?;
189 Some(Score::new(s).map_err(|_| format!("score {s} out of range [0,1]"))?)
190 }
191 };
192
193 if !store::trace_exists(db_path, tenant, trace_id).map_err(|e| format!("store error: {e}"))? {
196 return Err(format!("unknown trace_id {trace_id:?}"));
197 }
198 let dv = DeferredVerdict {
199 gate_id: gate_id.to_owned(),
200 verdict,
201 score,
202 reported_at: jiff::Timestamp::now(),
203 reporter: reporter.to_owned(),
204 };
205 store::append_deferred(db_path, trace_id, &dv).map_err(|e| format!("store error: {e}"))?;
206 Ok(json!({ "status": "recorded", "trace_id": trace_id }).to_string())
207}
208
209fn ok(id: Value, result: Value) -> Value {
210 json!({ "jsonrpc": "2.0", "id": id, "result": result })
211}
212
213fn err(id: Value, code: i64, message: &str) -> Value {
214 json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
215}
216
217pub fn serve_stdio(db_path: &str, tenant: &str) -> std::io::Result<()> {
227 use std::io::{BufRead, Write};
228 let stdin = std::io::stdin();
229 let mut stdout = std::io::stdout();
230 for line in stdin.lock().lines() {
231 let line = line?;
232 if line.trim().is_empty() {
233 continue;
234 }
235 let response = match serde_json::from_str::<Value>(&line) {
236 Ok(req) => handle_rpc(&req, db_path, tenant),
237 Err(_) => Some(err(Value::Null, -32700, "parse error")),
238 };
239 if let Some(resp) = response {
240 writeln!(stdout, "{resp}")?;
241 stdout.flush()?;
242 }
243 }
244 Ok(())
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn tmp_db() -> String {
252 std::env::temp_dir()
253 .join(format!("firstpass-mcp-{}.db", uuid::Uuid::now_v7()))
254 .to_string_lossy()
255 .into_owned()
256 }
257
258 #[test]
259 fn initialize_announces_protocol_and_server() {
260 let req = json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize" });
261 let resp = handle_rpc(&req, "unused.db", "default").unwrap();
262 assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
263 assert_eq!(resp["result"]["serverInfo"]["name"], "firstpass");
264 assert_eq!(resp["id"], 1);
265 }
266
267 #[test]
268 fn tools_list_exposes_every_tool() {
269 let req = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" });
270 let resp = handle_rpc(&req, "unused.db", "default").unwrap();
271 let names: Vec<&str> = resp["result"]["tools"]
272 .as_array()
273 .unwrap()
274 .iter()
275 .filter_map(|t| t["name"].as_str())
276 .collect();
277 assert_eq!(
278 names,
279 [
280 "list_traces",
281 "get_trace",
282 "get_savings",
283 "get_evals",
284 "submit_feedback"
285 ]
286 );
287 }
288
289 #[test]
290 fn notifications_get_no_response() {
291 let req = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
292 assert!(handle_rpc(&req, "unused.db", "default").is_none());
293 }
294
295 #[test]
296 fn unknown_method_errors_for_requests_but_not_notifications() {
297 let request = json!({ "jsonrpc": "2.0", "id": 9, "method": "does/not/exist" });
298 let resp = handle_rpc(&request, "unused.db", "default").unwrap();
299 assert_eq!(resp["error"]["code"], -32601);
300
301 let notification = json!({ "jsonrpc": "2.0", "method": "does/not/exist" });
302 assert!(handle_rpc(¬ification, "unused.db", "default").is_none());
303 }
304
305 #[test]
306 fn list_traces_on_empty_store_is_empty_not_error() {
307 let db = tmp_db();
308 let req = json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
309 "params": { "name": "list_traces", "arguments": {} } });
310 let resp = handle_rpc(&req, &db, "default").unwrap();
311 assert!(
312 resp["result"]["isError"].is_null(),
313 "empty store is not an error"
314 );
315 assert_eq!(resp["result"]["content"][0]["text"], "[]");
316 }
317
318 #[test]
319 fn get_trace_unknown_is_tool_error() {
320 let db = tmp_db();
321 let _ = store::load_all_traces(&db);
323 let req = json!({ "jsonrpc": "2.0", "id": 4, "method": "tools/call",
324 "params": { "name": "get_trace", "arguments": { "trace_id": "nope" } } });
325 let resp = handle_rpc(&req, &db, "default").unwrap();
326 assert_eq!(resp["result"]["isError"], true);
327 }
328
329 #[test]
330 fn submit_feedback_validates_and_rejects_unknown_trace() {
331 let db = tmp_db();
332 let _ = store::load_all_traces(&db);
333
334 let bad = json!({ "jsonrpc": "2.0", "id": 5, "method": "tools/call",
336 "params": { "name": "submit_feedback", "arguments": {
337 "trace_id": "t", "gate_id": "g", "verdict": "banana", "reporter": "ci" } } });
338 assert_eq!(
339 handle_rpc(&bad, &db, "default").unwrap()["result"]["isError"],
340 true
341 );
342
343 let unknown = json!({ "jsonrpc": "2.0", "id": 6, "method": "tools/call",
345 "params": { "name": "submit_feedback", "arguments": {
346 "trace_id": "missing", "gate_id": "g", "verdict": "pass", "reporter": "ci" } } });
347 assert_eq!(
348 handle_rpc(&unknown, &db, "default").unwrap()["result"]["isError"],
349 true
350 );
351 }
352
353 #[test]
354 fn unknown_tool_is_tool_error() {
355 let req = json!({ "jsonrpc": "2.0", "id": 7, "method": "tools/call",
356 "params": { "name": "no_such_tool", "arguments": {} } });
357 let resp = handle_rpc(&req, "unused.db", "default").unwrap();
358 assert_eq!(resp["result"]["isError"], true);
359 }
360
361 #[test]
362 fn savings_and_evals_tools_are_listed_and_zero_state_on_fresh_store() {
363 let db = tmp_db();
364 let db_str = db.as_str();
365
366 let listed = handle_rpc(
367 &json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }),
368 db_str,
369 "default",
370 )
371 .unwrap();
372 let names: Vec<&str> = listed["result"]["tools"]
373 .as_array()
374 .unwrap()
375 .iter()
376 .filter_map(|t| t["name"].as_str())
377 .collect();
378 assert!(names.contains(&"get_savings") && names.contains(&"get_evals"));
379
380 for tool in ["get_savings", "get_evals"] {
381 let out = handle_rpc(
382 &json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
383 "params": { "name": tool, "arguments": {} } }),
384 db_str,
385 "default",
386 )
387 .unwrap();
388 let text = out["result"]["content"][0]["text"].as_str().unwrap();
389 let parsed: serde_json::Value = serde_json::from_str(text).unwrap();
390 assert!(
391 parsed.is_object(),
392 "{tool} must return a JSON summary even on a fresh store"
393 );
394 }
395 }
396}