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