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