Skip to main content

mkit_cli/commands/
mcp.rs

1//! `mkit mcp` — a local Model Context Protocol server over stdio.
2//!
3//! Exposes a conservative subset of mkit as MCP tools so LLM agents can
4//! read, search, and manipulate local mkit repositories — the mkit
5//! analog of the reference `mcp-server-git`. Design choices follow that
6//! template where it is right and diverge where mkit is stronger:
7//!
8//! * **Local + stdio.** Newline-delimited JSON-RPC 2.0 on stdin/stdout,
9//!   processed sequentially. No async runtime: the loop is plain
10//!   blocking I/O, keeping the default build tokio-free.
11//! * **Subprocess execution.** Each tool call re-invokes this same
12//!   binary (`std::env::current_exe()`) with a structured argv — never
13//!   a shell — capturing stdout/stderr and the sysexits code. The MCP
14//!   loop owns this process's stdout for the protocol, so tools must
15//!   not print here; subprocess isolation guarantees that, and the
16//!   server version always equals the CLI version.
17//! * **Conservative surface.** Like the git template: no network ops
18//!   (push/pull/fetch/clone), no history surgery (merge/rebase/
19//!   cherry-pick/revert), no worktree destruction (reset --hard /
20//!   clean / rm). The server never passes `-f`/`--force`, so mkit's
21//!   own data-loss guards remain the backstop. Unlike the template:
22//!   first-class signing + attestation tools — the reason mkit exists.
23//! * **Scoping.** `--repository <path>` confines every `repo_path`
24//!   argument (symlink-resolved) to that root. Without the flag any
25//!   path the process can reach is allowed (client-trust mode), same
26//!   as the git template.
27//! * **Injection defense.** Path/ref-like arguments are rejected if
28//!   they begin with `-` so a value can never be parsed as a flag by
29//!   the child CLI (which has no `--` separator on `add`).
30
31use std::io::{BufRead, Write};
32use std::path::{Path, PathBuf};
33
34use clap::Parser;
35use serde_json::{Value, json};
36
37use crate::clap_shim;
38use crate::exit;
39
40#[derive(Debug, Parser)]
41struct McpOpts {
42    /// Confine all tool calls to this repository path (and its
43    /// subdirectories). Strongly recommended for agent use.
44    #[arg(long, short = 'r', value_name = "PATH")]
45    repository: Option<PathBuf>,
46}
47
48/// Entry point for `mkit mcp`.
49#[must_use]
50pub fn run(args: &[String]) -> u8 {
51    let opts = match clap_shim::parse::<McpOpts>("mkit mcp", args) {
52        Ok(o) => o,
53        Err(code) => return code,
54    };
55    let allowed = match &opts.repository {
56        Some(p) => match p.canonicalize() {
57            Ok(c) => Some(c),
58            Err(e) => {
59                let mut stderr = std::io::stderr().lock();
60                let _ = writeln!(stderr, "error: --repository {}: {e}", p.display());
61                return exit::NOINPUT;
62            }
63        },
64        None => None,
65    };
66    serve(allowed.as_deref())
67}
68
69/// Blocking JSON-RPC loop: one message per line, responses flushed
70/// immediately. Returns when stdin reaches EOF (client disconnect).
71fn serve(allowed: Option<&Path>) -> u8 {
72    let stdin = std::io::stdin();
73    let mut stdout = std::io::stdout().lock();
74    // MCP lifecycle: `initialize` must precede any tool traffic.
75    let mut initialized = false;
76
77    for line in stdin.lock().lines() {
78        let Ok(line) = line else { break };
79        if line.trim().is_empty() {
80            continue;
81        }
82        let parsed: Result<Value, _> = serde_json::from_str(&line);
83        let (messages, is_batch): (Vec<Value>, bool) = match parsed {
84            // A few legacy clients batch; MCP 2025+ forbids it, but
85            // handling an array costs nothing. Per JSON-RPC 2.0, a batch
86            // request gets ONE batch (array) response — and a batch of
87            // only notifications gets no response at all.
88            Ok(Value::Array(batch)) => (batch, true),
89            Ok(v) => (vec![v], false),
90            Err(_) => {
91                write_msg(
92                    &mut stdout,
93                    &json!({
94                        "jsonrpc": "2.0",
95                        "id": null,
96                        "error": { "code": -32700, "message": "parse error" }
97                    }),
98                );
99                continue;
100            }
101        };
102        let responses: Vec<Value> = messages
103            .iter()
104            .filter_map(|msg| handle_message(msg, allowed, &mut initialized))
105            .collect();
106        if is_batch {
107            if !responses.is_empty() {
108                write_msg(&mut stdout, &Value::Array(responses));
109            }
110        } else if let Some(response) = responses.into_iter().next() {
111            write_msg(&mut stdout, &response);
112        }
113    }
114    exit::OK
115}
116
117/// Protocol revisions this server interoperates with. The wire framing
118/// and tools surface are common across these; `initialize` negotiates
119/// the requested one when supported, else falls back to the latest.
120const SUPPORTED_PROTOCOLS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
121const LATEST_PROTOCOL: &str = "2025-06-18";
122
123fn write_msg(stdout: &mut impl Write, msg: &Value) {
124    // serde_json compact form contains no raw newlines, so one
125    // message per line is structurally guaranteed.
126    if let Ok(s) = serde_json::to_string(msg) {
127        let _ = writeln!(stdout, "{s}");
128        let _ = stdout.flush();
129    }
130}
131
132/// Dispatch one JSON-RPC message. Returns `None` for notifications
133/// (nothing is written back). `initialized` tracks the MCP lifecycle:
134/// set on `initialize`, required before any tool traffic.
135fn handle_message(msg: &Value, allowed: Option<&Path>, initialized: &mut bool) -> Option<Value> {
136    let method = msg.get("method").and_then(Value::as_str)?;
137    let id = msg.get("id");
138    match (method, id) {
139        // ---- notifications (no response) --------------------------
140        (_, None | Some(Value::Null)) => None,
141        // ---- requests ----------------------------------------------
142        ("initialize", Some(id)) => {
143            *initialized = true;
144            // Negotiate the protocol: honor the client's requested
145            // revision when we support it, else return our latest so
146            // the client can decide — never claim an arbitrary version.
147            let requested = msg
148                .pointer("/params/protocolVersion")
149                .and_then(Value::as_str);
150            let version = match requested {
151                Some(v) if SUPPORTED_PROTOCOLS.contains(&v) => v,
152                _ => LATEST_PROTOCOL,
153            };
154            Some(json!({
155                "jsonrpc": "2.0",
156                "id": id,
157                "result": {
158                    "protocolVersion": version,
159                    "capabilities": { "tools": {} },
160                    "serverInfo": { "name": "mkit-repo", "version": crate::cli::CLI_VERSION },
161                    "instructions": INSTRUCTIONS,
162                }
163            }))
164        }
165        ("ping", Some(id)) => Some(json!({ "jsonrpc": "2.0", "id": id, "result": {} })),
166        // Tool traffic is rejected until the client has initialized.
167        ("tools/list" | "tools/call", Some(id)) if !*initialized => Some(json!({
168            "jsonrpc": "2.0",
169            "id": id,
170            "error": { "code": -32002, "message": "server not initialized: send `initialize` first" }
171        })),
172        ("tools/list", Some(id)) => Some(json!({
173            "jsonrpc": "2.0",
174            "id": id,
175            "result": { "tools": tool_descriptors() }
176        })),
177        ("tools/call", Some(id)) => {
178            let name = msg
179                .pointer("/params/name")
180                .and_then(Value::as_str)
181                .unwrap_or("");
182            let empty = json!({});
183            let args = msg.pointer("/params/arguments").unwrap_or(&empty);
184            match call_tool(name, args, allowed) {
185                Ok(CallOutcome { text, is_error }) => Some(json!({
186                    "jsonrpc": "2.0",
187                    "id": id,
188                    "result": {
189                        "content": [ { "type": "text", "text": text } ],
190                        "isError": is_error,
191                    }
192                })),
193                Err(protocol_err) => Some(json!({
194                    "jsonrpc": "2.0",
195                    "id": id,
196                    "error": { "code": -32602, "message": protocol_err }
197                })),
198            }
199        }
200        (_, Some(id)) => Some(json!({
201            "jsonrpc": "2.0",
202            "id": id,
203            "error": { "code": -32601, "message": format!("method not found: {method}") }
204        })),
205    }
206}
207
208const INSTRUCTIONS: &str = "Operate local mkit repositories (content-addressed VCS with \
209Ed25519-signed commits and in-toto/DSSE attestation). Every tool takes a repo_path. \
210Typical flow: mkit_init -> mkit_keygen (REQUIRED before the first commit) -> mkit_add -> \
211mkit_commit -> mkit_log/mkit_show. Differentiators: mkit_verify (check a commit/tag \
212signature), mkit_attest (attach a signed DSSE attestation), mkit_verify_attest (verify \
213attestations against trust roots), mkit_cat_object (inspect content-addressed objects). \
214This server runs no network operations (push/pull/fetch/clone), no history surgery \
215(merge/rebase/cherry-pick), and never overrides mkit's data-loss guards; a 'refuses \
216without -f' error means run that operation outside the MCP, deliberately. Path rules: an \
217attest predicate_file must resolve INSIDE the repo; a verify_attest trust_roots path must \
218resolve OUTSIDE it. For docs/specs/source of mkit itself, use the separate mkit docs MCP \
219(mcp.mkit.sh).";
220
221// ---------------------------------------------------------------------------
222// Tool table
223// ---------------------------------------------------------------------------
224
225struct ToolSpec {
226    name: &'static str,
227    description: &'static str,
228    /// (`read_only`, `destructive`, `idempotent`)
229    hints: (bool, bool, bool),
230    schema: fn() -> Value,
231}
232
233fn prop(desc: &str) -> Value {
234    json!({ "type": "string", "description": desc })
235}
236
237fn schema(props: Vec<(&str, Value)>, required: &[&str]) -> Value {
238    let mut map = serde_json::Map::new();
239    for (k, v) in props {
240        map.insert(k.to_string(), v);
241    }
242    json!({ "type": "object", "properties": Value::Object(map), "required": required })
243}
244
245fn repo_prop() -> (&'static str, Value) {
246    (
247        "repo_path",
248        prop("Path to the mkit repository (the directory containing .mkit/)"),
249    )
250}
251
252const TOOLS: &[ToolSpec] = &[
253    ToolSpec {
254        name: "mkit_status",
255        description: "Show staged and working-tree changes (porcelain v2; empty means clean).",
256        hints: (true, false, true),
257        schema: || schema(vec![repo_prop()], &["repo_path"]),
258    },
259    ToolSpec {
260        name: "mkit_diff_unstaged",
261        description: "Show changes in the working directory that are not yet staged.",
262        hints: (true, false, true),
263        schema: || schema(vec![repo_prop()], &["repo_path"]),
264    },
265    ToolSpec {
266        name: "mkit_diff_staged",
267        description: "Show changes staged for the next commit.",
268        hints: (true, false, true),
269        schema: || schema(vec![repo_prop()], &["repo_path"]),
270    },
271    ToolSpec {
272        name: "mkit_diff",
273        description: "Show the diff against a target revision (branch, tag, or 64-hex BLAKE3 id).",
274        hints: (true, false, true),
275        schema: || {
276            schema(
277                vec![repo_prop(), ("target", prop("Revision to diff against"))],
278                &["repo_path", "target"],
279            )
280        },
281    },
282    ToolSpec {
283        name: "mkit_log",
284        description: "Show commit history as JSONL (hash, author identity, timestamp, message).",
285        hints: (true, false, true),
286        schema: || {
287            schema(
288                vec![
289                    repo_prop(),
290                    (
291                        "max_count",
292                        json!({ "type": "integer", "description": "Maximum commits to show (default 10)" }),
293                    ),
294                    (
295                        "rev",
296                        prop("Optional revision (or A..B range) to start the walk from"),
297                    ),
298                ],
299                &["repo_path"],
300            )
301        },
302    },
303    ToolSpec {
304        name: "mkit_show",
305        description: "Show an object: a commit with its diff, a tag, a tree listing, or blob contents.",
306        hints: (true, false, true),
307        schema: || {
308            schema(
309                vec![
310                    repo_prop(),
311                    ("revision", prop("Revision or object id to show")),
312                ],
313                &["repo_path", "revision"],
314            )
315        },
316    },
317    ToolSpec {
318        name: "mkit_branch",
319        description: "List branches as JSONL (current branch marked).",
320        hints: (true, false, true),
321        schema: || schema(vec![repo_prop()], &["repo_path"]),
322    },
323    ToolSpec {
324        name: "mkit_cat_object",
325        description: "Inspect a content-addressed object: its type, size, or pretty-printed content.",
326        hints: (true, false, true),
327        schema: || {
328            schema(
329                vec![
330                    repo_prop(),
331                    (
332                        "object",
333                        prop("Object id (64-hex BLAKE3, prefix accepted) or revision"),
334                    ),
335                    (
336                        "mode",
337                        json!({ "type": "string", "enum": ["type", "size", "pretty"], "description": "What to show (default: pretty)" }),
338                    ),
339                ],
340                &["repo_path", "object"],
341            )
342        },
343    },
344    ToolSpec {
345        name: "mkit_verify",
346        description: "Verify the Ed25519 signature on a commit, remix, or signed tag. Pass \
347                      `trusted` (or `trust_roots`) to also cross-check the signer against the \
348                      trust-roots registry `mkit_trust_add`/`mkit trust list` manage, failing \
349                      even on a cryptographically valid signature from an unlisted key.",
350        hints: (true, false, true),
351        schema: || {
352            schema(
353                vec![
354                    repo_prop(),
355                    ("revision", prop("Revision to verify (e.g. HEAD)")),
356                    (
357                        "trusted",
358                        json!({ "type": "boolean", "description": "Cross-check the signer against the default trust-roots registry" }),
359                    ),
360                    (
361                        "trust_roots",
362                        prop(
363                            "Path to a trust-roots TOML file OUTSIDE the repo (default: \
364                             $XDG_CONFIG_HOME/mkit/trust-roots.toml). Implies trusted=true. An \
365                             in-repo path is rejected.",
366                        ),
367                    ),
368                ],
369                &["repo_path", "revision"],
370            )
371        },
372    },
373    ToolSpec {
374        name: "mkit_verify_attest",
375        description: "Verify every DSSE attestation attached to a commit against a trust-roots \
376                      registry. Defaults to the user-scoped trust-roots file; a trust_roots path \
377                      inside the repository is always rejected here (hostile-clone defense — \
378                      planted in-repo roots can never be selected through the MCP).",
379        hints: (true, false, true),
380        schema: || {
381            schema(
382                vec![
383                    repo_prop(),
384                    (
385                        "commit",
386                        prop("Commit hash to verify, or \"HEAD\" / omit for the current commit"),
387                    ),
388                    (
389                        "trust_roots",
390                        prop(
391                            "Path to a trust-roots TOML file OUTSIDE the repo (default: \
392                             $XDG_CONFIG_HOME/mkit/trust-roots.toml). An in-repo path is rejected.",
393                        ),
394                    ),
395                    (
396                        "algorithm",
397                        json!({ "type": "string", "enum": ["ed25519", "secp256k1", "p256"], "description": "Only report signatures of this algorithm" }),
398                    ),
399                ],
400                &["repo_path"],
401            )
402        },
403    },
404    ToolSpec {
405        name: "mkit_add",
406        description: "Stage files for the next commit. Pass explicit paths (\".\" stages everything \
407                      non-ignored under the repo root).",
408        hints: (false, false, true),
409        schema: || {
410            schema(
411                vec![
412                    repo_prop(),
413                    (
414                        "files",
415                        json!({ "type": "array", "items": { "type": "string" }, "description": "Paths to stage" }),
416                    ),
417                ],
418                &["repo_path", "files"],
419            )
420        },
421    },
422    ToolSpec {
423        name: "mkit_unstage",
424        description: "Unstage changes: with files, restores those index entries from HEAD; without, \
425                      unstages everything (mixed reset). Never touches the working tree.",
426        hints: (false, true, true),
427        schema: || {
428            schema(
429                vec![
430                    repo_prop(),
431                    (
432                        "files",
433                        json!({ "type": "array", "items": { "type": "string" }, "description": "Paths to unstage (omit to unstage all)" }),
434                    ),
435                ],
436                &["repo_path"],
437            )
438        },
439    },
440    ToolSpec {
441        name: "mkit_commit",
442        description: "Create an Ed25519-signed commit from the staging index. Requires a signing \
443                      key (mkit_keygen) — commits are always signed.",
444        hints: (false, false, false),
445        schema: || {
446            schema(
447                vec![repo_prop(), ("message", prop("Commit message"))],
448                &["repo_path", "message"],
449            )
450        },
451    },
452    ToolSpec {
453        name: "mkit_create_branch",
454        description: "Create a new branch at HEAD.",
455        hints: (false, false, false),
456        schema: || {
457            schema(
458                vec![repo_prop(), ("branch_name", prop("Name of the new branch"))],
459                &["repo_path", "branch_name"],
460            )
461        },
462    },
463    ToolSpec {
464        name: "mkit_checkout",
465        description: "Switch HEAD to a branch and restore files. Overwrites clean tracked files \
466                      and removes tracked paths absent from the target branch (dirty-worktree \
467                      changes are guarded and refuse instead).",
468        hints: (false, true, false),
469        schema: || {
470            schema(
471                vec![repo_prop(), ("branch_name", prop("Branch to switch to"))],
472                &["repo_path", "branch_name"],
473            )
474        },
475    },
476    ToolSpec {
477        name: "mkit_init",
478        description: "Create a new mkit repository (.mkit/) in repo_path. Run mkit_keygen next — \
479                      commits require a signing key.",
480        hints: (false, false, false),
481        schema: || schema(vec![repo_prop()], &["repo_path"]),
482    },
483    ToolSpec {
484        name: "mkit_keygen",
485        description: "Generate a signing key. Default (ed25519) writes the commit-signing key at \
486                      .mkit/keys/default.key; secp256k1/p256 write separate ATTESTATION signer keys \
487                      (.mkit/keys/<alg>.key) for use with mkit_attest. Refuses to overwrite.",
488        hints: (false, false, false),
489        schema: || {
490            schema(
491                vec![
492                    repo_prop(),
493                    (
494                        "algorithm",
495                        json!({ "type": "string", "enum": ["ed25519", "secp256k1", "p256"], "description": "Key algorithm (default: ed25519 = the commit key)" }),
496                    ),
497                    (
498                        "print_pubkey",
499                        json!({ "type": "boolean", "description": "Also print the public key" }),
500                    ),
501                ],
502                &["repo_path"],
503            )
504        },
505    },
506    ToolSpec {
507        name: "mkit_attest",
508        description: "Produce a signed DSSE attestation (in-toto v1 Statement) for a commit. \
509                      Prints the att-id and stores the envelope under .mkit/attestations/. \
510                      (Multi-signer envelopes and external-signer argv are intentionally NOT \
511                      exposed here — they can direct subprocess execution; use the `mkit attest` \
512                      CLI for that advanced flow.)",
513        hints: (false, false, false),
514        schema: || {
515            schema(
516                vec![
517                    repo_prop(),
518                    (
519                        "commit",
520                        prop("Commit hash to attest, or \"HEAD\" / omit for the current commit"),
521                    ),
522                    (
523                        "algorithm",
524                        json!({ "type": "string", "enum": ["ed25519", "secp256k1", "p256"], "description": "Signing algorithm (default: ed25519, always passed explicitly — user config cannot reroute the algorithm through the MCP). Non-ed25519 needs the matching mkit_keygen key." }),
525                    ),
526                    (
527                        "signer",
528                        json!({ "type": "string", "enum": ["repo-key", "keystore"], "description": "Primary signer (default: repo-key, always passed explicitly — user config cannot reroute to an external signer through the MCP)." }),
529                    ),
530                    (
531                        "predicate_type",
532                        prop("Predicate-type URI written into the Statement"),
533                    ),
534                    (
535                        "predicate_file",
536                        prop(
537                            "Path to a JSON predicate file INSIDE the repo (an outside path is rejected)",
538                        ),
539                    ),
540                ],
541                &["repo_path"],
542            )
543        },
544    },
545];
546
547fn tool_descriptors() -> Value {
548    Value::Array(
549        TOOLS
550            .iter()
551            .map(|t| {
552                let (read_only, destructive, idempotent) = t.hints;
553                json!({
554                    "name": t.name,
555                    "description": t.description,
556                    "inputSchema": (t.schema)(),
557                    "annotations": {
558                        "readOnlyHint": read_only,
559                        "destructiveHint": destructive,
560                        "idempotentHint": idempotent,
561                        "openWorldHint": false,
562                    },
563                })
564            })
565            .collect(),
566    )
567}
568
569// ---------------------------------------------------------------------------
570// Tool execution
571// ---------------------------------------------------------------------------
572
573struct CallOutcome {
574    text: String,
575    is_error: bool,
576}
577
578impl CallOutcome {
579    fn err(text: impl Into<String>) -> Self {
580        Self {
581            text: text.into(),
582            is_error: true,
583        }
584    }
585}
586
587/// `Err(_)` is a protocol-level error (unknown tool); per-call
588/// validation and execution failures come back as `Ok` with
589/// `is_error: true` so the agent sees an explanatory message.
590fn call_tool(name: &str, args: &Value, allowed: Option<&Path>) -> Result<CallOutcome, String> {
591    if !TOOLS.iter().any(|t| t.name == name) {
592        return Err(format!("unknown tool: {name}"));
593    }
594
595    let Some(repo_raw) = args.get("repo_path").and_then(Value::as_str) else {
596        return Ok(CallOutcome::err("missing required argument: repo_path"));
597    };
598    let repo = match validate_repo_path(repo_raw, allowed) {
599        Ok(p) => p,
600        Err(e) => return Ok(CallOutcome::err(e)),
601    };
602
603    // Confine path-typed arguments relative to the repo. `--repository`
604    // only constrains repo_path; predicate/trust-roots paths reach the
605    // child CLI directly, so the MCP must hold the boundary itself.
606    if let Err(e) = confine_path_args(name, args, &repo) {
607        return Ok(CallOutcome::err(e));
608    }
609
610    let command = match build_argv(name, args) {
611        Ok(a) => a,
612        Err(e) => return Ok(CallOutcome::err(e)),
613    };
614
615    Ok(run_subprocess(&repo, &command))
616}
617
618/// Enforce containment of the file-path arguments the child CLI opens
619/// itself (so `--repository` scoping can't be bypassed through them):
620///
621/// * `predicate_file` (attest) is *repo data* — it must resolve INSIDE
622///   the repo, so a prompt-injected agent can't slurp an outside file
623///   into a signed attestation.
624/// * `trust_roots` (verify-attest, verify) is *external authority* — it
625///   must resolve OUTSIDE the repo, so a hostile clone's planted
626///   `.mkit/trust-roots.toml` can never be selected via the MCP
627///   (the CLI's "explicit --trust-roots = user intent" gate assumes a
628///   user, but here the value can come from repo-controlled prompt text;
629///   see docs/THREAT-MODEL.md §"Trust-roots scope").
630fn confine_path_args(name: &str, args: &Value, repo: &Path) -> Result<(), String> {
631    match name {
632        "mkit_attest" => {
633            if let Some(f) = opt_str(args, "predicate_file") {
634                confine_path(repo, &f, Containment::Inside, "predicate_file")?;
635            }
636        }
637        "mkit_verify_attest" | "mkit_verify" => {
638            if let Some(f) = opt_str(args, "trust_roots") {
639                confine_path(repo, &f, Containment::Outside, "trust_roots")?;
640            }
641        }
642        _ => {}
643    }
644    Ok(())
645}
646
647#[derive(Clone, Copy)]
648enum Containment {
649    Inside,
650    Outside,
651}
652
653/// Resolve `raw` the way the child CLI will (relative to the repo cwd,
654/// or as an absolute path) and require it to be inside / outside the
655/// repo. The target must exist (the CLI reads it), so canonicalize is
656/// the source of truth for both existence and symlink resolution.
657fn confine_path(repo: &Path, raw: &str, want: Containment, what: &str) -> Result<(), String> {
658    let candidate = if Path::new(raw).is_absolute() {
659        PathBuf::from(raw)
660    } else {
661        repo.join(raw)
662    };
663    let resolved = candidate
664        .canonicalize()
665        .map_err(|e| format!("invalid {what} '{raw}': {e}"))?;
666    let within = resolved.starts_with(repo);
667    match want {
668        Containment::Inside if !within => Err(format!(
669            "{what} '{raw}' is outside the repository; predicate files must live in the repo"
670        )),
671        Containment::Outside if within => Err(format!(
672            "{what} '{raw}' is inside the repository; trust-roots must be a user-controlled file \
673             outside the repo (hostile-clone defense — see docs/THREAT-MODEL.md)"
674        )),
675        _ => Ok(()),
676    }
677}
678
679/// Resolve and (when scoped) confine `repo_path`.
680fn validate_repo_path(raw: &str, allowed: Option<&Path>) -> Result<PathBuf, String> {
681    let resolved = PathBuf::from(raw)
682        .canonicalize()
683        .map_err(|e| format!("invalid repo_path '{raw}': {e}"))?;
684    if let Some(root) = allowed
685        && !resolved.starts_with(root)
686    {
687        return Err(format!(
688            "repo_path '{raw}' is outside the allowed repository '{}'",
689            root.display()
690        ));
691    }
692    if !resolved.is_dir() {
693        return Err(format!("repo_path '{raw}' is not a directory"));
694    }
695    Ok(resolved)
696}
697
698/// Reject values that the child CLI could parse as a flag. mkit's
699/// `add` has no `--` separator, so this is the containment line.
700fn no_dash(value: &str, what: &str) -> Result<(), String> {
701    if value.starts_with('-') {
702        return Err(format!("invalid {what} '{value}': must not start with '-'"));
703    }
704    if value.is_empty() {
705        return Err(format!("invalid {what}: must not be empty"));
706    }
707    Ok(())
708}
709
710fn req_str(args: &Value, key: &str) -> Result<String, String> {
711    args.get(key)
712        .and_then(Value::as_str)
713        .map(str::to_owned)
714        .ok_or_else(|| format!("missing required argument: {key}"))
715}
716
717fn opt_str(args: &Value, key: &str) -> Option<String> {
718    args.get(key).and_then(Value::as_str).map(str::to_owned)
719}
720
721/// Push `--commit <hash>` unless the value is "HEAD" (any case) or
722/// absent — the CLI's `--commit` parses a hex hash and rejects "HEAD",
723/// but defaults to HEAD when the flag is omitted, so map the common
724/// agent shorthand onto that default.
725fn push_commit(out: &mut Vec<String>, args: &Value) -> Result<(), String> {
726    if let Some(commit) = opt_str(args, "commit")
727        && !commit.eq_ignore_ascii_case("HEAD")
728    {
729        no_dash(&commit, "commit")?;
730        out.extend(["--commit".into(), commit]);
731    }
732    Ok(())
733}
734
735/// Validate and push `--algorithm <alg>` when present.
736fn push_algorithm(out: &mut Vec<String>, args: &Value) -> Result<(), String> {
737    if let Some(alg) = opt_str(args, "algorithm") {
738        if !matches!(alg.as_str(), "ed25519" | "secp256k1" | "p256") {
739            return Err(format!(
740                "invalid algorithm '{alg}': expected ed25519, secp256k1, or p256"
741            ));
742        }
743        out.extend(["--algorithm".into(), alg]);
744    }
745    Ok(())
746}
747
748/// Map a tool invocation to a child argv. Every push of a
749/// user-controlled value is preceded by a `no_dash` check unless the
750/// value follows a long flag that takes it as an unambiguous operand.
751/// One arm per tool — long but flat, like the dispatcher in `lib.rs`
752/// (same precedent as `serve.rs` for the line-count allowance).
753#[allow(clippy::too_many_lines)]
754fn build_argv(name: &str, args: &Value) -> Result<Vec<String>, String> {
755    let mut out: Vec<String> = Vec::new();
756    match name {
757        "mkit_status" => out.extend(["status".into(), "--porcelain=v2".into()]),
758        "mkit_diff_unstaged" => out.push("diff".into()),
759        "mkit_diff_staged" => out.extend(["diff".into(), "--staged".into()]),
760        "mkit_diff" => {
761            let target = req_str(args, "target")?;
762            no_dash(&target, "target")?;
763            out.extend(["diff".into(), target]);
764        }
765        "mkit_log" => {
766            out.extend(["log".into(), "--format=json".into(), "-n".into()]);
767            let n = args.get("max_count").and_then(Value::as_u64).unwrap_or(10);
768            out.push(n.to_string());
769            if let Some(rev) = opt_str(args, "rev") {
770                no_dash(&rev, "rev")?;
771                out.push(rev);
772            }
773        }
774        "mkit_show" => {
775            let rev = req_str(args, "revision")?;
776            no_dash(&rev, "revision")?;
777            out.extend(["show".into(), rev]);
778        }
779        "mkit_branch" => out.extend(["branch".into(), "--format=json".into()]),
780        "mkit_cat_object" => {
781            let object = req_str(args, "object")?;
782            no_dash(&object, "object")?;
783            let flag = match opt_str(args, "mode").as_deref() {
784                None | Some("pretty") => "-p",
785                Some("type") => "-t",
786                Some("size") => "-s",
787                Some(other) => {
788                    return Err(format!(
789                        "invalid mode '{other}': expected type, size, or pretty"
790                    ));
791                }
792            };
793            out.extend(["cat-file".into(), flag.into(), object]);
794        }
795        "mkit_verify" => {
796            let rev = req_str(args, "revision")?;
797            no_dash(&rev, "revision")?;
798            out.extend(["verify".into(), rev]);
799            if args.get("trusted").and_then(Value::as_bool) == Some(true) {
800                out.push("--trusted".into());
801            }
802            if let Some(roots) = opt_str(args, "trust_roots") {
803                no_dash(&roots, "trust_roots")?;
804                out.extend(["--trust-roots".into(), roots]);
805            }
806        }
807        "mkit_verify_attest" => {
808            out.push("verify-attest".into());
809            push_commit(&mut out, args)?;
810            if let Some(roots) = opt_str(args, "trust_roots") {
811                no_dash(&roots, "trust_roots")?;
812                out.extend(["--trust-roots".into(), roots]);
813            }
814            push_algorithm(&mut out, args)?;
815        }
816        "mkit_add" => {
817            out.push("add".into());
818            let files = args
819                .get("files")
820                .and_then(Value::as_array)
821                .ok_or("missing required argument: files")?;
822            if files.is_empty() {
823                return Err("files must not be empty".into());
824            }
825            for f in files {
826                let f = f.as_str().ok_or("files entries must be strings")?;
827                no_dash(f, "file path")?;
828                out.push(f.into());
829            }
830        }
831        "mkit_unstage" => {
832            match args.get("files") {
833                // Key absent = the documented "unstage everything" form:
834                // bare `reset` (mixed) — the working tree is never touched.
835                None => out.push("reset".into()),
836                // Key present: it must be a non-empty array of strings. A
837                // malformed value (string, empty array, …) must NOT silently
838                // widen a targeted unstage into a whole-index mutation.
839                Some(Value::Array(list)) if !list.is_empty() => {
840                    out.extend(["restore".into(), "--staged".into()]);
841                    for f in list {
842                        let f = f.as_str().ok_or("files entries must be strings")?;
843                        no_dash(f, "file path")?;
844                        out.push(f.into());
845                    }
846                }
847                Some(_) => {
848                    return Err(
849                        "files must be a non-empty array of paths; omit it entirely to \
850                         unstage everything"
851                            .into(),
852                    );
853                }
854            }
855        }
856        "mkit_commit" => {
857            let message = req_str(args, "message")?;
858            if message.trim().is_empty() {
859                return Err("message must not be empty".into());
860            }
861            out.extend(["commit".into(), "-m".into(), message]);
862        }
863        "mkit_create_branch" => {
864            let branch = req_str(args, "branch_name")?;
865            no_dash(&branch, "branch_name")?;
866            out.extend(["branch".into(), branch]);
867        }
868        "mkit_checkout" => {
869            let branch = req_str(args, "branch_name")?;
870            no_dash(&branch, "branch_name")?;
871            out.extend(["checkout".into(), branch]);
872        }
873        "mkit_init" => out.push("init".into()),
874        "mkit_keygen" => {
875            out.push("keygen".into());
876            push_algorithm(&mut out, args)?;
877            if args.get("print_pubkey").and_then(Value::as_bool) == Some(true) {
878                out.push("--print-pubkey".into());
879            }
880        }
881        "mkit_attest" => {
882            out.push("attest".into());
883            push_commit(&mut out, args)?;
884            // ALWAYS pass --algorithm explicitly: when absent the child CLI
885            // falls back to user-scoped `attest.default_algorithm`, which
886            // config.rs documents as a security-sensitive selector — ambient
887            // config must not steer an agent-triggered signing operation.
888            let alg = opt_str(args, "algorithm").unwrap_or_else(|| "ed25519".into());
889            if !matches!(alg.as_str(), "ed25519" | "secp256k1" | "p256") {
890                return Err(format!(
891                    "invalid algorithm '{alg}': expected ed25519, secp256k1, or p256"
892                ));
893            }
894            out.extend(["--algorithm".into(), alg]);
895            // ALWAYS pass --signer explicitly: when the flag is absent the
896            // child CLI falls back to user-scoped `attest.signer` config,
897            // which may name `external` — and the external-signer path is
898            // excluded from the MCP (it executes a configured subprocess).
899            let signer = opt_str(args, "signer").unwrap_or_else(|| "repo-key".into());
900            if !matches!(signer.as_str(), "repo-key" | "keystore") {
901                return Err(format!(
902                    "invalid signer '{signer}': expected repo-key or keystore \
903                     (external is excluded from the MCP)"
904                ));
905            }
906            out.extend(["--signer".into(), signer]);
907            if let Some(uri) = opt_str(args, "predicate_type") {
908                no_dash(&uri, "predicate_type")?;
909                out.extend(["--predicate-type".into(), uri]);
910            }
911            if let Some(file) = opt_str(args, "predicate_file") {
912                no_dash(&file, "predicate_file")?;
913                out.extend(["--predicate-file".into(), file]);
914            }
915        }
916        other => return Err(format!("unknown tool: {other}")),
917    }
918    Ok(out)
919}
920
921/// Run `mkit <argv>` in `repo`, capturing everything. The child is
922/// always this same binary, so server and CLI can never skew.
923fn run_subprocess(repo: &Path, argv: &[String]) -> CallOutcome {
924    let exe = match std::env::current_exe() {
925        Ok(p) => p,
926        Err(e) => return CallOutcome::err(format!("cannot locate mkit binary: {e}")),
927    };
928    let output = std::process::Command::new(exe)
929        .args(argv)
930        .current_dir(repo)
931        // Deterministic, capture-friendly child environment: no ANSI,
932        // and no editor fallback (commit always receives -m here, but
933        // belt-and-braces against any future interactive path).
934        .env("NO_COLOR", "1")
935        .env_remove("CLICOLOR_FORCE")
936        .env_remove("EDITOR")
937        .env_remove("VISUAL")
938        .output();
939    let output = match output {
940        Ok(o) => o,
941        Err(e) => return CallOutcome::err(format!("failed to run mkit {}: {e}", argv.join(" "))),
942    };
943
944    let stdout = String::from_utf8_lossy(&output.stdout);
945    let stderr = String::from_utf8_lossy(&output.stderr);
946    let code = output.status.code().unwrap_or(-1);
947
948    if output.status.success() {
949        let mut text = stdout.trim_end().to_string();
950        if text.is_empty() {
951            // Several mkit commands put confirmation prose on stderr
952            // and reserve stdout for machine output; surface it.
953            text = stderr.trim_end().to_string();
954        }
955        if text.is_empty() {
956            text = "(ok — no output)".into();
957        }
958        CallOutcome {
959            text,
960            is_error: false,
961        }
962    } else {
963        let mut text = format!("error: mkit exited {code} ({})", sysexits_name(code));
964        if !stderr.trim().is_empty() {
965            text.push('\n');
966            text.push_str(stderr.trim_end());
967        }
968        if !stdout.trim().is_empty() {
969            text.push('\n');
970            text.push_str(stdout.trim_end());
971        }
972        CallOutcome {
973            text,
974            is_error: true,
975        }
976    }
977}
978
979/// Human label for the BSD sysexits codes documented in docs/CLI.md.
980fn sysexits_name(code: i32) -> &'static str {
981    match code {
982        0 => "ok",
983        1 => "general error",
984        64 => "usage: wrong args or unknown subcommand",
985        65 => "dataerr: malformed input",
986        66 => "noinput: missing or unreadable input",
987        69 => "unavailable: transport could not connect",
988        73 => "cantcreat: cannot create output",
989        75 => "tempfail: transient failure, retry is safe",
990        76 => "protocol error",
991        77 => "noperm: permission denied",
992        78 => "config error",
993        _ => "unknown",
994    }
995}
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000
1001    #[test]
1002    fn tool_table_is_complete_and_annotated() {
1003        let tools = tool_descriptors();
1004        let arr = tools.as_array().unwrap();
1005        assert_eq!(arr.len(), 18, "tool count is part of the public surface");
1006        for t in arr {
1007            assert!(t.get("name").is_some());
1008            assert!(t.get("description").is_some());
1009            assert_eq!(t.pointer("/inputSchema/type").unwrap(), "object");
1010            // Every tool is local-only.
1011            assert_eq!(t.pointer("/annotations/openWorldHint").unwrap(), false);
1012            // repo_path is universal.
1013            assert!(t.pointer("/inputSchema/properties/repo_path").is_some());
1014        }
1015    }
1016
1017    #[test]
1018    fn read_only_tools_are_marked() {
1019        let tools = tool_descriptors();
1020        for t in tools.as_array().unwrap() {
1021            let name = t.get("name").unwrap().as_str().unwrap();
1022            let ro = t
1023                .pointer("/annotations/readOnlyHint")
1024                .unwrap()
1025                .as_bool()
1026                .unwrap();
1027            let expect_ro = matches!(
1028                name,
1029                "mkit_status"
1030                    | "mkit_diff_unstaged"
1031                    | "mkit_diff_staged"
1032                    | "mkit_diff"
1033                    | "mkit_log"
1034                    | "mkit_show"
1035                    | "mkit_branch"
1036                    | "mkit_cat_object"
1037                    | "mkit_verify"
1038                    | "mkit_verify_attest"
1039            );
1040            assert_eq!(ro, expect_ro, "readOnlyHint wrong for {name}");
1041        }
1042    }
1043
1044    #[test]
1045    fn argv_construction_basics() {
1046        let argv = build_argv("mkit_status", &json!({})).unwrap();
1047        assert_eq!(argv, ["status", "--porcelain=v2"]);
1048
1049        let argv = build_argv("mkit_commit", &json!({ "message": "hello world" })).unwrap();
1050        assert_eq!(argv, ["commit", "-m", "hello world"]);
1051
1052        let argv = build_argv("mkit_add", &json!({ "files": ["a.txt", "src/b.rs"] })).unwrap();
1053        assert_eq!(argv, ["add", "a.txt", "src/b.rs"]);
1054    }
1055
1056    #[test]
1057    fn flag_injection_is_rejected() {
1058        for (tool, args) in [
1059            ("mkit_diff", json!({ "target": "-R" })),
1060            ("mkit_show", json!({ "revision": "--help" })),
1061            ("mkit_add", json!({ "files": ["-A"] })),
1062            ("mkit_checkout", json!({ "branch_name": "-b" })),
1063            ("mkit_create_branch", json!({ "branch_name": "-D" })),
1064            ("mkit_cat_object", json!({ "object": "--batch" })),
1065            ("mkit_log", json!({ "rev": "--graph" })),
1066            ("mkit_attest", json!({ "predicate_file": "--force" })),
1067        ] {
1068            let err = build_argv(tool, &args).unwrap_err();
1069            assert!(err.contains("must not start with '-'"), "{tool}: {err}");
1070        }
1071    }
1072
1073    #[test]
1074    fn unstage_maps_to_restore_or_reset() {
1075        let argv = build_argv("mkit_unstage", &json!({})).unwrap();
1076        assert_eq!(argv, ["reset"]);
1077        let argv = build_argv("mkit_unstage", &json!({ "files": ["a.txt"] })).unwrap();
1078        assert_eq!(argv, ["restore", "--staged", "a.txt"]);
1079    }
1080
1081    #[test]
1082    fn unstage_rejects_malformed_files_instead_of_widening() {
1083        // A present-but-malformed `files` must error, never silently
1084        // broaden a targeted unstage into a whole-index reset.
1085        for bad in [
1086            json!({ "files": "a.txt" }),
1087            json!({ "files": [] }),
1088            json!({ "files": 3 }),
1089        ] {
1090            let err = build_argv("mkit_unstage", &bad).unwrap_err();
1091            assert!(err.contains("non-empty array"), "{bad}: {err}");
1092        }
1093    }
1094
1095    #[test]
1096    fn attest_always_pins_the_signer() {
1097        // Omitted signer must still emit --signer repo-key so user config
1098        // (`attest.signer = external`) can never reroute an MCP-triggered
1099        // attestation into an external-signer subprocess.
1100        let argv = build_argv("mkit_attest", &json!({})).unwrap();
1101        assert_eq!(
1102            argv,
1103            ["attest", "--algorithm", "ed25519", "--signer", "repo-key"]
1104        );
1105        let argv = build_argv("mkit_attest", &json!({ "signer": "keystore" })).unwrap();
1106        assert_eq!(
1107            argv,
1108            ["attest", "--algorithm", "ed25519", "--signer", "keystore"]
1109        );
1110        let argv = build_argv("mkit_attest", &json!({ "algorithm": "p256" })).unwrap();
1111        assert_eq!(
1112            argv,
1113            ["attest", "--algorithm", "p256", "--signer", "repo-key"]
1114        );
1115        let err = build_argv("mkit_attest", &json!({ "signer": "external" })).unwrap_err();
1116        assert!(err.contains("excluded"), "{err}");
1117    }
1118
1119    #[test]
1120    fn checkout_is_marked_destructive() {
1121        // Checkout rewrites tracked worktree files; clients use
1122        // destructiveHint to decide whether to confirm.
1123        let tools = tool_descriptors();
1124        let checkout = tools
1125            .as_array()
1126            .unwrap()
1127            .iter()
1128            .find(|t| t.get("name").unwrap() == "mkit_checkout")
1129            .unwrap();
1130        assert_eq!(
1131            checkout.pointer("/annotations/destructiveHint").unwrap(),
1132            true
1133        );
1134    }
1135
1136    #[test]
1137    fn no_force_flag_ever_emitted() {
1138        // The server must never override mkit's data-loss guards.
1139        for spec in TOOLS {
1140            let args = json!({
1141                "repo_path": "/tmp", "target": "x", "revision": "x", "object": "x",
1142                "message": "m", "branch_name": "b", "files": ["f"],
1143                "commit": "c", "predicate_type": "t", "predicate_file": "p",
1144            });
1145            if let Ok(argv) = build_argv(spec.name, &args) {
1146                assert!(
1147                    !argv.iter().any(|a| a == "-f" || a == "--force"),
1148                    "{} emits a force flag",
1149                    spec.name
1150                );
1151            }
1152        }
1153    }
1154
1155    #[test]
1156    fn scope_validation_rejects_outside_paths() {
1157        let root = tempfile::tempdir().unwrap();
1158        let outside = tempfile::tempdir().unwrap();
1159        let allowed = root.path().canonicalize().unwrap();
1160
1161        assert!(validate_repo_path(root.path().to_str().unwrap(), Some(&allowed)).is_ok());
1162        let err = validate_repo_path(outside.path().to_str().unwrap(), Some(&allowed)).unwrap_err();
1163        assert!(err.contains("outside the allowed repository"));
1164        // Unscoped mode allows anything that exists.
1165        assert!(validate_repo_path(outside.path().to_str().unwrap(), None).is_ok());
1166    }
1167
1168    #[test]
1169    fn initialize_negotiates_protocol_and_lists_tools() {
1170        let mut init_state = false;
1171
1172        // Tool traffic before initialize is rejected (-32002).
1173        let early = json!({ "jsonrpc": "2.0", "id": 0, "method": "tools/list" });
1174        let resp = handle_message(&early, None, &mut init_state).unwrap();
1175        assert_eq!(resp.pointer("/error/code").unwrap(), -32002);
1176        assert!(!init_state);
1177
1178        // A supported protocol is echoed back.
1179        let init = json!({
1180            "jsonrpc": "2.0", "id": 1, "method": "initialize",
1181            "params": { "protocolVersion": "2024-11-05", "capabilities": {} }
1182        });
1183        let resp = handle_message(&init, None, &mut init_state).unwrap();
1184        assert_eq!(
1185            resp.pointer("/result/protocolVersion").unwrap(),
1186            "2024-11-05"
1187        );
1188        assert_eq!(
1189            resp.pointer("/result/serverInfo/name").unwrap(),
1190            "mkit-repo"
1191        );
1192        assert!(resp.pointer("/result/instructions").is_some());
1193        assert!(init_state);
1194
1195        // An UNsupported protocol falls back to our latest, never echoed.
1196        let mut s2 = false;
1197        let bad = json!({
1198            "jsonrpc": "2.0", "id": 9, "method": "initialize",
1199            "params": { "protocolVersion": "1900-01-01" }
1200        });
1201        let resp = handle_message(&bad, None, &mut s2).unwrap();
1202        assert_eq!(
1203            resp.pointer("/result/protocolVersion").unwrap(),
1204            LATEST_PROTOCOL
1205        );
1206
1207        // After initialize, tools/list works.
1208        let list = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" });
1209        let resp = handle_message(&list, None, &mut init_state).unwrap();
1210        assert_eq!(
1211            resp.pointer("/result/tools")
1212                .unwrap()
1213                .as_array()
1214                .unwrap()
1215                .len(),
1216            18
1217        );
1218
1219        // Notifications produce no response.
1220        let note = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
1221        assert!(handle_message(&note, None, &mut init_state).is_none());
1222
1223        // Unknown methods error.
1224        let bogus = json!({ "jsonrpc": "2.0", "id": 3, "method": "resources/list" });
1225        let resp = handle_message(&bogus, None, &mut init_state).unwrap();
1226        assert_eq!(resp.pointer("/error/code").unwrap(), -32601);
1227    }
1228}