#![cfg(feature = "mcp-v2")]
#![allow(clippy::unwrap_used)]
use std::io::{BufRead, BufReader};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
fn mkit_bin() -> &'static str {
env!("CARGO_BIN_EXE_mkit")
}
struct HttpMcp {
child: Child,
base_url: String,
headers: Vec<(String, String)>,
}
impl Drop for HttpMcp {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl HttpMcp {
fn spawn(repo: &std::path::Path) -> Self {
Self::spawn_with_args(repo, &["--unsafe-allow-any-http-peer"], &[])
}
fn spawn_with_args(
repo: &std::path::Path,
extra_args: &[&str],
headers: &[(&str, &str)],
) -> Self {
let mut child = Command::new(mkit_bin())
.args([
"mcp",
"--repository",
repo.to_str().unwrap(),
"--http",
"127.0.0.1:0",
])
.args(extra_args)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("spawn mkit mcp --http");
let stderr = child.stderr.take().unwrap();
let mut lines = BufReader::new(stderr).lines();
let deadline = Instant::now() + Duration::from_secs(10);
let addr = loop {
assert!(
Instant::now() < deadline,
"server did not report a listening address in time"
);
let line = lines
.next()
.expect("stderr closed before reporting an address")
.unwrap();
if let Some(addr) = line.strip_prefix("mkit mcp: listening on http://") {
break addr.to_string();
}
};
std::thread::spawn(move || for _ in lines {});
Self {
child,
base_url: format!("http://{addr}/"),
headers: headers
.iter()
.map(|&(k, v)| (k.to_string(), v.to_string()))
.collect(),
}
}
fn spawn_expect_refusal(
repo: &std::path::Path,
extra_args: &[&str],
) -> (std::process::ExitStatus, String) {
let output = Command::new(mkit_bin())
.args([
"mcp",
"--repository",
repo.to_str().unwrap(),
"--http",
"127.0.0.1:0",
])
.args(extra_args)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.expect("spawn mkit mcp --http");
(
output.status,
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
fn request(&self, id: i64, method: &str, params: Value) -> Value {
let mut params = params;
params["_meta"]["io.modelcontextprotocol/protocolVersion"] = json!("2026-07-28");
params["_meta"]["io.modelcontextprotocol/clientInfo"] =
json!({ "name": "mkit-mcp-http-tests", "version": "0" });
params["_meta"]["io.modelcontextprotocol/clientCapabilities"] = json!({});
let body = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
let mut request = reqwest::blocking::Client::new()
.post(&self.base_url)
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.header("mcp-protocol-version", "2026-07-28")
.header("mcp-method", method);
if method == "tools/call"
&& let Some(name) = params.get("name").and_then(Value::as_str)
{
request = request.header("mcp-name", name);
}
for (k, v) in &self.headers {
request = request.header(k, v);
}
let response = request.body(body.to_string()).send().expect("http request");
let status = response.status();
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let text = response.text().expect("response body");
let parsed: Value = if content_type.contains("text/event-stream") {
let data_line = text
.lines()
.find_map(|l| l.strip_prefix("data:"))
.unwrap_or_else(|| panic!("no 'data:' line in SSE body: {text}"));
serde_json::from_str(data_line.trim())
.unwrap_or_else(|e| panic!("SSE data is not JSON: {e}: {data_line}"))
} else {
serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("response is not JSON (status {status}): {e}: {text}"))
};
assert!(
status.is_success() || parsed.get("error").is_some(),
"status: {status}, body: {parsed}"
);
assert_eq!(
parsed.get("id").and_then(Value::as_i64),
Some(id),
"id mismatch: {parsed}"
);
parsed
}
}
#[test]
fn tools_list_over_http_matches_the_stdio_catalog() {
let repo = tempfile::tempdir().unwrap();
let server = HttpMcp::spawn(repo.path());
let resp = server.request(1, "tools/list", json!({}));
let tools = resp
.pointer("/result/tools")
.and_then(Value::as_array)
.unwrap_or_else(|| panic!("{resp}"));
assert_eq!(tools.len(), 21, "tool count is part of the public surface");
assert!(
tools
.iter()
.any(|t| t.get("name").and_then(Value::as_str) == Some("mkit_status"))
);
}
#[test]
fn tool_call_round_trip_over_http_operates_the_real_repo() {
let repo = tempfile::tempdir().unwrap();
let server = HttpMcp::spawn(repo.path());
let repo_path = repo.path().to_str().unwrap();
let init = server.request(
1,
"tools/call",
json!({ "name": "mkit_init", "arguments": { "repo_path": repo_path } }),
);
assert!(!is_tool_error(&init), "{init}");
let keygen = server.request(
2,
"tools/call",
json!({ "name": "mkit_keygen", "arguments": { "repo_path": repo_path } }),
);
assert!(!is_tool_error(&keygen), "{keygen}");
let status = server.request(
3,
"tools/call",
json!({ "name": "mkit_status", "arguments": { "repo_path": repo_path } }),
);
assert!(!is_tool_error(&status), "{status}");
assert!(
repo.path().join(".mkit").is_dir(),
"mkit_init actually created .mkit/ on disk"
);
}
fn is_tool_error(resp: &Value) -> bool {
resp.pointer("/result/isError")
.and_then(Value::as_bool)
.unwrap_or(false)
}
#[test]
fn unknown_tool_is_a_protocol_error_over_http() {
let repo = tempfile::tempdir().unwrap();
let server = HttpMcp::spawn(repo.path());
let resp = server.request(
1,
"tools/call",
json!({ "name": "mkit_push", "arguments": { "repo_path": "." } }),
);
assert!(resp.get("error").is_some(), "{resp}");
}
mod auth {
use super::{HttpMcp, mkit_bin};
use std::process::{Command, Stdio};
#[test]
fn refuses_to_bind_without_a_token_or_the_unsafe_flag() {
let repo = tempfile::tempdir().unwrap();
let (status, stderr) = HttpMcp::spawn_expect_refusal(repo.path(), &[]);
assert!(!status.success(), "should refuse to bind: {stderr}");
assert!(
stderr.contains("refusing to bind without a bearer token"),
"{stderr}"
);
}
#[test]
fn refuses_an_empty_token() {
let repo = tempfile::tempdir().unwrap();
let (status, stderr) = HttpMcp::spawn_expect_refusal(repo.path(), &["--http-token", ""]);
assert!(!status.success(), "should refuse to bind: {stderr}");
assert!(stderr.contains("MUST NOT be empty"), "{stderr}");
}
#[test]
fn refuses_token_and_unsafe_flag_together() {
let repo = tempfile::tempdir().unwrap();
let (status, stderr) = HttpMcp::spawn_expect_refusal(
repo.path(),
&["--http-token", "s3cr3t", "--unsafe-allow-any-http-peer"],
);
assert!(!status.success(), "should refuse to bind: {stderr}");
assert!(stderr.contains("mutually exclusive"), "{stderr}");
}
#[test]
fn rejects_requests_with_no_or_wrong_bearer_token() {
let repo = tempfile::tempdir().unwrap();
let server = HttpMcp::spawn_with_args(repo.path(), &["--http-token", "right-token"], &[]);
let no_auth = reqwest::blocking::Client::new()
.post(&server.base_url)
.header("content-type", "application/json")
.body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#)
.send()
.expect("http request");
assert_eq!(no_auth.status(), reqwest::StatusCode::UNAUTHORIZED);
let wrong_auth = reqwest::blocking::Client::new()
.post(&server.base_url)
.header("content-type", "application/json")
.header("authorization", "Bearer wrong-token")
.body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#)
.send()
.expect("http request");
assert_eq!(wrong_auth.status(), reqwest::StatusCode::UNAUTHORIZED);
}
#[test]
fn accepts_requests_with_the_right_bearer_token() {
let repo = tempfile::tempdir().unwrap();
let server = HttpMcp::spawn_with_args(
repo.path(),
&["--http-token", "right-token"],
&[("authorization", "Bearer right-token")],
);
let resp = server.request(1, "tools/list", serde_json::json!({}));
assert!(
resp.pointer("/result/tools").is_some(),
"authorized request should reach the tool catalog: {resp}"
);
}
#[test]
fn mkit_mcp_token_env_var_is_accepted_as_a_fallback() {
use std::io::{BufRead, BufReader};
let repo = tempfile::tempdir().unwrap();
let mut child = Command::new(mkit_bin())
.args([
"mcp",
"--repository",
repo.path().to_str().unwrap(),
"--http",
"127.0.0.1:0",
])
.env("MKIT_MCP_TOKEN", "env-token")
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("spawn mkit mcp --http");
let stderr = child.stderr.take().unwrap();
let mut lines = BufReader::new(stderr).lines();
let addr = loop {
let line = lines
.next()
.expect("stderr closed before reporting an address")
.unwrap();
if let Some(addr) = line.strip_prefix("mkit mcp: listening on http://") {
break addr.to_string();
}
};
std::thread::spawn(move || for _ in lines {});
let server = HttpMcp {
child,
base_url: format!("http://{addr}/"),
headers: vec![("authorization".to_string(), "Bearer env-token".to_string())],
};
let resp = server.request(1, "tools/list", serde_json::json!({}));
assert!(
resp.pointer("/result/tools").is_some(),
"MKIT_MCP_TOKEN should authorize the request: {resp}"
);
}
}