use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
use clap::Parser;
use serde_json::{Value, json};
use crate::clap_shim;
use crate::exit;
#[derive(Debug, Parser)]
struct McpOpts {
#[arg(long, short = 'r', value_name = "PATH")]
repository: Option<PathBuf>,
}
#[must_use]
pub fn run(args: &[String]) -> u8 {
let opts = match clap_shim::parse::<McpOpts>("mkit mcp", args) {
Ok(o) => o,
Err(code) => return code,
};
let allowed = match &opts.repository {
Some(p) => match p.canonicalize() {
Ok(c) => Some(c),
Err(e) => {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(stderr, "error: --repository {}: {e}", p.display());
return exit::NOINPUT;
}
},
None => None,
};
serve(allowed.as_deref())
}
fn serve(allowed: Option<&Path>) -> u8 {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout().lock();
let mut initialized = false;
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let parsed: Result<Value, _> = serde_json::from_str(&line);
let (messages, is_batch): (Vec<Value>, bool) = match parsed {
Ok(Value::Array(batch)) => (batch, true),
Ok(v) => (vec![v], false),
Err(_) => {
write_msg(
&mut stdout,
&json!({
"jsonrpc": "2.0",
"id": null,
"error": { "code": -32700, "message": "parse error" }
}),
);
continue;
}
};
let responses: Vec<Value> = messages
.iter()
.filter_map(|msg| handle_message(msg, allowed, &mut initialized))
.collect();
if is_batch {
if !responses.is_empty() {
write_msg(&mut stdout, &Value::Array(responses));
}
} else if let Some(response) = responses.into_iter().next() {
write_msg(&mut stdout, &response);
}
}
exit::OK
}
const SUPPORTED_PROTOCOLS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
const LATEST_PROTOCOL: &str = "2025-06-18";
fn write_msg(stdout: &mut impl Write, msg: &Value) {
if let Ok(s) = serde_json::to_string(msg) {
let _ = writeln!(stdout, "{s}");
let _ = stdout.flush();
}
}
fn handle_message(msg: &Value, allowed: Option<&Path>, initialized: &mut bool) -> Option<Value> {
let method = msg.get("method").and_then(Value::as_str)?;
let id = msg.get("id");
match (method, id) {
(_, None | Some(Value::Null)) => None,
("initialize", Some(id)) => {
*initialized = true;
let requested = msg
.pointer("/params/protocolVersion")
.and_then(Value::as_str);
let version = match requested {
Some(v) if SUPPORTED_PROTOCOLS.contains(&v) => v,
_ => LATEST_PROTOCOL,
};
Some(json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"protocolVersion": version,
"capabilities": { "tools": {} },
"serverInfo": { "name": "mkit-repo", "version": crate::cli::CLI_VERSION },
"instructions": INSTRUCTIONS,
}
}))
}
("ping", Some(id)) => Some(json!({ "jsonrpc": "2.0", "id": id, "result": {} })),
("tools/list" | "tools/call", Some(id)) if !*initialized => Some(json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": -32002, "message": "server not initialized: send `initialize` first" }
})),
("tools/list", Some(id)) => Some(json!({
"jsonrpc": "2.0",
"id": id,
"result": { "tools": tool_descriptors() }
})),
("tools/call", Some(id)) => {
let name = msg
.pointer("/params/name")
.and_then(Value::as_str)
.unwrap_or("");
let empty = json!({});
let args = msg.pointer("/params/arguments").unwrap_or(&empty);
match call_tool(name, args, allowed) {
Ok(CallOutcome { text, is_error }) => Some(json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [ { "type": "text", "text": text } ],
"isError": is_error,
}
})),
Err(protocol_err) => Some(json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": -32602, "message": protocol_err }
})),
}
}
(_, Some(id)) => Some(json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": -32601, "message": format!("method not found: {method}") }
})),
}
}
const INSTRUCTIONS: &str = "Operate local mkit repositories (content-addressed VCS with \
Ed25519-signed commits and in-toto/DSSE attestation). Every tool takes a repo_path. \
Typical flow: mkit_init -> mkit_keygen (REQUIRED before the first commit) -> mkit_add -> \
mkit_commit -> mkit_log/mkit_show. Differentiators: mkit_verify (check a commit/tag \
signature), mkit_attest (attach a signed DSSE attestation), mkit_verify_attest (verify \
attestations against trust roots), mkit_cat_object (inspect content-addressed objects). \
This server runs no network operations (push/pull/fetch/clone), no history surgery \
(merge/rebase/cherry-pick), and never overrides mkit's data-loss guards; a 'refuses \
without -f' error means run that operation outside the MCP, deliberately. Path rules: an \
attest predicate_file must resolve INSIDE the repo; a verify_attest trust_roots path must \
resolve OUTSIDE it. For docs/specs/source of mkit itself, use the separate mkit docs MCP \
(mcp.mkit.sh).";
struct ToolSpec {
name: &'static str,
description: &'static str,
hints: (bool, bool, bool),
schema: fn() -> Value,
}
fn prop(desc: &str) -> Value {
json!({ "type": "string", "description": desc })
}
fn schema(props: Vec<(&str, Value)>, required: &[&str]) -> Value {
let mut map = serde_json::Map::new();
for (k, v) in props {
map.insert(k.to_string(), v);
}
json!({ "type": "object", "properties": Value::Object(map), "required": required })
}
fn repo_prop() -> (&'static str, Value) {
(
"repo_path",
prop("Path to the mkit repository (the directory containing .mkit/)"),
)
}
const TOOLS: &[ToolSpec] = &[
ToolSpec {
name: "mkit_status",
description: "Show staged and working-tree changes (porcelain v2; empty means clean).",
hints: (true, false, true),
schema: || schema(vec![repo_prop()], &["repo_path"]),
},
ToolSpec {
name: "mkit_diff_unstaged",
description: "Show changes in the working directory that are not yet staged.",
hints: (true, false, true),
schema: || schema(vec![repo_prop()], &["repo_path"]),
},
ToolSpec {
name: "mkit_diff_staged",
description: "Show changes staged for the next commit.",
hints: (true, false, true),
schema: || schema(vec![repo_prop()], &["repo_path"]),
},
ToolSpec {
name: "mkit_diff",
description: "Show the diff against a target revision (branch, tag, or 64-hex BLAKE3 id).",
hints: (true, false, true),
schema: || {
schema(
vec![repo_prop(), ("target", prop("Revision to diff against"))],
&["repo_path", "target"],
)
},
},
ToolSpec {
name: "mkit_log",
description: "Show commit history as JSONL (hash, author identity, timestamp, message).",
hints: (true, false, true),
schema: || {
schema(
vec![
repo_prop(),
(
"max_count",
json!({ "type": "integer", "description": "Maximum commits to show (default 10)" }),
),
(
"rev",
prop("Optional revision (or A..B range) to start the walk from"),
),
],
&["repo_path"],
)
},
},
ToolSpec {
name: "mkit_show",
description: "Show an object: a commit with its diff, a tag, a tree listing, or blob contents.",
hints: (true, false, true),
schema: || {
schema(
vec![
repo_prop(),
("revision", prop("Revision or object id to show")),
],
&["repo_path", "revision"],
)
},
},
ToolSpec {
name: "mkit_branch",
description: "List branches as JSONL (current branch marked).",
hints: (true, false, true),
schema: || schema(vec![repo_prop()], &["repo_path"]),
},
ToolSpec {
name: "mkit_cat_object",
description: "Inspect a content-addressed object: its type, size, or pretty-printed content.",
hints: (true, false, true),
schema: || {
schema(
vec![
repo_prop(),
(
"object",
prop("Object id (64-hex BLAKE3, prefix accepted) or revision"),
),
(
"mode",
json!({ "type": "string", "enum": ["type", "size", "pretty"], "description": "What to show (default: pretty)" }),
),
],
&["repo_path", "object"],
)
},
},
ToolSpec {
name: "mkit_verify",
description: "Verify the Ed25519 signature on a commit, remix, or signed tag. Pass \
`trusted` (or `trust_roots`) to also cross-check the signer against the \
trust-roots registry `mkit_trust_add`/`mkit trust list` manage, failing \
even on a cryptographically valid signature from an unlisted key.",
hints: (true, false, true),
schema: || {
schema(
vec![
repo_prop(),
("revision", prop("Revision to verify (e.g. HEAD)")),
(
"trusted",
json!({ "type": "boolean", "description": "Cross-check the signer against the default trust-roots registry" }),
),
(
"trust_roots",
prop(
"Path to a trust-roots TOML file OUTSIDE the repo (default: \
$XDG_CONFIG_HOME/mkit/trust-roots.toml). Implies trusted=true. An \
in-repo path is rejected.",
),
),
],
&["repo_path", "revision"],
)
},
},
ToolSpec {
name: "mkit_verify_attest",
description: "Verify every DSSE attestation attached to a commit against a trust-roots \
registry. Defaults to the user-scoped trust-roots file; a trust_roots path \
inside the repository is always rejected here (hostile-clone defense — \
planted in-repo roots can never be selected through the MCP).",
hints: (true, false, true),
schema: || {
schema(
vec![
repo_prop(),
(
"commit",
prop("Commit hash to verify, or \"HEAD\" / omit for the current commit"),
),
(
"trust_roots",
prop(
"Path to a trust-roots TOML file OUTSIDE the repo (default: \
$XDG_CONFIG_HOME/mkit/trust-roots.toml). An in-repo path is rejected.",
),
),
(
"algorithm",
json!({ "type": "string", "enum": ["ed25519", "secp256k1", "p256"], "description": "Only report signatures of this algorithm" }),
),
],
&["repo_path"],
)
},
},
ToolSpec {
name: "mkit_add",
description: "Stage files for the next commit. Pass explicit paths (\".\" stages everything \
non-ignored under the repo root).",
hints: (false, false, true),
schema: || {
schema(
vec![
repo_prop(),
(
"files",
json!({ "type": "array", "items": { "type": "string" }, "description": "Paths to stage" }),
),
],
&["repo_path", "files"],
)
},
},
ToolSpec {
name: "mkit_unstage",
description: "Unstage changes: with files, restores those index entries from HEAD; without, \
unstages everything (mixed reset). Never touches the working tree.",
hints: (false, true, true),
schema: || {
schema(
vec![
repo_prop(),
(
"files",
json!({ "type": "array", "items": { "type": "string" }, "description": "Paths to unstage (omit to unstage all)" }),
),
],
&["repo_path"],
)
},
},
ToolSpec {
name: "mkit_commit",
description: "Create an Ed25519-signed commit from the staging index. Requires a signing \
key (mkit_keygen) — commits are always signed.",
hints: (false, false, false),
schema: || {
schema(
vec![repo_prop(), ("message", prop("Commit message"))],
&["repo_path", "message"],
)
},
},
ToolSpec {
name: "mkit_create_branch",
description: "Create a new branch at HEAD.",
hints: (false, false, false),
schema: || {
schema(
vec![repo_prop(), ("branch_name", prop("Name of the new branch"))],
&["repo_path", "branch_name"],
)
},
},
ToolSpec {
name: "mkit_checkout",
description: "Switch HEAD to a branch and restore files. Overwrites clean tracked files \
and removes tracked paths absent from the target branch (dirty-worktree \
changes are guarded and refuse instead).",
hints: (false, true, false),
schema: || {
schema(
vec![repo_prop(), ("branch_name", prop("Branch to switch to"))],
&["repo_path", "branch_name"],
)
},
},
ToolSpec {
name: "mkit_init",
description: "Create a new mkit repository (.mkit/) in repo_path. Run mkit_keygen next — \
commits require a signing key.",
hints: (false, false, false),
schema: || schema(vec![repo_prop()], &["repo_path"]),
},
ToolSpec {
name: "mkit_keygen",
description: "Generate a signing key. Default (ed25519) writes the commit-signing key at \
.mkit/keys/default.key; secp256k1/p256 write separate ATTESTATION signer keys \
(.mkit/keys/<alg>.key) for use with mkit_attest. Refuses to overwrite.",
hints: (false, false, false),
schema: || {
schema(
vec![
repo_prop(),
(
"algorithm",
json!({ "type": "string", "enum": ["ed25519", "secp256k1", "p256"], "description": "Key algorithm (default: ed25519 = the commit key)" }),
),
(
"print_pubkey",
json!({ "type": "boolean", "description": "Also print the public key" }),
),
],
&["repo_path"],
)
},
},
ToolSpec {
name: "mkit_attest",
description: "Produce a signed DSSE attestation (in-toto v1 Statement) for a commit. \
Prints the att-id and stores the envelope under .mkit/attestations/. \
(Multi-signer envelopes and external-signer argv are intentionally NOT \
exposed here — they can direct subprocess execution; use the `mkit attest` \
CLI for that advanced flow.)",
hints: (false, false, false),
schema: || {
schema(
vec![
repo_prop(),
(
"commit",
prop("Commit hash to attest, or \"HEAD\" / omit for the current commit"),
),
(
"algorithm",
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." }),
),
(
"signer",
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)." }),
),
(
"predicate_type",
prop("Predicate-type URI written into the Statement"),
),
(
"predicate_file",
prop(
"Path to a JSON predicate file INSIDE the repo (an outside path is rejected)",
),
),
],
&["repo_path"],
)
},
},
];
fn tool_descriptors() -> Value {
Value::Array(
TOOLS
.iter()
.map(|t| {
let (read_only, destructive, idempotent) = t.hints;
json!({
"name": t.name,
"description": t.description,
"inputSchema": (t.schema)(),
"annotations": {
"readOnlyHint": read_only,
"destructiveHint": destructive,
"idempotentHint": idempotent,
"openWorldHint": false,
},
})
})
.collect(),
)
}
struct CallOutcome {
text: String,
is_error: bool,
}
impl CallOutcome {
fn err(text: impl Into<String>) -> Self {
Self {
text: text.into(),
is_error: true,
}
}
}
fn call_tool(name: &str, args: &Value, allowed: Option<&Path>) -> Result<CallOutcome, String> {
if !TOOLS.iter().any(|t| t.name == name) {
return Err(format!("unknown tool: {name}"));
}
let Some(repo_raw) = args.get("repo_path").and_then(Value::as_str) else {
return Ok(CallOutcome::err("missing required argument: repo_path"));
};
let repo = match validate_repo_path(repo_raw, allowed) {
Ok(p) => p,
Err(e) => return Ok(CallOutcome::err(e)),
};
if let Err(e) = confine_path_args(name, args, &repo) {
return Ok(CallOutcome::err(e));
}
let command = match build_argv(name, args) {
Ok(a) => a,
Err(e) => return Ok(CallOutcome::err(e)),
};
Ok(run_subprocess(&repo, &command))
}
fn confine_path_args(name: &str, args: &Value, repo: &Path) -> Result<(), String> {
match name {
"mkit_attest" => {
if let Some(f) = opt_str(args, "predicate_file") {
confine_path(repo, &f, Containment::Inside, "predicate_file")?;
}
}
"mkit_verify_attest" | "mkit_verify" => {
if let Some(f) = opt_str(args, "trust_roots") {
confine_path(repo, &f, Containment::Outside, "trust_roots")?;
}
}
_ => {}
}
Ok(())
}
#[derive(Clone, Copy)]
enum Containment {
Inside,
Outside,
}
fn confine_path(repo: &Path, raw: &str, want: Containment, what: &str) -> Result<(), String> {
let candidate = if Path::new(raw).is_absolute() {
PathBuf::from(raw)
} else {
repo.join(raw)
};
let resolved = candidate
.canonicalize()
.map_err(|e| format!("invalid {what} '{raw}': {e}"))?;
let within = resolved.starts_with(repo);
match want {
Containment::Inside if !within => Err(format!(
"{what} '{raw}' is outside the repository; predicate files must live in the repo"
)),
Containment::Outside if within => Err(format!(
"{what} '{raw}' is inside the repository; trust-roots must be a user-controlled file \
outside the repo (hostile-clone defense — see docs/THREAT-MODEL.md)"
)),
_ => Ok(()),
}
}
fn validate_repo_path(raw: &str, allowed: Option<&Path>) -> Result<PathBuf, String> {
let resolved = PathBuf::from(raw)
.canonicalize()
.map_err(|e| format!("invalid repo_path '{raw}': {e}"))?;
if let Some(root) = allowed
&& !resolved.starts_with(root)
{
return Err(format!(
"repo_path '{raw}' is outside the allowed repository '{}'",
root.display()
));
}
if !resolved.is_dir() {
return Err(format!("repo_path '{raw}' is not a directory"));
}
Ok(resolved)
}
fn no_dash(value: &str, what: &str) -> Result<(), String> {
if value.starts_with('-') {
return Err(format!("invalid {what} '{value}': must not start with '-'"));
}
if value.is_empty() {
return Err(format!("invalid {what}: must not be empty"));
}
Ok(())
}
fn req_str(args: &Value, key: &str) -> Result<String, String> {
args.get(key)
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| format!("missing required argument: {key}"))
}
fn opt_str(args: &Value, key: &str) -> Option<String> {
args.get(key).and_then(Value::as_str).map(str::to_owned)
}
fn push_commit(out: &mut Vec<String>, args: &Value) -> Result<(), String> {
if let Some(commit) = opt_str(args, "commit")
&& !commit.eq_ignore_ascii_case("HEAD")
{
no_dash(&commit, "commit")?;
out.extend(["--commit".into(), commit]);
}
Ok(())
}
fn push_algorithm(out: &mut Vec<String>, args: &Value) -> Result<(), String> {
if let Some(alg) = opt_str(args, "algorithm") {
if !matches!(alg.as_str(), "ed25519" | "secp256k1" | "p256") {
return Err(format!(
"invalid algorithm '{alg}': expected ed25519, secp256k1, or p256"
));
}
out.extend(["--algorithm".into(), alg]);
}
Ok(())
}
#[allow(clippy::too_many_lines)]
fn build_argv(name: &str, args: &Value) -> Result<Vec<String>, String> {
let mut out: Vec<String> = Vec::new();
match name {
"mkit_status" => out.extend(["status".into(), "--porcelain=v2".into()]),
"mkit_diff_unstaged" => out.push("diff".into()),
"mkit_diff_staged" => out.extend(["diff".into(), "--staged".into()]),
"mkit_diff" => {
let target = req_str(args, "target")?;
no_dash(&target, "target")?;
out.extend(["diff".into(), target]);
}
"mkit_log" => {
out.extend(["log".into(), "--format=json".into(), "-n".into()]);
let n = args.get("max_count").and_then(Value::as_u64).unwrap_or(10);
out.push(n.to_string());
if let Some(rev) = opt_str(args, "rev") {
no_dash(&rev, "rev")?;
out.push(rev);
}
}
"mkit_show" => {
let rev = req_str(args, "revision")?;
no_dash(&rev, "revision")?;
out.extend(["show".into(), rev]);
}
"mkit_branch" => out.extend(["branch".into(), "--format=json".into()]),
"mkit_cat_object" => {
let object = req_str(args, "object")?;
no_dash(&object, "object")?;
let flag = match opt_str(args, "mode").as_deref() {
None | Some("pretty") => "-p",
Some("type") => "-t",
Some("size") => "-s",
Some(other) => {
return Err(format!(
"invalid mode '{other}': expected type, size, or pretty"
));
}
};
out.extend(["cat-file".into(), flag.into(), object]);
}
"mkit_verify" => {
let rev = req_str(args, "revision")?;
no_dash(&rev, "revision")?;
out.extend(["verify".into(), rev]);
if args.get("trusted").and_then(Value::as_bool) == Some(true) {
out.push("--trusted".into());
}
if let Some(roots) = opt_str(args, "trust_roots") {
no_dash(&roots, "trust_roots")?;
out.extend(["--trust-roots".into(), roots]);
}
}
"mkit_verify_attest" => {
out.push("verify-attest".into());
push_commit(&mut out, args)?;
if let Some(roots) = opt_str(args, "trust_roots") {
no_dash(&roots, "trust_roots")?;
out.extend(["--trust-roots".into(), roots]);
}
push_algorithm(&mut out, args)?;
}
"mkit_add" => {
out.push("add".into());
let files = args
.get("files")
.and_then(Value::as_array)
.ok_or("missing required argument: files")?;
if files.is_empty() {
return Err("files must not be empty".into());
}
for f in files {
let f = f.as_str().ok_or("files entries must be strings")?;
no_dash(f, "file path")?;
out.push(f.into());
}
}
"mkit_unstage" => {
match args.get("files") {
None => out.push("reset".into()),
Some(Value::Array(list)) if !list.is_empty() => {
out.extend(["restore".into(), "--staged".into()]);
for f in list {
let f = f.as_str().ok_or("files entries must be strings")?;
no_dash(f, "file path")?;
out.push(f.into());
}
}
Some(_) => {
return Err(
"files must be a non-empty array of paths; omit it entirely to \
unstage everything"
.into(),
);
}
}
}
"mkit_commit" => {
let message = req_str(args, "message")?;
if message.trim().is_empty() {
return Err("message must not be empty".into());
}
out.extend(["commit".into(), "-m".into(), message]);
}
"mkit_create_branch" => {
let branch = req_str(args, "branch_name")?;
no_dash(&branch, "branch_name")?;
out.extend(["branch".into(), branch]);
}
"mkit_checkout" => {
let branch = req_str(args, "branch_name")?;
no_dash(&branch, "branch_name")?;
out.extend(["checkout".into(), branch]);
}
"mkit_init" => out.push("init".into()),
"mkit_keygen" => {
out.push("keygen".into());
push_algorithm(&mut out, args)?;
if args.get("print_pubkey").and_then(Value::as_bool) == Some(true) {
out.push("--print-pubkey".into());
}
}
"mkit_attest" => {
out.push("attest".into());
push_commit(&mut out, args)?;
let alg = opt_str(args, "algorithm").unwrap_or_else(|| "ed25519".into());
if !matches!(alg.as_str(), "ed25519" | "secp256k1" | "p256") {
return Err(format!(
"invalid algorithm '{alg}': expected ed25519, secp256k1, or p256"
));
}
out.extend(["--algorithm".into(), alg]);
let signer = opt_str(args, "signer").unwrap_or_else(|| "repo-key".into());
if !matches!(signer.as_str(), "repo-key" | "keystore") {
return Err(format!(
"invalid signer '{signer}': expected repo-key or keystore \
(external is excluded from the MCP)"
));
}
out.extend(["--signer".into(), signer]);
if let Some(uri) = opt_str(args, "predicate_type") {
no_dash(&uri, "predicate_type")?;
out.extend(["--predicate-type".into(), uri]);
}
if let Some(file) = opt_str(args, "predicate_file") {
no_dash(&file, "predicate_file")?;
out.extend(["--predicate-file".into(), file]);
}
}
other => return Err(format!("unknown tool: {other}")),
}
Ok(out)
}
fn run_subprocess(repo: &Path, argv: &[String]) -> CallOutcome {
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => return CallOutcome::err(format!("cannot locate mkit binary: {e}")),
};
let output = std::process::Command::new(exe)
.args(argv)
.current_dir(repo)
.env("NO_COLOR", "1")
.env_remove("CLICOLOR_FORCE")
.env_remove("EDITOR")
.env_remove("VISUAL")
.output();
let output = match output {
Ok(o) => o,
Err(e) => return CallOutcome::err(format!("failed to run mkit {}: {e}", argv.join(" "))),
};
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let code = output.status.code().unwrap_or(-1);
if output.status.success() {
let mut text = stdout.trim_end().to_string();
if text.is_empty() {
text = stderr.trim_end().to_string();
}
if text.is_empty() {
text = "(ok — no output)".into();
}
CallOutcome {
text,
is_error: false,
}
} else {
let mut text = format!("error: mkit exited {code} ({})", sysexits_name(code));
if !stderr.trim().is_empty() {
text.push('\n');
text.push_str(stderr.trim_end());
}
if !stdout.trim().is_empty() {
text.push('\n');
text.push_str(stdout.trim_end());
}
CallOutcome {
text,
is_error: true,
}
}
}
fn sysexits_name(code: i32) -> &'static str {
match code {
0 => "ok",
1 => "general error",
64 => "usage: wrong args or unknown subcommand",
65 => "dataerr: malformed input",
66 => "noinput: missing or unreadable input",
69 => "unavailable: transport could not connect",
73 => "cantcreat: cannot create output",
75 => "tempfail: transient failure, retry is safe",
76 => "protocol error",
77 => "noperm: permission denied",
78 => "config error",
_ => "unknown",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_table_is_complete_and_annotated() {
let tools = tool_descriptors();
let arr = tools.as_array().unwrap();
assert_eq!(arr.len(), 18, "tool count is part of the public surface");
for t in arr {
assert!(t.get("name").is_some());
assert!(t.get("description").is_some());
assert_eq!(t.pointer("/inputSchema/type").unwrap(), "object");
assert_eq!(t.pointer("/annotations/openWorldHint").unwrap(), false);
assert!(t.pointer("/inputSchema/properties/repo_path").is_some());
}
}
#[test]
fn read_only_tools_are_marked() {
let tools = tool_descriptors();
for t in tools.as_array().unwrap() {
let name = t.get("name").unwrap().as_str().unwrap();
let ro = t
.pointer("/annotations/readOnlyHint")
.unwrap()
.as_bool()
.unwrap();
let expect_ro = matches!(
name,
"mkit_status"
| "mkit_diff_unstaged"
| "mkit_diff_staged"
| "mkit_diff"
| "mkit_log"
| "mkit_show"
| "mkit_branch"
| "mkit_cat_object"
| "mkit_verify"
| "mkit_verify_attest"
);
assert_eq!(ro, expect_ro, "readOnlyHint wrong for {name}");
}
}
#[test]
fn argv_construction_basics() {
let argv = build_argv("mkit_status", &json!({})).unwrap();
assert_eq!(argv, ["status", "--porcelain=v2"]);
let argv = build_argv("mkit_commit", &json!({ "message": "hello world" })).unwrap();
assert_eq!(argv, ["commit", "-m", "hello world"]);
let argv = build_argv("mkit_add", &json!({ "files": ["a.txt", "src/b.rs"] })).unwrap();
assert_eq!(argv, ["add", "a.txt", "src/b.rs"]);
}
#[test]
fn flag_injection_is_rejected() {
for (tool, args) in [
("mkit_diff", json!({ "target": "-R" })),
("mkit_show", json!({ "revision": "--help" })),
("mkit_add", json!({ "files": ["-A"] })),
("mkit_checkout", json!({ "branch_name": "-b" })),
("mkit_create_branch", json!({ "branch_name": "-D" })),
("mkit_cat_object", json!({ "object": "--batch" })),
("mkit_log", json!({ "rev": "--graph" })),
("mkit_attest", json!({ "predicate_file": "--force" })),
] {
let err = build_argv(tool, &args).unwrap_err();
assert!(err.contains("must not start with '-'"), "{tool}: {err}");
}
}
#[test]
fn unstage_maps_to_restore_or_reset() {
let argv = build_argv("mkit_unstage", &json!({})).unwrap();
assert_eq!(argv, ["reset"]);
let argv = build_argv("mkit_unstage", &json!({ "files": ["a.txt"] })).unwrap();
assert_eq!(argv, ["restore", "--staged", "a.txt"]);
}
#[test]
fn unstage_rejects_malformed_files_instead_of_widening() {
for bad in [
json!({ "files": "a.txt" }),
json!({ "files": [] }),
json!({ "files": 3 }),
] {
let err = build_argv("mkit_unstage", &bad).unwrap_err();
assert!(err.contains("non-empty array"), "{bad}: {err}");
}
}
#[test]
fn attest_always_pins_the_signer() {
let argv = build_argv("mkit_attest", &json!({})).unwrap();
assert_eq!(
argv,
["attest", "--algorithm", "ed25519", "--signer", "repo-key"]
);
let argv = build_argv("mkit_attest", &json!({ "signer": "keystore" })).unwrap();
assert_eq!(
argv,
["attest", "--algorithm", "ed25519", "--signer", "keystore"]
);
let argv = build_argv("mkit_attest", &json!({ "algorithm": "p256" })).unwrap();
assert_eq!(
argv,
["attest", "--algorithm", "p256", "--signer", "repo-key"]
);
let err = build_argv("mkit_attest", &json!({ "signer": "external" })).unwrap_err();
assert!(err.contains("excluded"), "{err}");
}
#[test]
fn checkout_is_marked_destructive() {
let tools = tool_descriptors();
let checkout = tools
.as_array()
.unwrap()
.iter()
.find(|t| t.get("name").unwrap() == "mkit_checkout")
.unwrap();
assert_eq!(
checkout.pointer("/annotations/destructiveHint").unwrap(),
true
);
}
#[test]
fn no_force_flag_ever_emitted() {
for spec in TOOLS {
let args = json!({
"repo_path": "/tmp", "target": "x", "revision": "x", "object": "x",
"message": "m", "branch_name": "b", "files": ["f"],
"commit": "c", "predicate_type": "t", "predicate_file": "p",
});
if let Ok(argv) = build_argv(spec.name, &args) {
assert!(
!argv.iter().any(|a| a == "-f" || a == "--force"),
"{} emits a force flag",
spec.name
);
}
}
}
#[test]
fn scope_validation_rejects_outside_paths() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let allowed = root.path().canonicalize().unwrap();
assert!(validate_repo_path(root.path().to_str().unwrap(), Some(&allowed)).is_ok());
let err = validate_repo_path(outside.path().to_str().unwrap(), Some(&allowed)).unwrap_err();
assert!(err.contains("outside the allowed repository"));
assert!(validate_repo_path(outside.path().to_str().unwrap(), None).is_ok());
}
#[test]
fn initialize_negotiates_protocol_and_lists_tools() {
let mut init_state = false;
let early = json!({ "jsonrpc": "2.0", "id": 0, "method": "tools/list" });
let resp = handle_message(&early, None, &mut init_state).unwrap();
assert_eq!(resp.pointer("/error/code").unwrap(), -32002);
assert!(!init_state);
let init = json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": { "protocolVersion": "2024-11-05", "capabilities": {} }
});
let resp = handle_message(&init, None, &mut init_state).unwrap();
assert_eq!(
resp.pointer("/result/protocolVersion").unwrap(),
"2024-11-05"
);
assert_eq!(
resp.pointer("/result/serverInfo/name").unwrap(),
"mkit-repo"
);
assert!(resp.pointer("/result/instructions").is_some());
assert!(init_state);
let mut s2 = false;
let bad = json!({
"jsonrpc": "2.0", "id": 9, "method": "initialize",
"params": { "protocolVersion": "1900-01-01" }
});
let resp = handle_message(&bad, None, &mut s2).unwrap();
assert_eq!(
resp.pointer("/result/protocolVersion").unwrap(),
LATEST_PROTOCOL
);
let list = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" });
let resp = handle_message(&list, None, &mut init_state).unwrap();
assert_eq!(
resp.pointer("/result/tools")
.unwrap()
.as_array()
.unwrap()
.len(),
18
);
let note = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
assert!(handle_message(¬e, None, &mut init_state).is_none());
let bogus = json!({ "jsonrpc": "2.0", "id": 3, "method": "resources/list" });
let resp = handle_message(&bogus, None, &mut init_state).unwrap();
assert_eq!(resp.pointer("/error/code").unwrap(), -32601);
}
}