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": "submit_feedback",
40 "description": "Attach a downstream outcome (deferred verdict) to a past decision, closing the feedback loop.",
41 "inputSchema": {
42 "type": "object",
43 "properties": {
44 "trace_id": { "type": "string" },
45 "gate_id": { "type": "string" },
46 "verdict": { "type": "string", "enum": ["pass", "fail", "abstain"] },
47 "score": { "type": "number" },
48 "reporter": { "type": "string" }
49 },
50 "required": ["trace_id", "gate_id", "verdict", "reporter"]
51 }
52 }
53 ])
54}
55
56#[must_use]
60pub fn handle_rpc(req: &Value, db_path: &str, tenant: &str) -> Option<Value> {
61 let is_notification = req.get("id").is_none();
62 let id = req.get("id").cloned().unwrap_or(Value::Null);
63 let method = req
64 .get("method")
65 .and_then(Value::as_str)
66 .unwrap_or_default();
67
68 match method {
69 "initialize" => Some(ok(
70 id,
71 json!({
72 "protocolVersion": PROTOCOL_VERSION,
73 "capabilities": { "tools": {} },
74 "serverInfo": { "name": "firstpass", "version": env!("CARGO_PKG_VERSION") }
75 }),
76 )),
77 "tools/list" => Some(ok(id, json!({ "tools": tool_schemas() }))),
78 "tools/call" => Some(handle_tool_call(id, req.get("params"), db_path, tenant)),
79 "ping" => Some(ok(id, json!({}))),
80 _ if is_notification => None,
82 other => Some(err(id, -32601, &format!("method not found: {other}"))),
83 }
84}
85
86fn handle_tool_call(id: Value, params: Option<&Value>, db_path: &str, tenant: &str) -> Value {
89 let name = params
90 .and_then(|p| p.get("name"))
91 .and_then(Value::as_str)
92 .unwrap_or_default();
93 let args = params
94 .and_then(|p| p.get("arguments"))
95 .cloned()
96 .unwrap_or_else(|| json!({}));
97
98 let result = match name {
99 "list_traces" => tool_list_traces(&args, db_path, tenant),
100 "get_trace" => tool_get_trace(&args, db_path, tenant),
101 "submit_feedback" => tool_submit_feedback(&args, db_path, tenant),
102 other => Err(format!("unknown tool: {other}")),
103 };
104
105 match result {
106 Ok(text) => ok(id, json!({ "content": [{ "type": "text", "text": text }] })),
107 Err(msg) => ok(
108 id,
109 json!({ "content": [{ "type": "text", "text": msg }], "isError": true }),
110 ),
111 }
112}
113
114fn tool_list_traces(args: &Value, db_path: &str, tenant: &str) -> Result<String, String> {
115 let limit = args
116 .get("limit")
117 .and_then(Value::as_u64)
118 .map_or(20, |n| n as usize);
119 let all = store::load_tenant_traces(db_path, tenant).unwrap_or_default();
123 let start = all.len().saturating_sub(limit);
124 let recent = &all[start..];
125 serde_json::to_string(recent).map_err(|e| format!("encode error: {e}"))
126}
127
128fn tool_get_trace(args: &Value, db_path: &str, tenant: &str) -> Result<String, String> {
129 let trace_id = args
130 .get("trace_id")
131 .and_then(Value::as_str)
132 .ok_or("missing `trace_id`")?;
133 match store::load_trace_view(db_path, tenant, trace_id)
134 .map_err(|e| format!("store error: {e}"))?
135 {
136 Some(trace) => serde_json::to_string(&trace).map_err(|e| format!("encode error: {e}")),
137 None => Err(format!("unknown trace_id {trace_id:?}")),
138 }
139}
140
141fn tool_submit_feedback(args: &Value, db_path: &str, tenant: &str) -> Result<String, String> {
142 let trace_id = args
143 .get("trace_id")
144 .and_then(Value::as_str)
145 .ok_or("missing `trace_id`")?;
146 let gate_id = args
147 .get("gate_id")
148 .and_then(Value::as_str)
149 .ok_or("missing `gate_id`")?;
150 let reporter = args
151 .get("reporter")
152 .and_then(Value::as_str)
153 .ok_or("missing `reporter`")?;
154 let verdict = match args.get("verdict").and_then(Value::as_str) {
155 Some("pass") => Verdict::Pass,
156 Some("fail") => Verdict::Fail,
157 Some("abstain") => Verdict::Abstain,
158 other => return Err(format!("invalid verdict {other:?}")),
159 };
160 let score = match args.get("score") {
161 None | Some(Value::Null) => None,
162 Some(v) => {
163 let s = v.as_f64().ok_or("score must be a number")?;
164 Some(Score::new(s).map_err(|_| format!("score {s} out of range [0,1]"))?)
165 }
166 };
167
168 if !store::trace_exists(db_path, tenant, trace_id).map_err(|e| format!("store error: {e}"))? {
171 return Err(format!("unknown trace_id {trace_id:?}"));
172 }
173 let dv = DeferredVerdict {
174 gate_id: gate_id.to_owned(),
175 verdict,
176 score,
177 reported_at: jiff::Timestamp::now(),
178 reporter: reporter.to_owned(),
179 };
180 store::append_deferred(db_path, trace_id, &dv).map_err(|e| format!("store error: {e}"))?;
181 Ok(json!({ "status": "recorded", "trace_id": trace_id }).to_string())
182}
183
184fn ok(id: Value, result: Value) -> Value {
185 json!({ "jsonrpc": "2.0", "id": id, "result": result })
186}
187
188fn err(id: Value, code: i64, message: &str) -> Value {
189 json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
190}
191
192pub fn serve_stdio(db_path: &str, tenant: &str) -> std::io::Result<()> {
202 use std::io::{BufRead, Write};
203 let stdin = std::io::stdin();
204 let mut stdout = std::io::stdout();
205 for line in stdin.lock().lines() {
206 let line = line?;
207 if line.trim().is_empty() {
208 continue;
209 }
210 let response = match serde_json::from_str::<Value>(&line) {
211 Ok(req) => handle_rpc(&req, db_path, tenant),
212 Err(_) => Some(err(Value::Null, -32700, "parse error")),
213 };
214 if let Some(resp) = response {
215 writeln!(stdout, "{resp}")?;
216 stdout.flush()?;
217 }
218 }
219 Ok(())
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 fn tmp_db() -> String {
227 std::env::temp_dir()
228 .join(format!("firstpass-mcp-{}.db", uuid::Uuid::now_v7()))
229 .to_string_lossy()
230 .into_owned()
231 }
232
233 #[test]
234 fn initialize_announces_protocol_and_server() {
235 let req = json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize" });
236 let resp = handle_rpc(&req, "unused.db", "default").unwrap();
237 assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
238 assert_eq!(resp["result"]["serverInfo"]["name"], "firstpass");
239 assert_eq!(resp["id"], 1);
240 }
241
242 #[test]
243 fn tools_list_exposes_the_three_tools() {
244 let req = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" });
245 let resp = handle_rpc(&req, "unused.db", "default").unwrap();
246 let names: Vec<&str> = resp["result"]["tools"]
247 .as_array()
248 .unwrap()
249 .iter()
250 .filter_map(|t| t["name"].as_str())
251 .collect();
252 assert_eq!(names, ["list_traces", "get_trace", "submit_feedback"]);
253 }
254
255 #[test]
256 fn notifications_get_no_response() {
257 let req = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
258 assert!(handle_rpc(&req, "unused.db", "default").is_none());
259 }
260
261 #[test]
262 fn unknown_method_errors_for_requests_but_not_notifications() {
263 let request = json!({ "jsonrpc": "2.0", "id": 9, "method": "does/not/exist" });
264 let resp = handle_rpc(&request, "unused.db", "default").unwrap();
265 assert_eq!(resp["error"]["code"], -32601);
266
267 let notification = json!({ "jsonrpc": "2.0", "method": "does/not/exist" });
268 assert!(handle_rpc(¬ification, "unused.db", "default").is_none());
269 }
270
271 #[test]
272 fn list_traces_on_empty_store_is_empty_not_error() {
273 let db = tmp_db();
274 let req = json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
275 "params": { "name": "list_traces", "arguments": {} } });
276 let resp = handle_rpc(&req, &db, "default").unwrap();
277 assert!(
278 resp["result"]["isError"].is_null(),
279 "empty store is not an error"
280 );
281 assert_eq!(resp["result"]["content"][0]["text"], "[]");
282 }
283
284 #[test]
285 fn get_trace_unknown_is_tool_error() {
286 let db = tmp_db();
287 let _ = store::load_all_traces(&db);
289 let req = json!({ "jsonrpc": "2.0", "id": 4, "method": "tools/call",
290 "params": { "name": "get_trace", "arguments": { "trace_id": "nope" } } });
291 let resp = handle_rpc(&req, &db, "default").unwrap();
292 assert_eq!(resp["result"]["isError"], true);
293 }
294
295 #[test]
296 fn submit_feedback_validates_and_rejects_unknown_trace() {
297 let db = tmp_db();
298 let _ = store::load_all_traces(&db);
299
300 let bad = json!({ "jsonrpc": "2.0", "id": 5, "method": "tools/call",
302 "params": { "name": "submit_feedback", "arguments": {
303 "trace_id": "t", "gate_id": "g", "verdict": "banana", "reporter": "ci" } } });
304 assert_eq!(
305 handle_rpc(&bad, &db, "default").unwrap()["result"]["isError"],
306 true
307 );
308
309 let unknown = json!({ "jsonrpc": "2.0", "id": 6, "method": "tools/call",
311 "params": { "name": "submit_feedback", "arguments": {
312 "trace_id": "missing", "gate_id": "g", "verdict": "pass", "reporter": "ci" } } });
313 assert_eq!(
314 handle_rpc(&unknown, &db, "default").unwrap()["result"]["isError"],
315 true
316 );
317 }
318
319 #[test]
320 fn unknown_tool_is_tool_error() {
321 let req = json!({ "jsonrpc": "2.0", "id": 7, "method": "tools/call",
322 "params": { "name": "no_such_tool", "arguments": {} } });
323 let resp = handle_rpc(&req, "unused.db", "default").unwrap();
324 assert_eq!(resp["result"]["isError"], true);
325 }
326}