1use std::path::PathBuf;
14use std::process::Command;
15
16use kintsugi_core::{shell, Decision, ProposedCommand};
17use kintsugi_daemon::Client;
18use serde_json::{json, Value};
19
20const DEFAULT_PROTOCOL_VERSION: &str = "2024-11-05";
22pub const TOOL_NAME: &str = "kintsugi-exec";
24
25pub 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 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 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
146fn 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 Ok(verdict) if verdict.decision == Decision::Hold => wait_for_approval(&id),
157 Ok(verdict) => verdict.decision,
158 Err(e) => {
159 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 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
202fn 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
213fn wait_for_approval(id: &str) -> Decision {
216 let deadline = std::time::Instant::now() + approval_timeout();
217 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, Ok(_) => consecutive_errors = 0, Err(_) => {
228 consecutive_errors += 1;
229 if consecutive_errors >= 5 {
230 return Decision::Hold; }
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
241fn 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
286fn 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
305pub 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
312pub 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 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 assert_eq!(text.lines().count(), 2);
387 assert!(text.contains("serverInfo"));
388 assert!(text.contains(TOOL_NAME));
389 }
390}