use super::*;
#[test]
fn test_mcp_setup_documents_search_files_modes() {
let doc = fs::read_to_string(repo_root().join("docs/getting-started/mcp-setup.md")).unwrap();
assert!(doc.contains("literal, case-insensitive, count, file-only (`files_with_matches` / `files_without_match`), multiline, invert-match, and assert-count modes"));
}
#[test]
fn test_mcp_setup_documents_surface_core_honesty() {
let doc = fs::read_to_string(repo_root().join("docs/getting-started/mcp-setup.md")).unwrap();
assert!(
doc.contains("PATCHLOOM_MCP_SURFACE"),
"mcp-setup must document surface env"
);
assert!(
doc.contains("match the active surface"),
"mcp-setup must document surface-aware instructions"
);
assert!(
doc.contains("not a capability sandbox for the plan catalog"),
"mcp-setup must clarify execute_plan is not sandboxed by core surface"
);
}
#[test]
fn test_mcp_setup_execute_plan_strips_lifecycle() {
let doc = fs::read_to_string(repo_root().join("docs/getting-started/mcp-setup.md")).unwrap();
assert!(
doc.contains("MCP `execute_plan` strips `format`/`validate`")
|| doc.contains("strips `format` and `validate`"),
"mcp-setup must state that MCP strips format/validate"
);
assert!(
!doc.contains("format` steps, `validate` steps: same as CLI"),
"mcp-setup must not say MCP plans run format/validate like CLI tx"
);
}
#[test]
fn test_mcp_setup_doc_update_selector_not_predicate_field() {
let doc = fs::read_to_string(repo_root().join("docs/getting-started/mcp-setup.md")).unwrap();
assert!(
!doc.contains("| `doc_update` | Update array elements matching a predicate |"),
"doc_update must not be described as taking a predicate field"
);
let update_idx = doc
.find("`doc_update`")
.expect("mcp-setup must list doc_update");
let row = doc[update_idx..].lines().next().unwrap_or("");
assert!(
row.contains("selector") && (row.contains("wildcard") || row.contains("wildcards")),
"doc_update row must mention selector predicates/wildcards: {row}"
);
assert!(
row.contains("doc_delete_where"),
"doc_update row must point at doc_delete_where for the predicate tool: {row}"
);
}
#[test]
fn test_mcp_setup_documents_server_info_version_fields() {
let doc = fs::read_to_string(repo_root().join("docs/getting-started/mcp-setup.md")).unwrap();
assert!(
doc.contains("protocol_version"),
"mcp-setup must document server_info protocol_version after #2060"
);
assert!(
doc.contains("package `version`") || doc.contains("package \"version\""),
"mcp-setup must document server_info package version after #2060"
);
assert!(
doc.contains("tool_count"),
"mcp-setup must keep documenting server_info tool_count"
);
}
#[cfg(feature = "mcp")]
#[test]
fn test_mcp_surface_invalid_env_fails_closed() {
if !has_mcp_support() {
return;
}
Command::cargo_bin("patchloom")
.unwrap()
.args(["mcp-server"])
.env("PATCHLOOM_MCP_SURFACE", "tiny")
.assert()
.failure()
.stderr(predicates::str::contains("PATCHLOOM_MCP_SURFACE"));
}
#[cfg(feature = "mcp")]
#[tokio::test]
async fn test_mcp_surface_core_env_lists_eleven_tools() {
if !has_mcp_support() {
return;
}
let dir = TempDir::new().unwrap();
let client = spawn_mcp_client_with_env(dir.path(), &[("PATCHLOOM_MCP_SURFACE", "core")]).await;
let tools = client.peer().list_all_tools().await.unwrap();
let names: std::collections::BTreeSet<_> =
tools.iter().map(|t| t.name.as_ref().to_string()).collect();
assert_eq!(names.len(), 11, "core pack is 11 tools, got {names:?}");
for required in [
"read_file",
"search_files",
"list_files",
"replace_text",
"batch_replace",
"doc_get",
"doc_set",
"doc_query",
"md_replace_section",
"execute_plan",
"server_info",
] {
assert!(names.contains(required), "missing core tool {required}");
}
assert!(!names.contains("create_file"));
assert!(!names.contains("ast_list"));
let info = client.peer_info().expect("peer info");
let instructions = info.instructions.as_deref().unwrap_or("");
assert!(
instructions.contains("PATCHLOOM_MCP_SURFACE=core"),
"handshake instructions must name core surface"
);
assert!(
!instructions.contains("create_file"),
"core instructions must not advertise create_file"
);
let params = rmcp::model::CallToolRequestParams::new("server_info");
let result = client.peer().call_tool(params).await.unwrap();
let text = match result.content.first().unwrap() {
rmcp::model::ContentBlock::Text(t) => &t.text,
_ => panic!("expected text"),
};
let v: serde_json::Value = serde_json::from_str(text).unwrap();
assert_eq!(v["surface"], "core");
assert_eq!(v["tool_count"], 11);
client.cancel().await.unwrap();
}
#[test]
fn test_mcp_setup_documents_text_file_skip_semantics() {
let doc = fs::read_to_string(repo_root().join("docs/getting-started/mcp-setup.md")).unwrap();
assert!(doc.contains("Binary and invalid UTF-8 files are skipped"));
}
#[cfg(feature = "mcp-http")]
#[test]
fn test_mcp_http_port_requires_http_flag() {
if !has_mcp_http_support() {
return;
}
Command::cargo_bin("patchloom")
.unwrap()
.args(["mcp-server", "--port", "3000"])
.assert()
.failure()
.stderr(predicates::str::contains("--http"));
}
#[cfg(feature = "mcp-http")]
#[test]
fn test_mcp_http_host_requires_http_flag() {
if !has_mcp_http_support() {
return;
}
Command::cargo_bin("patchloom")
.unwrap()
.args(["mcp-server", "--host", "0.0.0.0"])
.assert()
.failure()
.stderr(predicates::str::contains("--http"));
}
#[cfg(feature = "mcp-http")]
#[test]
fn test_mcp_http_non_loopback_refused_without_allow_flag() {
if !has_mcp_http_support() {
return;
}
let output = Command::cargo_bin("patchloom")
.unwrap()
.args(["--json", "mcp-server", "--http", "--host", "0.0.0.0"])
.output()
.unwrap();
assert!(
!output.status.success(),
"non-loopback HTTP must fail without --allow-unauthenticated"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}{stderr}");
assert!(
!combined.contains("listening"),
"must refuse before bind, got: {combined}"
);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| {
panic!("expected JSON invalid_input on stdout: {e}; stdout={stdout} stderr={stderr}")
});
assert_eq!(v["error_kind"], "invalid_input", "{v}");
let msg = v["error"].as_str().unwrap_or_default();
assert!(
msg.contains("--allow-unauthenticated"),
"error must name the opt-in flag: {msg}"
);
assert!(
msg.contains("no authentication") || msg.contains("unauthenticated"),
"error must say HTTP has no auth: {msg}"
);
}
#[cfg(feature = "mcp-http")]
#[tokio::test]
async fn test_mcp_http_loopback_starts_without_allow_flag() {
if !has_mcp_http_support() {
return;
}
let dir = TempDir::new().unwrap();
let bin = assert_cmd::cargo::cargo_bin("patchloom");
let mut child = tokio::process::Command::new(&bin)
.args(["mcp-server", "--http", "--host", "127.0.0.1", "--port", "0"])
.current_dir(dir.path())
.stderr(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stdin(std::process::Stdio::null())
.spawn()
.expect("failed to spawn loopback mcp-server --http");
let stderr = child.stderr.take().unwrap();
let mut reader = tokio::io::BufReader::new(stderr);
let mut line = String::new();
tokio::io::AsyncBufReadExt::read_line(&mut reader, &mut line)
.await
.expect("failed to read loopback HTTP banner");
assert!(
line.contains("MCP HTTP server listening"),
"loopback --http must start without --allow-unauthenticated: {line}"
);
assert!(
line.contains("127.0.0.1"),
"banner should show loopback bind: {line}"
);
child.kill().await.ok();
}
#[cfg(feature = "mcp-http")]
#[tokio::test]
async fn test_mcp_http_quiet_suppresses_banner() {
if !has_mcp_http_support() {
return;
}
let dir = TempDir::new().unwrap();
let bin = assert_cmd::cargo::cargo_bin("patchloom");
let mut child = tokio::process::Command::new(&bin)
.args([
"--quiet",
"mcp-server",
"--http",
"--host",
"127.0.0.1",
"--port",
"0",
])
.current_dir(dir.path())
.stderr(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stdin(std::process::Stdio::null())
.spawn()
.expect("failed to spawn quiet mcp-server --http");
let stderr = child.stderr.take().unwrap();
let collector = tokio::spawn(async move {
let mut reader = tokio::io::BufReader::new(stderr);
let mut all = String::new();
let mut line = String::new();
loop {
line.clear();
match tokio::io::AsyncBufReadExt::read_line(&mut reader, &mut line).await {
Ok(0) => break,
Ok(_) => all.push_str(&line),
Err(_) => break,
}
}
all
});
tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
assert!(
child.try_wait().expect("try_wait").is_none(),
"quiet --http server should still be running"
);
child.kill().await.ok();
let stderr = collector.await.expect("stderr collector");
assert!(
!stderr.contains("listening"),
"--quiet must not print the listening banner: {stderr}"
);
}
#[cfg(feature = "mcp-http")]
#[test]
fn test_mcp_http_bind_failure_is_invalid_input_json() {
if !has_mcp_http_support() {
return;
}
let holder = std::net::TcpListener::bind("127.0.0.1:0").expect("hold a port");
let port = holder.local_addr().expect("local_addr").port();
let output = Command::cargo_bin("patchloom")
.unwrap()
.args([
"--json",
"mcp-server",
"--http",
"--host",
"127.0.0.1",
"--port",
&port.to_string(),
])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1), "{output:?}");
let stdout = String::from_utf8_lossy(&output.stdout);
let v: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("expected JSON invalid_input on stdout: {e}; stdout={stdout}"));
assert_eq!(v["ok"], false, "{v}");
assert_eq!(v["error_kind"], "invalid_input", "{v}");
let msg = v["error"].as_str().unwrap_or_default();
assert!(
msg.contains("failed to bind"),
"error must name the bind failure: {msg}"
);
drop(holder);
}
#[test]
fn test_mcp_setup_does_not_advertise_bare_all_interfaces_http() {
let doc = fs::read_to_string(repo_root().join("docs/getting-started/mcp-setup.md")).unwrap();
assert!(
doc.contains("--allow-unauthenticated"),
"mcp-setup must document --allow-unauthenticated"
);
assert!(
doc.contains("127.0.0.1"),
"mcp-setup must show loopback first"
);
for line in doc.lines() {
let trimmed = line.trim();
if trimmed.starts_with("patchloom ") && trimmed.contains("--host 0.0.0.0") {
assert!(
trimmed.contains("--allow-unauthenticated") || trimmed.ends_with('\\'),
"0.0.0.0 command must include --allow-unauthenticated (or continue to a line that does): {trimmed}"
);
}
}
let idx = doc
.find("patchloom mcp-server --http --host 0.0.0.0")
.expect("all-interfaces example should still exist with the flag");
let window = &doc[idx..idx.saturating_add(200).min(doc.len())];
assert!(
window.contains("--allow-unauthenticated"),
"0.0.0.0 command example must include --allow-unauthenticated nearby"
);
}
#[cfg(feature = "mcp-http")]
#[test]
fn test_mcp_http_tls_cert_requires_tls_key() {
if !has_mcp_http_support() {
return;
}
Command::cargo_bin("patchloom")
.unwrap()
.args(["mcp-server", "--http", "--tls-cert", "cert.pem"])
.assert()
.failure()
.stderr(predicates::str::contains("--tls-key"));
}
#[cfg(feature = "mcp-http")]
#[test]
fn test_mcp_http_tls_key_requires_tls_cert() {
if !has_mcp_http_support() {
return;
}
Command::cargo_bin("patchloom")
.unwrap()
.args(["mcp-server", "--http", "--tls-key", "key.pem"])
.assert()
.failure()
.stderr(predicates::str::contains("--tls-cert"));
}
#[cfg(feature = "mcp-http")]
#[test]
fn test_mcp_http_invalid_tls_cert_fails_with_error() {
if !has_mcp_http_support() {
return;
}
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("bad-cert.pem"), "not a certificate\n").unwrap();
fs::write(dir.path().join("bad-key.pem"), "not a key\n").unwrap();
Command::cargo_bin("patchloom")
.unwrap()
.args([
"mcp-server",
"--http",
"--tls-cert",
dir.path().join("bad-cert.pem").to_str().unwrap(),
"--tls-key",
dir.path().join("bad-key.pem").to_str().unwrap(),
])
.assert()
.failure()
.stderr(predicates::str::contains("TLS"));
}
#[cfg(feature = "mcp-http")]
#[tokio::test]
async fn test_mcp_https_search_files_round_trip() {
if !has_mcp_http_support() {
return;
}
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("hello.txt"), "hello tls world\n").unwrap();
let ca = rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()])
.expect("self-signed cert");
let cert_pem = ca.cert.pem();
let key_pem = ca.signing_key.serialize_pem();
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("key.pem");
fs::write(&cert_path, &cert_pem).unwrap();
fs::write(&key_path, &key_pem).unwrap();
let bin = assert_cmd::cargo::cargo_bin("patchloom");
let mut child = tokio::process::Command::new(&bin)
.args([
"mcp-server",
"--http",
"--port",
"0",
"--tls-cert",
cert_path.to_str().unwrap(),
"--tls-key",
key_path.to_str().unwrap(),
])
.current_dir(dir.path())
.stderr(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stdin(std::process::Stdio::null())
.spawn()
.expect("failed to spawn mcp-server --http --tls-*");
let stderr = child.stderr.take().unwrap();
let mut reader = tokio::io::BufReader::new(stderr);
let mut line = String::new();
tokio::io::AsyncBufReadExt::read_line(&mut reader, &mut line)
.await
.expect("failed to read HTTPS server banner");
let url = line.trim().rsplit("on ").next().expect("no URL in banner");
assert!(
url.starts_with("https://"),
"expected https:// URL in banner: {line}"
);
assert!(
!url.contains(":0/"),
"banner should show real ephemeral port, not :0: {url}"
);
let ca_cert = reqwest::tls::Certificate::from_pem(cert_pem.as_bytes()).expect("parse CA cert");
let http_client = reqwest::Client::builder()
.pool_max_idle_per_host(0)
.add_root_certificate(ca_cert)
.build()
.expect("build reqwest client");
use rmcp::ServiceExt;
let mut client_opt = None;
for attempt in 0..10 {
let config =
rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri(
url,
);
let transport = rmcp::transport::StreamableHttpClientTransport::with_client(
http_client.clone(),
config,
);
match ().serve(transport).await {
Ok(c) => {
client_opt = Some(c);
break;
}
Err(_) if attempt < 9 => {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
Err(e) => panic!("HTTPS client connect failed after 10 attempts: {e}"),
}
}
let client: rmcp::service::RunningService<rmcp::RoleClient, ()> = client_opt.unwrap();
let tools = client.peer().list_all_tools().await.unwrap();
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
assert!(
names.contains(&"search_files"),
"search_files tool should be listed over HTTPS"
);
let params = rmcp::model::CallToolRequestParams::new("search_files".to_string())
.with_arguments(
serde_json::from_value(serde_json::json!({"pattern": "tls", "paths": ["."]})).unwrap(),
);
let result = client.peer().call_tool(params).await.unwrap();
assert!(
!result.is_error.unwrap_or(false),
"search_files should succeed over HTTPS"
);
let text = result
.content
.first()
.and_then(|c| match c {
rmcp::model::ContentBlock::Text(t) => Some(t.text.clone()),
_ => None,
})
.unwrap_or_default();
assert!(
text.contains("hello tls world"),
"search result should contain match: {text}"
);
client.cancel().await.unwrap();
child.kill().await.ok();
}