1use firstpass_core::{DeferredVerdict, RoutingMode, 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": "explain_route",
50 "description": "Explain one routing decision from its sealed receipt — served model, per-rung verdicts, escalations, cost vs the always-top baseline, and the savings.",
51 "inputSchema": {
52 "type": "object",
53 "properties": { "trace_id": { "type": "string" } },
54 "required": ["trace_id"]
55 }
56 },
57 {
58 "name": "rehearse_policy",
59 "description": "Rehearse a candidate routing policy against this deployment's logged traffic BEFORE enforcing it: estimated cost and served-failure with coverage, from a policy TOML (ladder + gates + serve_threshold). Off-policy replay, never a live change.",
60 "inputSchema": {
61 "type": "object",
62 "properties": { "policy_toml": { "type": "string", "description": "A candidate policy in the firstpass.toml route/escalation dialect." } },
63 "required": ["policy_toml"]
64 }
65 },
66 {
67 "name": "verify_receipts",
68 "description": "Independently re-derive the receipt hash chain from genesis over this deployment's log; reports whether it is intact (tamper-evident self-audit).",
69 "inputSchema": { "type": "object", "properties": {} }
70 },
71 {
72 "name": "submit_feedback",
73 "description": "Attach a downstream outcome (deferred verdict) to a past decision, closing the feedback loop.",
74 "inputSchema": {
75 "type": "object",
76 "properties": {
77 "trace_id": { "type": "string" },
78 "gate_id": { "type": "string" },
79 "verdict": { "type": "string", "enum": ["pass", "fail", "abstain"] },
80 "score": { "type": "number" },
81 "reporter": { "type": "string" }
82 },
83 "required": ["trace_id", "gate_id", "verdict", "reporter"]
84 }
85 },
86 {
87 "name": "list_modes",
88 "description": "List the supported routing-mode profiles with their knob overrides and honest tradeoffs. Set a mode via the x-firstpass-mode header, per-route routing_mode, or FIRSTPASS_MODE_PROFILE env var.",
89 "inputSchema": { "type": "object", "properties": {} }
90 }
91 ])
92}
93
94#[must_use]
98pub fn handle_rpc(req: &Value, db_path: &str, tenant: &str) -> Option<Value> {
99 let is_notification = req.get("id").is_none();
100 let id = req.get("id").cloned().unwrap_or(Value::Null);
101 let method = req
102 .get("method")
103 .and_then(Value::as_str)
104 .unwrap_or_default();
105
106 match method {
107 "initialize" => Some(ok(
108 id,
109 json!({
110 "protocolVersion": PROTOCOL_VERSION,
111 "capabilities": { "tools": {} },
112 "serverInfo": { "name": "firstpass", "version": env!("CARGO_PKG_VERSION") }
113 }),
114 )),
115 "tools/list" => Some(ok(id, json!({ "tools": tool_schemas() }))),
116 "tools/call" => Some(handle_tool_call(id, req.get("params"), db_path, tenant)),
117 "ping" => Some(ok(id, json!({}))),
118 _ if is_notification => None,
120 other => Some(err(id, -32601, &format!("method not found: {other}"))),
121 }
122}
123
124fn handle_tool_call(id: Value, params: Option<&Value>, db_path: &str, tenant: &str) -> Value {
127 let name = params
128 .and_then(|p| p.get("name"))
129 .and_then(Value::as_str)
130 .unwrap_or_default();
131 let args = params
132 .and_then(|p| p.get("arguments"))
133 .cloned()
134 .unwrap_or_else(|| json!({}));
135
136 let result = match name {
137 "list_traces" => tool_list_traces(&args, db_path, tenant),
138 "get_savings" => tool_get_savings(db_path, tenant),
139 "get_evals" => tool_get_evals(db_path, tenant),
140 "explain_route" => tool_explain_route(&args, db_path, tenant),
141 "rehearse_policy" => tool_rehearse_policy(&args, db_path, tenant),
142 "verify_receipts" => tool_verify_receipts(db_path),
143 "get_trace" => tool_get_trace(&args, db_path, tenant),
144 "submit_feedback" => tool_submit_feedback(&args, db_path, tenant),
145 "list_modes" => tool_list_modes(),
146 other => Err(format!("unknown tool: {other}")),
147 };
148
149 match result {
150 Ok(text) => ok(id, json!({ "content": [{ "type": "text", "text": text }] })),
151 Err(msg) => ok(
152 id,
153 json!({ "content": [{ "type": "text", "text": msg }], "isError": true }),
154 ),
155 }
156}
157
158fn tool_list_traces(args: &Value, db_path: &str, tenant: &str) -> Result<String, String> {
159 let limit = args
160 .get("limit")
161 .and_then(Value::as_u64)
162 .map_or(20, |n| n as usize);
163 let all = store::load_tenant_traces(db_path, tenant).unwrap_or_default();
167 let start = all.len().saturating_sub(limit);
168 let recent = &all[start..];
169 serde_json::to_string(recent).map_err(|e| format!("encode error: {e}"))
170}
171
172fn tool_get_savings(db_path: &str, tenant: &str) -> Result<String, String> {
173 let traces = store::load_tenant_traces(db_path, tenant).unwrap_or_default();
175 serde_json::to_string(&crate::cli::summarize_savings(&traces))
176 .map_err(|e| format!("encode error: {e}"))
177}
178
179fn tool_get_evals(db_path: &str, tenant: &str) -> Result<String, String> {
180 let traces = store::load_tenant_traces(db_path, tenant).unwrap_or_default();
181 serde_json::to_string(&crate::cli::summarize_evals(&traces))
182 .map_err(|e| format!("encode error: {e}"))
183}
184
185fn tool_explain_route(args: &Value, db_path: &str, tenant: &str) -> Result<String, String> {
186 let trace_id = args
187 .get("trace_id")
188 .and_then(Value::as_str)
189 .ok_or("missing `trace_id`")?;
190 match store::load_trace_view(db_path, tenant, trace_id)
191 .map_err(|e| format!("store error: {e}"))?
192 {
193 Some(trace) => serde_json::to_string(&crate::cli::explain_trace(&trace))
194 .map_err(|e| format!("encode error: {e}")),
195 None => Err(format!("unknown trace_id {trace_id:?}")),
196 }
197}
198
199fn tool_rehearse_policy(args: &Value, db_path: &str, tenant: &str) -> Result<String, String> {
200 let toml = args
201 .get("policy_toml")
202 .and_then(Value::as_str)
203 .ok_or("missing `policy_toml`")?;
204 let policy = crate::ope::CandidatePolicy::from_toml(toml)?;
205 let report = crate::ope::ope_from_store(db_path, tenant, &policy)
206 .map_err(|e| format!("store error: {e}"))?;
207 Ok(report.render())
208}
209
210fn tool_verify_receipts(db_path: &str) -> Result<String, String> {
211 let traces = store::load_all_traces(db_path).unwrap_or_default();
213 serde_json::to_string(&crate::cli::verify_receipts(&traces))
214 .map_err(|e| format!("encode error: {e}"))
215}
216
217fn tool_get_trace(args: &Value, db_path: &str, tenant: &str) -> Result<String, String> {
218 let trace_id = args
219 .get("trace_id")
220 .and_then(Value::as_str)
221 .ok_or("missing `trace_id`")?;
222 match store::load_trace_view(db_path, tenant, trace_id)
223 .map_err(|e| format!("store error: {e}"))?
224 {
225 Some(trace) => serde_json::to_string(&trace).map_err(|e| format!("encode error: {e}")),
226 None => Err(format!("unknown trace_id {trace_id:?}")),
227 }
228}
229
230fn tool_submit_feedback(args: &Value, db_path: &str, tenant: &str) -> Result<String, String> {
231 let trace_id = args
232 .get("trace_id")
233 .and_then(Value::as_str)
234 .ok_or("missing `trace_id`")?;
235 let gate_id = args
236 .get("gate_id")
237 .and_then(Value::as_str)
238 .ok_or("missing `gate_id`")?;
239 let reporter = args
240 .get("reporter")
241 .and_then(Value::as_str)
242 .ok_or("missing `reporter`")?;
243 let verdict = match args.get("verdict").and_then(Value::as_str) {
244 Some("pass") => Verdict::Pass,
245 Some("fail") => Verdict::Fail,
246 Some("abstain") => Verdict::Abstain,
247 other => return Err(format!("invalid verdict {other:?}")),
248 };
249 let score = match args.get("score") {
250 None | Some(Value::Null) => None,
251 Some(v) => {
252 let s = v.as_f64().ok_or("score must be a number")?;
253 Some(Score::new(s).map_err(|_| format!("score {s} out of range [0,1]"))?)
254 }
255 };
256
257 if !store::trace_exists(db_path, tenant, trace_id).map_err(|e| format!("store error: {e}"))? {
260 return Err(format!("unknown trace_id {trace_id:?}"));
261 }
262 let dv = DeferredVerdict {
263 gate_id: gate_id.to_owned(),
264 verdict,
265 score,
266 reported_at: jiff::Timestamp::now(),
267 reporter: reporter.to_owned(),
268 };
269 store::append_deferred(db_path, trace_id, &dv).map_err(|e| format!("store error: {e}"))?;
270 Ok(json!({ "status": "recorded", "trace_id": trace_id }).to_string())
271}
272
273fn tool_list_modes() -> Result<String, String> {
274 let modes: Vec<Value> = RoutingMode::ALL
275 .iter()
276 .map(|m| {
277 let p = m.preset();
278 json!({
279 "name": m.as_str(),
280 "description": p.description,
281 "tradeoff": p.tradeoff,
282 "overrides": {
283 "speculation": p.speculation,
284 "max_rungs_delta": p.max_rungs_delta,
285 "start_at_top": p.start_at_top,
286 }
287 })
288 })
289 .collect();
290 serde_json::to_string(&modes).map_err(|e| format!("encode error: {e}"))
291}
292
293fn ok(id: Value, result: Value) -> Value {
294 json!({ "jsonrpc": "2.0", "id": id, "result": result })
295}
296
297fn err(id: Value, code: i64, message: &str) -> Value {
298 json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
299}
300
301pub fn serve_stdio(db_path: &str, tenant: &str) -> std::io::Result<()> {
311 use std::io::{BufRead, Write};
312 let stdin = std::io::stdin();
313 let mut stdout = std::io::stdout();
314 for line in stdin.lock().lines() {
315 let line = line?;
316 if line.trim().is_empty() {
317 continue;
318 }
319 let response = match serde_json::from_str::<Value>(&line) {
320 Ok(req) => handle_rpc(&req, db_path, tenant),
321 Err(_) => Some(err(Value::Null, -32700, "parse error")),
322 };
323 if let Some(resp) = response {
324 writeln!(stdout, "{resp}")?;
325 stdout.flush()?;
326 }
327 }
328 Ok(())
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 fn tmp_db() -> String {
336 std::env::temp_dir()
337 .join(format!("firstpass-mcp-{}.db", uuid::Uuid::now_v7()))
338 .to_string_lossy()
339 .into_owned()
340 }
341
342 #[test]
343 fn initialize_announces_protocol_and_server() {
344 let req = json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize" });
345 let resp = handle_rpc(&req, "unused.db", "default").unwrap();
346 assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
347 assert_eq!(resp["result"]["serverInfo"]["name"], "firstpass");
348 assert_eq!(resp["id"], 1);
349 }
350
351 #[test]
352 fn tools_list_exposes_every_tool() {
353 let req = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" });
354 let resp = handle_rpc(&req, "unused.db", "default").unwrap();
355 let names: Vec<&str> = resp["result"]["tools"]
356 .as_array()
357 .unwrap()
358 .iter()
359 .filter_map(|t| t["name"].as_str())
360 .collect();
361 assert_eq!(
362 names,
363 [
364 "list_traces",
365 "get_trace",
366 "get_savings",
367 "get_evals",
368 "explain_route",
369 "rehearse_policy",
370 "verify_receipts",
371 "submit_feedback",
372 "list_modes",
373 ]
374 );
375 }
376
377 #[test]
378 fn notifications_get_no_response() {
379 let req = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
380 assert!(handle_rpc(&req, "unused.db", "default").is_none());
381 }
382
383 #[test]
384 fn unknown_method_errors_for_requests_but_not_notifications() {
385 let request = json!({ "jsonrpc": "2.0", "id": 9, "method": "does/not/exist" });
386 let resp = handle_rpc(&request, "unused.db", "default").unwrap();
387 assert_eq!(resp["error"]["code"], -32601);
388
389 let notification = json!({ "jsonrpc": "2.0", "method": "does/not/exist" });
390 assert!(handle_rpc(¬ification, "unused.db", "default").is_none());
391 }
392
393 #[test]
394 fn list_traces_on_empty_store_is_empty_not_error() {
395 let db = tmp_db();
396 let req = json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
397 "params": { "name": "list_traces", "arguments": {} } });
398 let resp = handle_rpc(&req, &db, "default").unwrap();
399 assert!(
400 resp["result"]["isError"].is_null(),
401 "empty store is not an error"
402 );
403 assert_eq!(resp["result"]["content"][0]["text"], "[]");
404 }
405
406 #[test]
407 fn get_trace_unknown_is_tool_error() {
408 let db = tmp_db();
409 let _ = store::load_all_traces(&db);
411 let req = json!({ "jsonrpc": "2.0", "id": 4, "method": "tools/call",
412 "params": { "name": "get_trace", "arguments": { "trace_id": "nope" } } });
413 let resp = handle_rpc(&req, &db, "default").unwrap();
414 assert_eq!(resp["result"]["isError"], true);
415 }
416
417 #[test]
418 fn submit_feedback_validates_and_rejects_unknown_trace() {
419 let db = tmp_db();
420 let _ = store::load_all_traces(&db);
421
422 let bad = json!({ "jsonrpc": "2.0", "id": 5, "method": "tools/call",
424 "params": { "name": "submit_feedback", "arguments": {
425 "trace_id": "t", "gate_id": "g", "verdict": "banana", "reporter": "ci" } } });
426 assert_eq!(
427 handle_rpc(&bad, &db, "default").unwrap()["result"]["isError"],
428 true
429 );
430
431 let unknown = json!({ "jsonrpc": "2.0", "id": 6, "method": "tools/call",
433 "params": { "name": "submit_feedback", "arguments": {
434 "trace_id": "missing", "gate_id": "g", "verdict": "pass", "reporter": "ci" } } });
435 assert_eq!(
436 handle_rpc(&unknown, &db, "default").unwrap()["result"]["isError"],
437 true
438 );
439 }
440
441 #[test]
442 fn list_modes_returns_all_named_modes() {
443 let req = json!({ "jsonrpc": "2.0", "id": 8, "method": "tools/call",
444 "params": { "name": "list_modes", "arguments": {} } });
445 let resp = handle_rpc(&req, "unused.db", "default").unwrap();
446 assert!(
447 resp["result"]["isError"].is_null(),
448 "list_modes must not error"
449 );
450 let text = resp["result"]["content"][0]["text"].as_str().unwrap();
451 let modes: serde_json::Value = serde_json::from_str(text).unwrap();
452 let names: Vec<&str> = modes
453 .as_array()
454 .unwrap()
455 .iter()
456 .filter_map(|m| m["name"].as_str())
457 .collect();
458 assert!(names.contains(&"balanced"), "balanced must be listed");
459 assert!(names.contains(&"cost"), "cost must be listed");
460 assert!(names.contains(&"quality"), "quality must be listed");
461 assert!(names.contains(&"latency"), "latency must be listed");
462 assert!(names.contains(&"max"), "max must be listed");
463 assert!(names.contains(&"observe"), "observe must be listed");
464 for mode in modes.as_array().unwrap() {
466 assert!(
467 mode["description"].as_str().is_some_and(|s| !s.is_empty()),
468 "mode {:?} missing description",
469 mode["name"]
470 );
471 assert!(
472 mode["tradeoff"].as_str().is_some_and(|s| !s.is_empty()),
473 "mode {:?} missing tradeoff",
474 mode["name"]
475 );
476 }
477 }
478
479 #[test]
480 fn unknown_tool_is_tool_error() {
481 let req = json!({ "jsonrpc": "2.0", "id": 7, "method": "tools/call",
482 "params": { "name": "no_such_tool", "arguments": {} } });
483 let resp = handle_rpc(&req, "unused.db", "default").unwrap();
484 assert_eq!(resp["result"]["isError"], true);
485 }
486
487 #[test]
488 fn savings_and_evals_tools_are_listed_and_zero_state_on_fresh_store() {
489 let db = tmp_db();
490 let db_str = db.as_str();
491
492 let listed = handle_rpc(
493 &json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }),
494 db_str,
495 "default",
496 )
497 .unwrap();
498 let names: Vec<&str> = listed["result"]["tools"]
499 .as_array()
500 .unwrap()
501 .iter()
502 .filter_map(|t| t["name"].as_str())
503 .collect();
504 assert!(names.contains(&"get_savings") && names.contains(&"get_evals"));
505
506 for tool in ["get_savings", "get_evals"] {
507 let out = handle_rpc(
508 &json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
509 "params": { "name": tool, "arguments": {} } }),
510 db_str,
511 "default",
512 )
513 .unwrap();
514 let text = out["result"]["content"][0]["text"].as_str().unwrap();
515 let parsed: serde_json::Value = serde_json::from_str(text).unwrap();
516 assert!(
517 parsed.is_object(),
518 "{tool} must return a JSON summary even on a fresh store"
519 );
520 }
521 }
522}