use firstpass_core::{DeferredVerdict, Score, Verdict};
use serde_json::{Value, json};
use crate::store;
const PROTOCOL_VERSION: &str = "2024-11-05";
fn tool_schemas() -> Value {
json!([
{
"name": "list_traces",
"description": "List the most recent Firstpass audit traces (routing decisions + receipts).",
"inputSchema": {
"type": "object",
"properties": { "limit": { "type": "integer", "description": "Max traces to return (default 20)." } }
}
},
{
"name": "get_trace",
"description": "Fetch a single audit trace by id, with any deferred verdicts merged in.",
"inputSchema": {
"type": "object",
"properties": { "trace_id": { "type": "string" } },
"required": ["trace_id"]
}
},
{
"name": "submit_feedback",
"description": "Attach a downstream outcome (deferred verdict) to a past decision, closing the feedback loop.",
"inputSchema": {
"type": "object",
"properties": {
"trace_id": { "type": "string" },
"gate_id": { "type": "string" },
"verdict": { "type": "string", "enum": ["pass", "fail", "abstain"] },
"score": { "type": "number" },
"reporter": { "type": "string" }
},
"required": ["trace_id", "gate_id", "verdict", "reporter"]
}
}
])
}
#[must_use]
pub fn handle_rpc(req: &Value, db_path: &str) -> Option<Value> {
let is_notification = req.get("id").is_none();
let id = req.get("id").cloned().unwrap_or(Value::Null);
let method = req
.get("method")
.and_then(Value::as_str)
.unwrap_or_default();
match method {
"initialize" => Some(ok(
id,
json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": { "tools": {} },
"serverInfo": { "name": "firstpass", "version": env!("CARGO_PKG_VERSION") }
}),
)),
"tools/list" => Some(ok(id, json!({ "tools": tool_schemas() }))),
"tools/call" => Some(handle_tool_call(id, req.get("params"), db_path)),
"ping" => Some(ok(id, json!({}))),
_ if is_notification => None,
other => Some(err(id, -32601, &format!("method not found: {other}"))),
}
}
fn handle_tool_call(id: Value, params: Option<&Value>, db_path: &str) -> Value {
let name = params
.and_then(|p| p.get("name"))
.and_then(Value::as_str)
.unwrap_or_default();
let args = params
.and_then(|p| p.get("arguments"))
.cloned()
.unwrap_or_else(|| json!({}));
let result = match name {
"list_traces" => tool_list_traces(&args, db_path),
"get_trace" => tool_get_trace(&args, db_path),
"submit_feedback" => tool_submit_feedback(&args, db_path),
other => Err(format!("unknown tool: {other}")),
};
match result {
Ok(text) => ok(id, json!({ "content": [{ "type": "text", "text": text }] })),
Err(msg) => ok(
id,
json!({ "content": [{ "type": "text", "text": msg }], "isError": true }),
),
}
}
fn tool_list_traces(args: &Value, db_path: &str) -> Result<String, String> {
let limit = args
.get("limit")
.and_then(Value::as_u64)
.map_or(20, |n| n as usize);
let all = store::load_all_traces(db_path).unwrap_or_default();
let start = all.len().saturating_sub(limit);
let recent = &all[start..];
serde_json::to_string(recent).map_err(|e| format!("encode error: {e}"))
}
fn tool_get_trace(args: &Value, db_path: &str) -> Result<String, String> {
let trace_id = args
.get("trace_id")
.and_then(Value::as_str)
.ok_or("missing `trace_id`")?;
match store::load_trace_view(db_path, trace_id).map_err(|e| format!("store error: {e}"))? {
Some(trace) => serde_json::to_string(&trace).map_err(|e| format!("encode error: {e}")),
None => Err(format!("unknown trace_id {trace_id:?}")),
}
}
fn tool_submit_feedback(args: &Value, db_path: &str) -> Result<String, String> {
let trace_id = args
.get("trace_id")
.and_then(Value::as_str)
.ok_or("missing `trace_id`")?;
let gate_id = args
.get("gate_id")
.and_then(Value::as_str)
.ok_or("missing `gate_id`")?;
let reporter = args
.get("reporter")
.and_then(Value::as_str)
.ok_or("missing `reporter`")?;
let verdict = match args.get("verdict").and_then(Value::as_str) {
Some("pass") => Verdict::Pass,
Some("fail") => Verdict::Fail,
Some("abstain") => Verdict::Abstain,
other => return Err(format!("invalid verdict {other:?}")),
};
let score = match args.get("score") {
None | Some(Value::Null) => None,
Some(v) => {
let s = v.as_f64().ok_or("score must be a number")?;
Some(Score::new(s).map_err(|_| format!("score {s} out of range [0,1]"))?)
}
};
if !store::trace_exists(db_path, trace_id).map_err(|e| format!("store error: {e}"))? {
return Err(format!("unknown trace_id {trace_id:?}"));
}
let dv = DeferredVerdict {
gate_id: gate_id.to_owned(),
verdict,
score,
reported_at: jiff::Timestamp::now(),
reporter: reporter.to_owned(),
};
store::append_deferred(db_path, trace_id, &dv).map_err(|e| format!("store error: {e}"))?;
Ok(json!({ "status": "recorded", "trace_id": trace_id }).to_string())
}
fn ok(id: Value, result: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "result": result })
}
fn err(id: Value, code: i64, message: &str) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
}
pub fn serve_stdio(db_path: &str) -> std::io::Result<()> {
use std::io::{BufRead, Write};
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
for line in stdin.lock().lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let response = match serde_json::from_str::<Value>(&line) {
Ok(req) => handle_rpc(&req, db_path),
Err(_) => Some(err(Value::Null, -32700, "parse error")),
};
if let Some(resp) = response {
writeln!(stdout, "{resp}")?;
stdout.flush()?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_db() -> String {
std::env::temp_dir()
.join(format!("firstpass-mcp-{}.db", uuid::Uuid::now_v7()))
.to_string_lossy()
.into_owned()
}
#[test]
fn initialize_announces_protocol_and_server() {
let req = json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize" });
let resp = handle_rpc(&req, "unused.db").unwrap();
assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
assert_eq!(resp["result"]["serverInfo"]["name"], "firstpass");
assert_eq!(resp["id"], 1);
}
#[test]
fn tools_list_exposes_the_three_tools() {
let req = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" });
let resp = handle_rpc(&req, "unused.db").unwrap();
let names: Vec<&str> = resp["result"]["tools"]
.as_array()
.unwrap()
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
assert_eq!(names, ["list_traces", "get_trace", "submit_feedback"]);
}
#[test]
fn notifications_get_no_response() {
let req = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
assert!(handle_rpc(&req, "unused.db").is_none());
}
#[test]
fn unknown_method_errors_for_requests_but_not_notifications() {
let request = json!({ "jsonrpc": "2.0", "id": 9, "method": "does/not/exist" });
let resp = handle_rpc(&request, "unused.db").unwrap();
assert_eq!(resp["error"]["code"], -32601);
let notification = json!({ "jsonrpc": "2.0", "method": "does/not/exist" });
assert!(handle_rpc(¬ification, "unused.db").is_none());
}
#[test]
fn list_traces_on_empty_store_is_empty_not_error() {
let db = tmp_db();
let req = json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": { "name": "list_traces", "arguments": {} } });
let resp = handle_rpc(&req, &db).unwrap();
assert!(
resp["result"]["isError"].is_null(),
"empty store is not an error"
);
assert_eq!(resp["result"]["content"][0]["text"], "[]");
}
#[test]
fn get_trace_unknown_is_tool_error() {
let db = tmp_db();
let _ = store::load_all_traces(&db);
let req = json!({ "jsonrpc": "2.0", "id": 4, "method": "tools/call",
"params": { "name": "get_trace", "arguments": { "trace_id": "nope" } } });
let resp = handle_rpc(&req, &db).unwrap();
assert_eq!(resp["result"]["isError"], true);
}
#[test]
fn submit_feedback_validates_and_rejects_unknown_trace() {
let db = tmp_db();
let _ = store::load_all_traces(&db);
let bad = json!({ "jsonrpc": "2.0", "id": 5, "method": "tools/call",
"params": { "name": "submit_feedback", "arguments": {
"trace_id": "t", "gate_id": "g", "verdict": "banana", "reporter": "ci" } } });
assert_eq!(handle_rpc(&bad, &db).unwrap()["result"]["isError"], true);
let unknown = json!({ "jsonrpc": "2.0", "id": 6, "method": "tools/call",
"params": { "name": "submit_feedback", "arguments": {
"trace_id": "missing", "gate_id": "g", "verdict": "pass", "reporter": "ci" } } });
assert_eq!(
handle_rpc(&unknown, &db).unwrap()["result"]["isError"],
true
);
}
#[test]
fn unknown_tool_is_tool_error() {
let req = json!({ "jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": { "name": "no_such_tool", "arguments": {} } });
let resp = handle_rpc(&req, "unused.db").unwrap();
assert_eq!(resp["result"]["isError"], true);
}
}