Skip to main content

kintsugi_intercept/
mcp.rs

1//! The `kintsugi-exec` MCP server.
2//!
3//! Tool-calling agents (Cursor CLI, Codex CLI, Qwen Code, Gemini CLI, and any
4//! custom MCP client) that speak the Model Context Protocol can call the
5//! `kintsugi-exec` tool to run a shell command *through*
6//! Kintsugi instead of shelling out raw. Each call is normalized to a
7//! [`ProposedCommand`], sent to the daemon, and — on allow — executed, with the
8//! command's output returned to the agent. Every call is recorded.
9//!
10//! Transport: newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio
11//! transport). Implemented by hand to avoid pulling an MCP framework dependency.
12
13use std::path::PathBuf;
14use std::process::Command;
15
16use kintsugi_core::{shell, Decision, ProposedCommand};
17use kintsugi_daemon::Client;
18use serde_json::{json, Value};
19
20/// Default protocol version advertised when the client doesn't pin one.
21const DEFAULT_PROTOCOL_VERSION: &str = "2024-11-05";
22/// Tool name agents call.
23pub const TOOL_NAME: &str = "kintsugi-exec";
24
25/// Handle one JSON-RPC message line. Returns the response line, or `None` for
26/// notifications (which get no reply).
27pub fn handle_message(line: &str) -> Option<String> {
28    let req: Value = match serde_json::from_str(line) {
29        Ok(v) => v,
30        Err(e) => {
31            return Some(error_response(
32                Value::Null,
33                -32700,
34                &format!("parse error: {e}"),
35            ));
36        }
37    };
38
39    let id = req.get("id").cloned();
40    let method = req.get("method").and_then(Value::as_str).unwrap_or("");
41
42    // Notifications (no id) are acknowledged silently (`?` yields None).
43    let id = id?;
44
45    match method {
46        "initialize" => Some(initialize_response(id, &req)),
47        "tools/list" => Some(tools_list_response(id)),
48        "tools/call" => Some(tools_call_response(id, &req)),
49        "ping" => Some(result_response(id, json!({}))),
50        other => Some(error_response(
51            id,
52            -32601,
53            &format!("method not found: {other}"),
54        )),
55    }
56}
57
58fn initialize_response(id: Value, req: &Value) -> String {
59    let protocol = req
60        .get("params")
61        .and_then(|p| p.get("protocolVersion"))
62        .and_then(Value::as_str)
63        .unwrap_or(DEFAULT_PROTOCOL_VERSION)
64        .to_string();
65
66    result_response(
67        id,
68        json!({
69            "protocolVersion": protocol,
70            "capabilities": { "tools": {} },
71            "serverInfo": { "name": "kintsugi-exec", "version": crate::VERSION },
72            "instructions": "Run shell commands via the kintsugi-exec tool so Kintsugi can guard and record them."
73        }),
74    )
75}
76
77fn tools_list_response(id: Value) -> String {
78    result_response(
79        id,
80        json!({
81            "tools": [{
82                "name": TOOL_NAME,
83                "description": "Run a shell command guarded and recorded by Kintsugi. \
84                    Use this instead of shelling out directly so dangerous commands \
85                    are held and everything is logged to the tamper-evident audit log.",
86                "inputSchema": {
87                    "type": "object",
88                    "properties": {
89                        "command": { "type": "string", "description": "The shell command to run." },
90                        "cwd": { "type": "string", "description": "Working directory (optional)." },
91                        "agent": { "type": "string", "description": "Calling agent name, e.g. 'qwen' or 'codex' (optional)." },
92                        "session": { "type": "string", "description": "Session id for grouping in the timeline (optional)." }
93                    },
94                    "required": ["command"]
95                }
96            }]
97        }),
98    )
99}
100
101fn tools_call_response(id: Value, req: &Value) -> String {
102    let params = req.get("params").cloned().unwrap_or(Value::Null);
103    let name = params.get("name").and_then(Value::as_str).unwrap_or("");
104    if name != TOOL_NAME {
105        return error_response(id, -32602, &format!("unknown tool: {name}"));
106    }
107    let args = params.get("arguments").cloned().unwrap_or(json!({}));
108    let command = match args.get("command").and_then(Value::as_str) {
109        Some(c) if !c.trim().is_empty() => c.to_string(),
110        _ => return error_response(id, -32602, "missing required argument: command"),
111    };
112    let agent = args
113        .get("agent")
114        .and_then(Value::as_str)
115        .filter(|s| !s.is_empty())
116        .unwrap_or("mcp")
117        .to_string();
118    let cwd = args
119        .get("cwd")
120        .and_then(Value::as_str)
121        .map(PathBuf::from)
122        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
123    // One session per server process (a client connection), overridable per call.
124    let session = args
125        .get("session")
126        .and_then(Value::as_str)
127        .filter(|s| !s.is_empty())
128        .map(str::to_string)
129        .unwrap_or_else(|| format!("mcp-{}", std::process::id()));
130
131    let outcome = exec_through_kintsugi(&agent, cwd, &session, &command);
132    result_response(
133        id,
134        json!({
135            "content": [{ "type": "text", "text": outcome.text }],
136            "isError": outcome.is_error,
137        }),
138    )
139}
140
141struct ToolOutcome {
142    text: String,
143    is_error: bool,
144}
145
146/// The full guarded-execution path: propose → decide → (allow) run → report.
147fn exec_through_kintsugi(agent: &str, cwd: PathBuf, session: &str, command: &str) -> ToolOutcome {
148    let argv = shell::split(command);
149    let proposed = ProposedCommand::new(agent, cwd.clone(), argv, command.to_string())
150        .with_session(Some(session.to_string()));
151
152    let id = proposed.id.to_string();
153    let decision = match Client::send(&proposed) {
154        // A held command waits (bounded) for a human to approve/deny so the agent
155        // can proceed in the same call. See `approval_timeout`.
156        Ok(verdict) if verdict.decision == Decision::Hold => wait_for_approval(&id),
157        Ok(verdict) => verdict.decision,
158        Err(e) => {
159            // Daemon down: locally classify so a catastrophic command is still
160            // refused (fail-closed for the hard floor), even though it can't be
161            // recorded. Non-catastrophic honors the fail-open default.
162            if kintsugi_core::classify(&proposed).class == kintsugi_core::Class::Catastrophic {
163                return ToolOutcome {
164                    text: format!(
165                        "Kintsugi daemon unreachable; catastrophic command refused (fail-closed): {e}"
166                    ),
167                    is_error: true,
168                };
169            }
170            if fail_closed() {
171                return ToolOutcome {
172                    text: format!(
173                        "Kintsugi daemon unreachable; refusing to run (fail-closed): {e}"
174                    ),
175                    is_error: true,
176                };
177            }
178            // Fail-open: run unguarded but say so.
179            eprintln!("kintsugi-mcp: warning: daemon unreachable; running unguarded: {e}");
180            Decision::Allow
181        }
182    };
183
184    let short = &id[..id.len().min(8)];
185    match decision {
186        Decision::Allow => run_command(&cwd, command),
187        Decision::Deny => ToolOutcome {
188            text: format!("Kintsugi blocked this command: {command}"),
189            is_error: true,
190        },
191        Decision::Hold => ToolOutcome {
192            text: format!(
193                "Kintsugi is holding this command for human approval (id {short}). It was not run. \
194                 A human can approve it with `kintsugi approve {short}` (then re-run), or you can \
195                 proceed with a different approach."
196            ),
197            is_error: true,
198        },
199    }
200}
201
202/// How long the MCP tool waits for a human to resolve a held command, in seconds.
203/// `0` (default) means do not wait — return "pending" immediately. Set
204/// `KINTSUGI_APPROVAL_TIMEOUT` to enable in-band wait-for-approval.
205fn approval_timeout() -> std::time::Duration {
206    let secs = std::env::var("KINTSUGI_APPROVAL_TIMEOUT")
207        .ok()
208        .and_then(|s| s.parse::<u64>().ok())
209        .unwrap_or(0);
210    std::time::Duration::from_secs(secs)
211}
212
213/// Poll the daemon until a held command is approved/denied, the deadline passes,
214/// or it leaves the queue. Returns the resulting decision (`Hold` = still pending).
215fn wait_for_approval(id: &str) -> Decision {
216    let deadline = std::time::Instant::now() + approval_timeout();
217    // Tolerate a few transient connection blips (the daemon is single-threaded and
218    // a concurrent connect can momentarily fail), but give up fast if the daemon
219    // is actually gone rather than busy-polling a dead socket for the whole timeout.
220    let mut consecutive_errors = 0u32;
221    loop {
222        match Client::pending_status(id) {
223            Ok(s) if s == "approved" => return Decision::Allow,
224            Ok(s) if s == "denied" => return Decision::Deny,
225            Ok(s) if s == "gone" => return Decision::Hold, // resolved/removed elsewhere
226            Ok(_) => consecutive_errors = 0,               // "pending" — keep waiting
227            Err(_) => {
228                consecutive_errors += 1;
229                if consecutive_errors >= 5 {
230                    return Decision::Hold; // daemon unreachable; don't hang
231                }
232            }
233        }
234        if std::time::Instant::now() >= deadline {
235            return Decision::Hold;
236        }
237        std::thread::sleep(std::time::Duration::from_millis(200));
238    }
239}
240
241/// Run the command in a shell, capturing output.
242fn run_command(cwd: &PathBuf, command: &str) -> ToolOutcome {
243    #[cfg(unix)]
244    let mut cmd = {
245        let mut c = Command::new("sh");
246        c.arg("-c").arg(command);
247        c
248    };
249    #[cfg(not(unix))]
250    let mut cmd = {
251        let mut c = Command::new("cmd");
252        c.arg("/C").arg(command);
253        c
254    };
255    cmd.current_dir(cwd);
256
257    match cmd.output() {
258        Ok(out) => {
259            let code = out.status.code().unwrap_or(-1);
260            let stdout = String::from_utf8_lossy(&out.stdout);
261            let stderr = String::from_utf8_lossy(&out.stderr);
262            let mut text = format!("exit code: {code}\n");
263            if !stdout.is_empty() {
264                text.push_str("stdout:\n");
265                text.push_str(&stdout);
266                if !stdout.ends_with('\n') {
267                    text.push('\n');
268                }
269            }
270            if !stderr.is_empty() {
271                text.push_str("stderr:\n");
272                text.push_str(&stderr);
273            }
274            ToolOutcome {
275                text: text.trim_end().to_string(),
276                is_error: !out.status.success(),
277            }
278        }
279        Err(e) => ToolOutcome {
280            text: format!("failed to run command: {e}"),
281            is_error: true,
282        },
283    }
284}
285
286/// True if the admin-set fail-closed marker is present (an agent can't unset a
287/// root-owned marker) OR the `KINTSUGI_FAIL_CLOSED` env var opts in. The marker
288/// wins, so `KINTSUGI_FAIL_CLOSED=0` can't re-open the gate.
289fn fail_closed() -> bool {
290    kintsugi_daemon::is_fail_closed_marked()
291        || matches!(
292            std::env::var("KINTSUGI_FAIL_CLOSED").ok().as_deref(),
293            Some("1") | Some("true") | Some("yes")
294        )
295}
296
297fn result_response(id: Value, result: Value) -> String {
298    json!({ "jsonrpc": "2.0", "id": id, "result": result }).to_string()
299}
300
301fn error_response(id: Value, code: i64, message: &str) -> String {
302    json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }).to_string()
303}
304
305/// Run the MCP server: read JSON-RPC lines from stdin, write responses to stdout.
306pub fn run() -> anyhow::Result<()> {
307    let stdin = std::io::stdin();
308    let stdout = std::io::stdout();
309    run_io(stdin.lock(), stdout.lock())
310}
311
312/// The server loop over arbitrary reader/writer (testable).
313pub fn run_io<R: std::io::BufRead, W: std::io::Write>(
314    reader: R,
315    mut writer: W,
316) -> anyhow::Result<()> {
317    for line in reader.lines() {
318        let line = line?;
319        if line.trim().is_empty() {
320            continue;
321        }
322        if let Some(resp) = handle_message(&line) {
323            writeln!(writer, "{resp}")?;
324            writer.flush()?;
325        }
326    }
327    Ok(())
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn initialize_reports_server_info() {
336        let req = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#;
337        let resp: Value = serde_json::from_str(&handle_message(req).unwrap()).unwrap();
338        assert_eq!(resp["id"], 1);
339        assert_eq!(resp["result"]["serverInfo"]["name"], "kintsugi-exec");
340        // Echoes the client's requested protocol version.
341        assert_eq!(resp["result"]["protocolVersion"], "2025-06-18");
342    }
343
344    #[test]
345    fn tools_list_includes_kintsugi_exec() {
346        let req = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#;
347        let resp: Value = serde_json::from_str(&handle_message(req).unwrap()).unwrap();
348        let tools = resp["result"]["tools"].as_array().unwrap();
349        assert_eq!(tools.len(), 1);
350        assert_eq!(tools[0]["name"], TOOL_NAME);
351        assert!(tools[0]["inputSchema"]["properties"]["command"].is_object());
352    }
353
354    #[test]
355    fn notification_gets_no_response() {
356        let note = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
357        assert!(handle_message(note).is_none());
358    }
359
360    #[test]
361    fn unknown_method_is_an_error() {
362        let req = r#"{"jsonrpc":"2.0","id":9,"method":"does/not/exist"}"#;
363        let resp: Value = serde_json::from_str(&handle_message(req).unwrap()).unwrap();
364        assert_eq!(resp["error"]["code"], -32601);
365    }
366
367    #[test]
368    fn call_without_command_is_an_error() {
369        let req = r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"kintsugi-exec","arguments":{}}}"#;
370        let resp: Value = serde_json::from_str(&handle_message(req).unwrap()).unwrap();
371        assert_eq!(resp["error"]["code"], -32602);
372    }
373
374    #[test]
375    fn run_io_responds_to_requests_and_skips_notifications() {
376        let input = concat!(
377            "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n",
378            "\n",
379            "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n",
380            "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}\n",
381        );
382        let mut out = Vec::new();
383        run_io(std::io::Cursor::new(input), &mut out).unwrap();
384        let text = String::from_utf8(out).unwrap();
385        // Two responses (initialize, tools/list); the notification yields none.
386        assert_eq!(text.lines().count(), 2);
387        assert!(text.contains("serverInfo"));
388        assert!(text.contains(TOOL_NAME));
389    }
390}