Skip to main content

innate_core/
mcp.rs

1//! MCP server — JSON-RPC 2.0 over stdio.
2//!
3//! Implements the Model Context Protocol (MCP) so any MCP-compatible host
4//! (Claude Code, Claude Desktop, etc.) can call Innate without a separate SDK.
5//!
6//! Usage:  innate mcp   (reads from stdin, writes to stdout)
7
8use std::io::{self, BufRead, Write};
9use std::path::PathBuf;
10use std::sync::Mutex;
11
12use serde_json::{json, Value};
13
14use crate::kb::{
15    AppraiseParams, KnowledgeBase, RecallParams, RecordParams, Situation, APPRAISE_ADVISORY,
16};
17
18#[cfg(target_os = "linux")]
19use libc;
20
21// Tool names
22const TOOLS: &[(&str, &str)] = &[
23    ("innate_recall",         "Call FIRST at the start of any task — retrieve relevant knowledge from the knowledge base and get a trace_id for subsequent recording."),
24    ("innate_record",         "Call LAST after completing any task — close the trace_id from recall with outcome ok/fail/unknown and optional feedback."),
25    ("innate_appraise",       "Critic check — given a situation (and optional candidate answer), return how much footing exists: {valence, strength, tier, confidence, dispersion, abstained, abstain_reason, flagged_points}. May abstain (abstained=true) when there is no footing — abstaining is correct, not a failure. Never returns an answer. Use to gut-check before committing to a risky step; flagged_points = things to be careful about."),
26    ("innate_add",            "Capture a confirmed insight as a knowledge chunk (always starts as pending for agent source)."),
27    ("innate_spark",          "Save a quick idea / hypothesis for later incubation."),
28    ("innate_inspect",        "Show knowledge base health: chunk counts, debt ratio, embed rebuild queue."),
29    ("innate_evolve",         "Call at session end — distil episodic logs into pending chunks and run curate cycle (archive / decay / promote). Pass rebuild_embeddings=true to also rebuild the embedding index."),
30    ("innate_approve",        "Approve a pending chunk, making it active."),
31    ("innate_archive",        "Archive a knowledge chunk."),
32    ("innate_invalidate",     "Invalidate a chunk and blacklist its content hash."),
33    ("innate_restore",        "Restore an archived chunk to active."),
34    ("innate_mature_spark",   "Advance a spark maturity: seed → sprouting → incubating."),
35    ("innate_promote_spark",  "Promote a spark to a full knowledge chunk."),
36    ("innate_drop_spark",     "Drop (abandon) a spark."),
37    ("innate_backup",         "Backup or inspect R2 backups. action: run=backup now (honours interval unless force=true), status=show state, list=list backups in R2, prune=delete old backups."),
38];
39
40fn default_mcp_log() -> PathBuf {
41    crate::paths::mcp_log_path()
42}
43
44fn mcp_log(log: &Mutex<Box<dyn io::Write + Send>>, msg: &str) {
45    let ts = crate::utils::utc_now_iso();
46    if let Ok(mut w) = log.lock() {
47        let _ = writeln!(w, "{ts} {msg}");
48    }
49}
50
51pub fn run_server(db_path: PathBuf) -> anyhow::Result<()> {
52    crate::paths::ensure_layout();
53    maybe_auto_start_daemon(&db_path);
54
55    let log_path = default_mcp_log();
56    if let Some(p) = log_path.parent() {
57        let _ = std::fs::create_dir_all(p);
58    }
59    let log: Mutex<Box<dyn io::Write + Send>> = Mutex::new(
60        match std::fs::OpenOptions::new()
61            .create(true)
62            .append(true)
63            .open(&log_path)
64        {
65            Ok(f) => Box::new(f),
66            Err(_) => Box::new(io::sink()),
67        },
68    );
69    mcp_log(&log, &format!("[mcp] started db={}", db_path.display()));
70
71    let kb = Mutex::new(crate::open_kb(&db_path)?);
72    let stdin = io::stdin();
73    let stdout = io::stdout();
74
75    for line in stdin.lock().lines() {
76        let line = line?;
77        if line.trim().is_empty() {
78            continue;
79        }
80
81        let req: Value = match serde_json::from_str(&line) {
82            Ok(v) => v,
83            Err(e) => {
84                mcp_log(&log, &format!("[mcp] parse error: {e}"));
85                write_response(
86                    &stdout,
87                    json!({
88                        "jsonrpc": "2.0",
89                        "error": {"code": -32700, "message": format!("Parse error: {e}")},
90                        "id": null
91                    }),
92                )?;
93                continue;
94            }
95        };
96
97        let id = req.get("id").cloned().unwrap_or(Value::Null);
98        let method = req.get("method").and_then(Value::as_str).unwrap_or("");
99        let params = req.get("params").cloned().unwrap_or(json!({}));
100
101        let response = match method {
102            "initialize" => handle_initialize(&id),
103            "tools/list" => handle_tools_list(&id),
104            "tools/call" => handle_tool_call(&kb, &id, &params, &log),
105            "notifications/initialized" | "ping" => continue,
106            _ => json!({
107                "jsonrpc": "2.0",
108                "error": {"code": -32601, "message": format!("Method not found: {method}")},
109                "id": id
110            }),
111        };
112
113        write_response(&stdout, response)?;
114    }
115    Ok(())
116}
117
118fn write_response(stdout: &io::Stdout, v: Value) -> io::Result<()> {
119    let mut out = stdout.lock();
120    writeln!(out, "{}", serde_json::to_string(&v).unwrap_or_default())?;
121    out.flush()
122}
123
124fn handle_initialize(id: &Value) -> Value {
125    json!({
126        "jsonrpc": "2.0",
127        "id": id,
128        "result": {
129            "protocolVersion": "2024-11-05",
130            "capabilities": {"tools": {}},
131            "serverInfo": {
132                "name": "innate",
133                "version": env!("CARGO_PKG_VERSION")
134            }
135        }
136    })
137}
138
139fn handle_tools_list(id: &Value) -> Value {
140    let tools: Vec<Value> = TOOLS
141        .iter()
142        .map(|(name, desc)| {
143            json!({
144                "name": name,
145                "description": desc,
146                "inputSchema": tool_schema(name)
147            })
148        })
149        .collect();
150    json!({"jsonrpc": "2.0", "id": id, "result": {"tools": tools}})
151}
152
153fn handle_tool_call(
154    kb: &Mutex<KnowledgeBase>,
155    id: &Value,
156    params: &Value,
157    log: &Mutex<Box<dyn io::Write + Send>>,
158) -> Value {
159    let name = params.get("name").and_then(Value::as_str).unwrap_or("");
160    let args = params.get("arguments").cloned().unwrap_or(json!({}));
161
162    let result = {
163        let kb = kb.lock().unwrap_or_else(|e| e.into_inner());
164        dispatch(&kb, name, &args)
165    };
166
167    match result {
168        Ok(v) => {
169            mcp_log(log, &format!("[mcp] tool={name} ok"));
170            json!({
171                "jsonrpc": "2.0",
172                "id": id,
173                "result": {
174                    "content": [{"type": "text", "text": v.to_string()}],
175                    "isError": false
176                }
177            })
178        }
179        Err(e) => {
180            mcp_log(log, &format!("[mcp] tool={name} err={e}"));
181            json!({
182                "jsonrpc": "2.0",
183                "id": id,
184                "result": {
185                    "content": [{"type": "text", "text": format!("error: {e}")}],
186                    "isError": true
187                }
188            })
189        }
190    }
191}
192
193fn dispatch(kb: &KnowledgeBase, name: &str, args: &Value) -> crate::errors::Result<Value> {
194    let s = |key: &str| {
195        args.get(key)
196            .and_then(Value::as_str)
197            .unwrap_or("")
198            .to_string()
199    };
200    let so = |key: &str| args.get(key).and_then(Value::as_str).map(str::to_string);
201    let b = |key: &str, d: bool| args.get(key).and_then(Value::as_bool).unwrap_or(d);
202    let n = |key: &str, d: i64| args.get(key).and_then(Value::as_i64).unwrap_or(d);
203    let arr = |key: &str| -> Vec<String> {
204        args.get(key)
205            .and_then(Value::as_array)
206            .map(|a| {
207                a.iter()
208                    .filter_map(|v| v.as_str().map(str::to_string))
209                    .collect()
210            })
211            .unwrap_or_default()
212    };
213
214    match name {
215        "innate_recall" => {
216            let query = s("query");
217            let budget = n("budget", 6000) as usize;
218            let top = args.get("top").and_then(Value::as_u64).map(|v| v as usize);
219            let include_sparks = b("include_sparks", false);
220            let source = if s("source").is_empty() {
221                "mcp".to_string()
222            } else {
223                s("source")
224            };
225            let expand_deps = if s("expand_deps").is_empty() {
226                "false".to_string()
227            } else {
228                s("expand_deps")
229            };
230            let allow_trim = b("allow_trim", false);
231            let refine_mode = if s("refine_mode").is_empty() {
232                "off".to_string()
233            } else {
234                s("refine_mode")
235            };
236            let min_score = args.get("min_score").and_then(Value::as_f64);
237            let result = kb.recall(RecallParams {
238                query: &query,
239                budget,
240                trace: true,
241                include_sparks,
242                top,
243                source: &source,
244                expand_deps: &expand_deps,
245                allow_trim,
246                refine_mode: &refine_mode,
247                min_score,
248                session_only: false,
249                // MCP recall stays no-LLM by default; deep rerank is CLI-only ("deep recall").
250                rerank: false,
251            })?;
252            Ok(json!({
253                "trace_id": result.trace_id,
254                "knowledge": result.knowledge,
255                "sparks": result.sparks,
256                "empty": result.empty,
257            }))
258        }
259        "innate_appraise" => {
260            let query = s("query");
261            let last_error = so("last_error");
262            let stage = so("stage");
263            let file_context = so("file_context");
264            let candidate = so("candidate");
265            let recent_actions = arr("recent_actions");
266            let top = args.get("top").and_then(Value::as_u64).map(|v| v as usize);
267            let min_strength = args.get("min_strength").and_then(Value::as_f64);
268            let source = if s("source").is_empty() {
269                "mcp".to_string()
270            } else {
271                s("source")
272            };
273            let situation = Situation {
274                query: (!query.is_empty()).then_some(query.as_str()),
275                last_error: last_error.as_deref(),
276                recent_actions: &recent_actions,
277                stage: stage.as_deref(),
278                file_context: file_context.as_deref(),
279            };
280            let verdict = kb.appraise(AppraiseParams {
281                situation,
282                candidate: candidate.as_deref(),
283                min_strength,
284                top,
285                trace: true,
286                source: &source,
287            })?;
288            Ok(json!({
289                // 显式声明:直觉仅供参考,不是精准答案。随每个 verdict 返给 agent。
290                "advisory": APPRAISE_ADVISORY,
291                "valence": verdict.valence,
292                "strength": verdict.strength,
293                "tier": verdict.tier,
294                "confidence": verdict.confidence,
295                "dispersion": verdict.dispersion,
296                "abstained": verdict.abstained,
297                "abstain_reason": verdict.abstain_reason,
298                "flagged_points": verdict.flagged_points,
299                "contributors": verdict.contributors,
300                "trace_id": verdict.trace_id,
301            }))
302        }
303        "innate_record" => {
304            let trace_id = s("trace_id");
305            let outcome = so("outcome");
306            let used = arr("used");
307            let used_ref: Option<&[String]> = args
308                .get("used")
309                .and_then(Value::as_array)
310                .map(|_| used.as_slice());
311            let fb_up = arr("feedback_up");
312            let fb_up_ref: Option<&[String]> = if fb_up.is_empty() { None } else { Some(&fb_up) };
313            let fb_down = arr("feedback_down");
314            let fb_down_ref: Option<&[String]> = if fb_down.is_empty() {
315                None
316            } else {
317                Some(&fb_down)
318            };
319            let source = if s("source").is_empty() {
320                "mcp".to_string()
321            } else {
322                s("source")
323            };
324            let used_attribution = if s("used_attribution").is_empty() {
325                "explicit".to_string()
326            } else {
327                s("used_attribution")
328            };
329            let feedback_kind = if s("feedback_kind").is_empty() {
330                "user".to_string()
331            } else {
332                s("feedback_kind")
333            };
334            let query = so("query");
335            let output = so("output");
336            let output_summary = so("output_summary");
337            let feedback_actor = so("feedback_actor");
338            let feedback_reason = so("feedback_reason");
339            let nomination = so("nomination");
340            let task_state = so("task_state");
341            kb.record(RecordParams {
342                trace_id: &trace_id,
343                query: query.as_deref(),
344                output: output.as_deref(),
345                output_summary: output_summary.as_deref(),
346                outcome: outcome.as_deref(),
347                used: used_ref,
348                used_attribution: &used_attribution,
349                used_complete: Some(b("used_complete", true)),
350                feedback_up: fb_up_ref,
351                feedback_down: fb_down_ref,
352                feedback_kind: &feedback_kind,
353                feedback_actor: feedback_actor.as_deref(),
354                feedback_reason: feedback_reason.as_deref(),
355                nomination: nomination.as_deref(),
356                priority: n("priority", 0),
357                task_state: task_state.as_deref(),
358                source: &source,
359                verdict_heeded: b("verdict_heeded", false),
360            })?;
361            Ok(json!({"ok": true}))
362        }
363        "innate_add" => {
364            let content = s("content");
365            let kind = if s("kind").is_empty() {
366                "note".to_string()
367            } else {
368                s("kind")
369            };
370            let source = if s("source").is_empty() {
371                "agent".to_string()
372            } else {
373                s("source")
374            };
375            let dep_kind = if s("dep_kind").is_empty() {
376                "hard".to_string()
377            } else {
378                s("dep_kind")
379            };
380            let deps: Vec<(String, String)> = arr("depends_on")
381                .into_iter()
382                .map(|d| (d, dep_kind.clone()))
383                .collect();
384            let id = kb.add_with_deps(
385                &content,
386                &kind,
387                so("trigger_desc").as_deref(),
388                so("anti_trigger_desc").as_deref(),
389                &source,
390                so("skill_name").as_deref(),
391                &deps,
392            )?;
393            Ok(json!({"chunk_id": id}))
394        }
395        "innate_spark" => {
396            let content = s("content");
397            let id = kb.spark(
398                &content,
399                so("trigger_desc").as_deref(),
400                so("anti_trigger_desc").as_deref(),
401            )?;
402            Ok(json!({"chunk_id": id}))
403        }
404        "innate_inspect" => kb.inspect(),
405        "innate_evolve" => {
406            let trigger = if s("trigger").is_empty() {
407                "manual".to_string()
408            } else {
409                s("trigger")
410            };
411            if b("rebuild_embeddings", false) {
412                // Bound the re-embed batch so a large stale backlog never blocks
413                // the MCP request on a long string of network embedding calls;
414                // the remainder is reported and picked up by the next evolve.
415                // `rebuild_max=0` means unbounded (rebuild everything this call).
416                let cap = n("rebuild_max", 200);
417                let max = if cap <= 0 { None } else { Some(cap as usize) };
418                let (rebuilt, remaining) = kb.rebuild_embeddings_capped(max)?;
419                let evolve = kb.evolve(&trigger)?;
420                return Ok(json!({
421                    "rebuilt_embeddings": rebuilt,
422                    "rebuild_remaining": remaining,
423                    "evolve": evolve,
424                }));
425            }
426            kb.evolve(&trigger)
427        }
428        "innate_approve" => {
429            kb.approve(&s("chunk_id"))?;
430            Ok(json!({"ok": true}))
431        }
432        "innate_archive" => {
433            let reason = s("reason");
434            let reason = if reason.is_empty() { "stale" } else { &reason };
435            kb.archive(&s("chunk_id"), reason)?;
436            Ok(json!({"ok": true}))
437        }
438        "innate_invalidate" => {
439            kb.invalidate(&s("chunk_id"), &s("reason"))?;
440            Ok(json!({"ok": true}))
441        }
442        "innate_restore" => {
443            kb.restore(&s("chunk_id"))?;
444            Ok(json!({"ok": true}))
445        }
446        "innate_mature_spark" => {
447            kb.mature_spark(&s("spark_id"), &s("to"))?;
448            Ok(json!({"ok": true}))
449        }
450        "innate_promote_spark" => {
451            let to = if s("to").is_empty() {
452                "note".to_string()
453            } else {
454                s("to")
455            };
456            let new_id = kb.promote_spark(&s("spark_id"), &to)?;
457            Ok(json!({"chunk_id": new_id}))
458        }
459        "innate_drop_spark" => {
460            kb.drop_spark(&s("spark_id"), &s("reason"))?;
461            Ok(json!({"ok": true}))
462        }
463        "innate_backup" => {
464            let db_path = kb.storage.db_path.clone();
465            dispatch_backup(&s("action"), b("force", false), &db_path)
466        }
467        _ => Err(crate::errors::InnateError::Other(format!(
468            "unknown tool: {name}"
469        ))),
470    }
471}
472
473fn tool_schema(name: &str) -> Value {
474    match name {
475        "innate_recall" => json!({
476            "type": "object",
477            "properties": {
478                "query": {"type": "string", "description": "Search query"},
479                "budget": {"type": "integer", "description": "Token budget (default 6000)"},
480                "top": {"type": "integer", "description": "Max results"},
481                "include_sparks": {"type": "boolean"},
482                "expand_deps": {"type": "string", "enum": ["false","direct","closure"], "description": "Dependency expansion: false (default) | direct | closure"},
483                "source": {"type": "string", "enum": ["mcp","sdk","cli","hook","daemon","augmented"]},
484                "min_score": {"type": "number", "description": "Relevance gate applied before the pending lifecycle penalty; final ranking remains penalized (omit to disable)"}
485            },
486            "required": ["query"]
487        }),
488        "innate_appraise" => json!({
489            "type": "object",
490            "properties": {
491                "query": {"type": "string", "description": "Explicit question/instruction (optional)"},
492                "last_error": {"type": "string", "description": "Current or last error text"},
493                "recent_actions": {"type": "array", "items": {"type": "string"}, "description": "Last few actions taken"},
494                "stage": {"type": "string", "description": "Task stage, e.g. merge | implement | review"},
495                "file_context": {"type": "string", "description": "File type/path summary in scope"},
496                "candidate": {"type": "string", "description": "Candidate answer under judgement (sanitized, never echoed back)"},
497                "top": {"type": "integer"},
498                "min_strength": {"type": "number"},
499                "source": {"type": "string", "enum": ["mcp","sdk","cli","hook","daemon","augmented"]}
500            }
501        }),
502        "innate_record" => json!({
503            "type": "object",
504            "properties": {
505                "trace_id": {"type": "string"},
506                "query": {"type": "string", "description": "Original query from the corresponding recall"},
507                "output": {"type": "string", "description": "Raw task output (optional, for distillation)"},
508                "outcome": {"type": "string", "enum": ["ok","fail","unknown"]},
509                "used": {"type": "array", "items": {"type": "string"}},
510                "used_attribution": {"type": "string", "enum": ["explicit","cited","inferred"]},
511                "used_complete": {"type": "boolean", "description": "Whether used exhaustively lists all selected chunks (default true)"},
512                "feedback_up": {"type": "array", "items": {"type": "string"}},
513                "feedback_down": {"type": "array", "items": {"type": "string"}},
514                "feedback_kind": {"type": "string", "enum": ["user","judge"]},
515                "feedback_actor": {"type": "string"},
516                "feedback_reason": {"type": "string"},
517                "task_state": {"type": "string", "enum": ["recalled","running","completed","abandoned","timed_out"]},
518                "output_summary": {"type": "string"},
519                "nomination": {"type": "string"},
520                "priority": {"type": "integer"},
521                "verdict_heeded": {"type": "boolean", "description": "Set when this trace came from an appraise whose caution was heeded (action avoided): the outcome is counterfactual and is excluded from the critic's calibration."},
522                "source": {"type": "string", "enum": ["mcp","sdk","cli","hook","daemon","augmented"]}
523            },
524            "required": ["trace_id"]
525        }),
526        "innate_add" => json!({
527            "type": "object",
528            "properties": {
529                "content": {"type": "string"},
530                "kind": {"type": "string", "enum": ["note","skill"]},
531                "trigger_desc": {"type": "string"},
532                "anti_trigger_desc": {"type": "string"},
533                "source": {"type": "string", "enum": ["chat","manual","doc","agent"]},
534                "skill_name": {"type": "string"},
535                "depends_on": {"type": "array", "items": {"type": "string"}, "description": "Chunk ids this chunk depends on"},
536                "dep_kind": {"type": "string", "enum": ["hard","soft"], "description": "Dependency kind for depends_on (default hard)"}
537            },
538            "required": ["content"]
539        }),
540        "innate_spark" => json!({
541            "type": "object",
542            "properties": {
543                "content": {"type": "string"},
544                "trigger_desc": {"type": "string"},
545                "anti_trigger_desc": {"type": "string"}
546            },
547            "required": ["content"]
548        }),
549        "innate_inspect" => json!({"type": "object", "properties": {}}),
550        "innate_evolve" => json!({
551            "type": "object",
552            "properties": {
553                "trigger": {"type": "string", "enum": ["manual","scheduled","threshold"]},
554                "rebuild_embeddings": {"type": "boolean", "description": "Also rebuild the embedding index before evolving"},
555                "rebuild_max": {"type": "integer", "description": "Max stale chunks to re-embed this call (default 200; 0 = unbounded). Bounds MCP latency; remainder reported as rebuild_remaining."}
556            }
557        }),
558        "innate_approve" | "innate_archive" | "innate_invalidate" | "innate_restore" => json!({
559            "type": "object",
560            "properties": {
561                "chunk_id": {"type": "string"},
562                "reason": {"type": "string"}
563            },
564            "required": ["chunk_id"]
565        }),
566        "innate_mature_spark" => json!({
567            "type": "object",
568            "properties": {
569                "spark_id": {"type": "string"},
570                "to": {"type": "string", "enum": ["sprouting","incubating"]}
571            },
572            "required": ["spark_id", "to"]
573        }),
574        "innate_promote_spark" => json!({
575            "type": "object",
576            "properties": {
577                "spark_id": {"type": "string"},
578                "to": {"type": "string", "enum": ["note","skill"]}
579            },
580            "required": ["spark_id"]
581        }),
582        "innate_drop_spark" => json!({
583            "type": "object",
584            "properties": {
585                "spark_id": {"type": "string"},
586                "reason": {"type": "string"}
587            },
588            "required": ["spark_id"]
589        }),
590        "innate_backup" => json!({
591            "type": "object",
592            "properties": {
593                "action": {
594                    "type": "string",
595                    "enum": ["run", "status", "list", "prune"],
596                    "description": "run=backup now, status=show last backup state, list=list R2 backups, prune=delete old backups"
597                },
598                "force": {
599                    "type": "boolean",
600                    "description": "For action=run: skip the interval check and backup immediately (default false)"
601                }
602            },
603            "required": ["action"]
604        }),
605        _ => json!({"type": "object", "properties": {}}),
606    }
607}
608
609// ---------------------------------------------------------------------------
610// Backup dispatch (converts anyhow errors to InnateError::Other)
611// ---------------------------------------------------------------------------
612
613fn dispatch_backup(
614    action: &str,
615    force: bool,
616    db_path: &std::path::Path,
617) -> crate::errors::Result<Value> {
618    use crate::backup::R2BackupService;
619    use crate::errors::InnateError;
620
621    let settings = crate::settings::load()?;
622    let cfg = settings.backup.as_ref().ok_or_else(|| {
623        InnateError::Other(
624            "backup not configured — add a \"backup\" section with \"enable\": true \
625             and \"r2\" credentials to ~/.innate/settings.json"
626                .into(),
627        )
628    })?;
629    if !cfg.enable {
630        return Err(InnateError::Other(
631            "R2 backup is disabled (backup.enable = false). \
632             Set \"enable\": true in ~/.innate/settings.json to activate."
633                .into(),
634        ));
635    }
636    let r2_cfg = cfg.r2.as_ref().ok_or_else(|| {
637        InnateError::Other("backup.r2 not configured in ~/.innate/settings.json".into())
638    })?;
639
640    match action {
641        "run" => {
642            if !force && !R2BackupService::needs_backup(cfg.auto_backup_interval_hours) {
643                let state = R2BackupService::last_backup_state();
644                return Ok(json!({
645                    "ok": false,
646                    "reason": "not_due",
647                    "last_backup_at": state.last_backup_at,
648                    "interval_hours": cfg.auto_backup_interval_hours,
649                }));
650            }
651            let svc = R2BackupService::from_config(r2_cfg)
652                .map_err(|e| InnateError::Other(e.to_string()))?;
653            let result = svc
654                .backup_now(db_path, cfg.retention_days, cfg.min_backups)
655                .map_err(|e| InnateError::Other(e.to_string()))?;
656            Ok(json!({
657                "ok": true,
658                "key": result.key,
659                "size_bytes": result.size_bytes,
660                "pruned": result.prune.deleted,
661                "kept": result.prune.kept,
662                "protected_by_min": result.prune.protected_by_min,
663            }))
664        }
665        "status" => {
666            let state = R2BackupService::last_backup_state();
667            Ok(json!({
668                "bucket": r2_cfg.bucket,
669                "last_backup_at": state.last_backup_at,
670                "last_backup_key": state.last_backup_key,
671                "backup_due": R2BackupService::needs_backup(cfg.auto_backup_interval_hours),
672                "interval_hours": cfg.auto_backup_interval_hours,
673                "retention_days": cfg.retention_days,
674                "min_backups": cfg.min_backups,
675            }))
676        }
677        "list" => {
678            let svc = R2BackupService::from_config(r2_cfg)
679                .map_err(|e| InnateError::Other(e.to_string()))?;
680            let backups = svc
681                .list_backups()
682                .map_err(|e| InnateError::Other(e.to_string()))?;
683            Ok(json!({"backups": backups}))
684        }
685        "prune" => {
686            let svc = R2BackupService::from_config(r2_cfg)
687                .map_err(|e| InnateError::Other(e.to_string()))?;
688            let result = svc
689                .prune_old_backups(cfg.retention_days, cfg.min_backups)
690                .map_err(|e| InnateError::Other(e.to_string()))?;
691            Ok(json!({
692                "deleted": result.deleted,
693                "kept": result.kept,
694                "protected_by_min": result.protected_by_min,
695            }))
696        }
697        other => Err(InnateError::Other(format!(
698            "unknown backup action '{other}'; valid: run, status, list, prune"
699        ))),
700    }
701}
702
703// ---------------------------------------------------------------------------
704// Daemon auto-start
705// ---------------------------------------------------------------------------
706
707fn default_pid_file() -> PathBuf {
708    crate::paths::daemon_pid_path()
709}
710
711/// If `settings.daemon.auto_start` is true and watch_dirs are configured, start the
712/// daemon in the background if it is not already running. Failures are silently ignored
713/// so the MCP server always starts even when the daemon can't be spawned.
714fn maybe_auto_start_daemon(db_path: &std::path::Path) {
715    // Best-effort: a corrupt config simply means "no auto-start" rather than
716    // blocking MCP startup (errors here are intentionally swallowed).
717    let s = crate::settings::load().unwrap_or_default();
718    match &s.daemon {
719        Some(c) if c.auto_start => {}
720        _ => return,
721    };
722    let watch_dirs = crate::settings::resolved_watch_dirs(&s);
723    if watch_dirs.is_empty() {
724        return;
725    }
726
727    let pid_file = default_pid_file();
728    if crate::daemon::is_running(&pid_file) {
729        return;
730    }
731
732    // Spawn `innate --db <path> daemon start --watch ...` as a detached child process.
733    let Ok(exe) = std::env::current_exe() else {
734        return;
735    };
736    let mut cmd = std::process::Command::new(&exe);
737    // --db is a global flag that must come before the subcommand.
738    cmd.arg("--db").arg(db_path);
739    cmd.arg("daemon").arg("start");
740    for dir in &watch_dirs {
741        cmd.arg("--watch").arg(dir);
742    }
743    // Redirect stdio away from the MCP stdio channel.
744    cmd.stdin(std::process::Stdio::null())
745        .stdout(std::process::Stdio::null())
746        .stderr(std::process::Stdio::null());
747
748    // Use setsid on Linux so the daemon is in its own process group and won't
749    // receive signals sent to the MCP server's process group.
750    #[cfg(target_os = "linux")]
751    {
752        use std::os::unix::process::CommandExt;
753        unsafe {
754            cmd.pre_exec(|| {
755                libc::setsid();
756                Ok(())
757            });
758        }
759    }
760
761    let _ = cmd.spawn(); // fire-and-forget
762}