Skip to main content

firstpass_proxy/
mcp.rs

1//! Minimal MCP (Model Context Protocol) stdio server (SPEC §0.2/§7.4): lets an agent inspect and
2//! correct its own routing — read the audit traces, and submit feedback — over JSON-RPC 2.0.
3//!
4//! Hand-rolled over `serde_json` (newline-delimited JSON-RPC on stdin/stdout). The surface is three
5//! methods — `initialize`, `tools/list`, `tools/call` — so a dependency-free handler we can
6//! unit-test by value beats pulling in an async MCP framework. [`handle_rpc`] is the pure core;
7//! [`serve_stdio`] is the thin transport loop around it.
8
9use firstpass_core::{DeferredVerdict, Score, Verdict};
10use serde_json::{Value, json};
11
12use crate::store;
13
14/// MCP protocol revision this server implements.
15const PROTOCOL_VERSION: &str = "2024-11-05";
16
17/// The tools this server exposes, with their JSON-Schema input contracts (agent-first: an agent
18/// discovers them at runtime via `tools/list`).
19fn 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/// Handle one JSON-RPC message. Returns the response for a request, or `None` for a notification
67/// (a message with no `id`, which must not be answered). Tool calls touch the trace store at
68/// `db_path`; everything else is pure.
69#[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        // Notifications (e.g. `notifications/initialized`) and anything else without an id: silent.
91        _ if is_notification => None,
92        other => Some(err(id, -32601, &format!("method not found: {other}"))),
93    }
94}
95
96/// Dispatch a `tools/call`. Tool-level failures come back as an `isError` result (MCP convention),
97/// not a protocol error, so the agent can read the reason as content.
98fn 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    // Mirror `firstpass trace`: an absent/unreadable store (no traffic yet) is "no traces", not an
132    // error — unlike get_trace/submit_feedback, which name a specific trace and should fail loudly.
133    // Tenant-scoped (ADR 0004 §D3): the reader only ever sees its own tenant's traces.
134    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    // Absent/unreadable store mirrors list_traces: a zero summary, not an error.
142    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    // Tenant-scoped existence check (ADR 0004 §D3/§D4): a trace owned by another tenant reads as
194    // unknown, so an agent cannot attach feedback across the tenant boundary.
195    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
217/// Serve MCP over stdio: read newline-delimited JSON-RPC from stdin, write responses to stdout.
218/// Blocks until stdin closes. Synchronous by design — one request in flight at a time.
219///
220/// All store reads/writes are scoped to `tenant` (ADR 0004 §D3): the reader is bound to a single
221/// tenant for its whole session and never reads or writes across the boundary. Single-operator
222/// runs pass the default tenant, so behavior is unchanged.
223///
224/// # Errors
225/// Returns an [`std::io::Error`] if reading stdin or writing stdout fails.
226pub 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(&notification, "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        // Touch the store so the file exists but has no such trace.
322        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        // Bad verdict → tool error.
335        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        // Valid shape but unknown trace → tool error.
344        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}