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, RoutingMode, 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": "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/// Handle one JSON-RPC message. Returns the response for a request, or `None` for a notification
95/// (a message with no `id`, which must not be answered). Tool calls touch the trace store at
96/// `db_path`; everything else is pure.
97#[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        // Notifications (e.g. `notifications/initialized`) and anything else without an id: silent.
119        _ if is_notification => None,
120        other => Some(err(id, -32601, &format!("method not found: {other}"))),
121    }
122}
123
124/// Dispatch a `tools/call`. Tool-level failures come back as an `isError` result (MCP convention),
125/// not a protocol error, so the agent can read the reason as content.
126fn 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    // Mirror `firstpass trace`: an absent/unreadable store (no traffic yet) is "no traces", not an
164    // error — unlike get_trace/submit_feedback, which name a specific trace and should fail loudly.
165    // Tenant-scoped (ADR 0004 §D3): the reader only ever sees its own tenant's traces.
166    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    // Absent/unreadable store mirrors list_traces: a zero summary, not an error.
174    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    // Operator-wide: the hash chain spans every tenant, so verification is not tenant-scoped.
212    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    // Tenant-scoped existence check (ADR 0004 §D3/§D4): a trace owned by another tenant reads as
258    // unknown, so an agent cannot attach feedback across the tenant boundary.
259    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
301/// Serve MCP over stdio: read newline-delimited JSON-RPC from stdin, write responses to stdout.
302/// Blocks until stdin closes. Synchronous by design — one request in flight at a time.
303///
304/// All store reads/writes are scoped to `tenant` (ADR 0004 §D3): the reader is bound to a single
305/// tenant for its whole session and never reads or writes across the boundary. Single-operator
306/// runs pass the default tenant, so behavior is unchanged.
307///
308/// # Errors
309/// Returns an [`std::io::Error`] if reading stdin or writing stdout fails.
310pub 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(&notification, "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        // Touch the store so the file exists but has no such trace.
410        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        // Bad verdict → tool error.
423        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        // Valid shape but unknown trace → tool error.
432        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        // Each entry must have description and tradeoff.
465        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}