1#[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 #[arg(long, short = 'r', value_name = "PATH")]
48 repository: Option<PathBuf>,
49 #[cfg(feature = "mcp-v2")]
62 #[arg(long, value_name = "ADDR")]
63 http: Option<String>,
64 #[cfg(feature = "mcp-v2")]
73 #[arg(long, value_name = "TOKEN")]
74 http_token: Option<String>,
75 #[cfg(feature = "mcp-v2")]
80 #[arg(long, default_value_t = false)]
81 unsafe_allow_any_http_peer: bool,
82}
83
84#[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#[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#[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 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 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#[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 if let Ok(s) = serde_json::to_string(msg) {
189 let _ = writeln!(stdout, "{s}");
190 let _ = stdout.flush();
191 }
192}
193
194#[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 (_, None | Some(Value::Null)) => None,
204 ("initialize", Some(id)) => {
206 *initialized = true;
207 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 ("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
285pub(crate) struct ToolSpec {
294 pub(crate) name: &'static str,
295 pub(crate) description: &'static str,
296 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
751pub(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
769pub(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 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
804fn 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
839fn 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
865fn 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
884fn 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
907fn 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
921fn 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#[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 None => out.push("reset".into()),
1083 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 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 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
1168fn 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 .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 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
1226fn 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 assert_eq!(t.pointer("/annotations/openWorldHint").unwrap(), false);
1260 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 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 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 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 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 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 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 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 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 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 let note = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
1473 assert!(handle_message(¬e, None, &mut init_state).is_none());
1474
1475 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}