use assert_cmd::cargo::cargo_bin_cmd;
use serde_json::json;
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
#[test]
fn test_mcp_help_shows_admin_flag() {
let mut cmd = cargo_bin_cmd!("lemma");
cmd.args(["mcp", "--help"]);
cmd.assert()
.success()
.stdout(predicates::str::contains("--admin"));
}
fn mcp_session(
prefix: Option<&std::path::Path>,
admin: bool,
messages: &[serde_json::Value],
) -> Vec<serde_json::Value> {
mcp_session_in_dir(prefix, None, admin, messages)
}
fn mcp_session_in_dir(
prefix: Option<&std::path::Path>,
current_dir: Option<&std::path::Path>,
admin: bool,
messages: &[serde_json::Value],
) -> Vec<serde_json::Value> {
mcp_session_with_env(prefix, current_dir, admin, &[], messages)
}
fn mcp_session_with_env(
prefix: Option<&std::path::Path>,
current_dir: Option<&std::path::Path>,
admin: bool,
env: &[(&str, &str)],
messages: &[serde_json::Value],
) -> Vec<serde_json::Value> {
let bin = env!("CARGO_BIN_EXE_lemma");
let mut cmd = Command::new(bin);
cmd.arg("mcp");
if let Some(p) = prefix {
cmd.arg("--prefix").arg(p);
}
if let Some(dir) = current_dir {
cmd.current_dir(dir);
}
if admin {
cmd.arg("--admin");
}
for (key, value) in env {
cmd.env(key, value);
}
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = cmd.spawn().expect("Failed to start MCP server");
let mut stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let reader = BufReader::new(stdout);
let mut input = String::new();
for msg in messages {
input.push_str(&serde_json::to_string(msg).unwrap());
input.push('\n');
}
stdin.write_all(input.as_bytes()).unwrap();
drop(stdin);
let mut responses = Vec::new();
for line in reader.lines() {
let line = line.unwrap();
if line.trim().is_empty() {
continue;
}
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&line) {
responses.push(val);
}
}
child.wait().unwrap();
responses
}
fn registry_fixtures_dir() -> std::path::PathBuf {
lemma::LemmaBase::test_fixtures_dir()
}
fn mcp_fetch_session(
prefix: &std::path::Path,
messages: &[serde_json::Value],
) -> Vec<serde_json::Value> {
let fixtures = registry_fixtures_dir();
mcp_session_with_env(
Some(prefix),
None,
true,
&[("LEMMA_REGISTRY_FIXTURES", fixtures.to_str().unwrap())],
messages,
)
}
fn make_request(id: u64, method: &str, params: serde_json::Value) -> serde_json::Value {
json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params
})
}
fn pricing_spec() -> &'static str {
"spec pricing\ndata quantity: number\ndata base_price: 10\nrule total: quantity * base_price\n"
}
fn write_spec(dir: &std::path::Path, filename: &str, content: &str) {
std::fs::write(dir.join(filename), content).unwrap();
}
#[test]
fn test_mcp_list_returns_list() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(temp_dir.path(), "pricing.lemma", pricing_spec());
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(2, "tools/list", json!({})),
make_request(
3,
"tools/call",
json!({
"name": "list",
"arguments": {}
}),
),
],
);
assert!(responses.len() >= 3, "Expected at least 3 responses");
let list_result = &responses[2]["result"]["content"][0]["text"];
let text = list_result.as_str().expect("list should return text");
let list: serde_json::Value = serde_json::from_str(text).expect("list should return list JSON");
let workspace = list
.as_array()
.and_then(|groups| {
groups
.iter()
.find(|g| g["repository"].is_null())
.map(|g| g["specs"].as_array())
})
.flatten()
.expect("workspace group with specs");
assert!(
workspace.iter().any(|row| row["name"] == "pricing"),
"list should include pricing, got: {text}"
);
}
#[test]
fn test_mcp_evaluate_includes_reasoning() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(
temp_dir.path(),
"discount.lemma",
"spec discount\ndata quantity: number\nrule rate: 0 percent\n unless quantity >= 10 then 10 percent\n unless quantity >= 50 then 20 percent\n",
);
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "evaluate",
"arguments": {
"spec": "discount",
"rule": "rate",
"data": ["quantity=25"]
}
}),
),
],
);
assert!(responses.len() >= 2, "Expected at least 2 responses");
let eval_result = &responses[1]["result"]["content"][0]["text"];
let text = eval_result.as_str().expect("evaluate should return text");
assert!(
text.contains("rate:"),
"Should contain rule name, got: {text}"
);
assert!(
text.contains("Reasoning:"),
"Should contain reasoning section, got: {text}"
);
assert!(
text.contains("quantity >= 10"),
"Should state the matching condition as a fact in reasoning, got: {text}"
);
assert!(
text.contains("quantity: 25"),
"Should show the data value that drove the conditions, got: {text}"
);
}
#[test]
fn test_mcp_evaluate_reports_missing_data_when_partial() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(
temp_dir.path(),
"pricing.lemma",
"spec pricing\ndata quantity: number\ndata price: number\nrule total: quantity * price\n",
);
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "evaluate",
"arguments": {
"spec": "pricing",
"rule": "total",
"data": ["quantity=2"]
}
}),
),
make_request(
3,
"tools/call",
json!({
"name": "evaluate",
"arguments": {
"spec": "pricing",
"rule": "total",
"data": ["quantity=2", "price=10"]
}
}),
),
],
);
assert!(responses.len() >= 3);
let partial = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("partial evaluate text");
assert!(
partial.contains("missing_data:"),
"partial evaluate must report missing_data, got: {partial}"
);
assert!(
partial.contains("price:"),
"missing_data must include price with type, got: {partial}"
);
let complete = responses[2]["result"]["content"][0]["text"]
.as_str()
.expect("complete evaluate text");
assert!(
!complete.contains("missing_data:"),
"complete evaluate must omit missing_data, got: {complete}"
);
assert!(
complete.contains("total:"),
"complete evaluate must show rule result, got: {complete}"
);
}
#[test]
fn test_mcp_evaluate_missing_data_includes_help() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(
temp_dir.path(),
"pricing.lemma",
r#"spec pricing
data quantity: number
data price: number
-> help "Unit price of the item."
rule total: quantity * price
"#,
);
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "evaluate",
"arguments": {
"spec": "pricing",
"rule": "total",
"data": ["quantity=2"]
}
}),
),
],
);
assert!(responses.len() >= 2);
let text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("evaluate text");
assert!(
text.contains("missing_data:"),
"must report missing_data, got: {text}"
);
assert!(
text.contains("Unit price of the item."),
"missing_data line must include -> help text, got: {text}"
);
assert!(
text.contains("price:") && text.contains("number"),
"missing_data line must include name and type, got: {text}"
);
}
#[test]
fn test_mcp_read_only_by_default() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(2, "tools/list", json!({})),
make_request(
3,
"tools/call",
json!({
"name": "add_spec",
"arguments": {
"code": "spec test\ndata x: 5\nrule y: x"
}
}),
),
],
);
assert!(responses.len() >= 3, "Expected at least 3 responses");
let tools = &responses[1]["result"]["tools"];
let tool_names: Vec<&str> = tools
.as_array()
.unwrap()
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
assert!(
!tool_names.contains(&"add_spec"),
"add_spec should not be listed in read-only mode, got: {:?}",
tool_names
);
assert!(
!tool_names.contains(&"update_spec"),
"update_spec should not be listed in read-only mode, got: {:?}",
tool_names
);
assert!(
!tool_names.contains(&"remove_spec"),
"remove_spec should not be listed in read-only mode, got: {:?}",
tool_names
);
assert!(
!tool_names.contains(&"clear"),
"clear should not be listed in read-only mode, got: {:?}",
tool_names
);
assert!(
!tool_names.contains(&"fetch"),
"fetch should not be listed in read-only mode, got: {:?}",
tool_names
);
assert!(
tool_names.contains(&"source"),
"source should be listed in read-only mode, got: {:?}",
tool_names
);
let error = &responses[2]["error"];
assert!(
error.is_object(),
"add_spec should return an error in read-only mode"
);
assert!(
error["message"]
.as_str()
.unwrap()
.contains("Admin tools are disabled"),
"Error should mention admin tools are disabled, got: {}",
error["message"]
);
}
#[test]
fn test_mcp_admin_enables_add_spec() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(2, "tools/list", json!({})),
make_request(
3,
"tools/call",
json!({
"name": "add_spec",
"arguments": {
"code": "spec test_spec\ndata x: 5\nrule y: x * 2",
"source_id": "test_spec.lemma"
}
}),
),
],
);
assert!(responses.len() >= 3, "Expected at least 3 responses");
let tools = &responses[1]["result"]["tools"];
let tool_names: Vec<&str> = tools
.as_array()
.unwrap()
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
assert!(
tool_names.contains(&"add_spec"),
"add_spec should be listed with --admin, got: {:?}",
tool_names
);
assert!(
tool_names.contains(&"update_spec"),
"update_spec should be listed with --admin, got: {:?}",
tool_names
);
assert!(
tool_names.contains(&"remove_spec"),
"remove_spec should be listed with --admin, got: {:?}",
tool_names
);
assert!(
tool_names.contains(&"clear"),
"clear should be listed with --admin, got: {:?}",
tool_names
);
assert!(
tool_names.contains(&"fetch"),
"fetch should be listed with --admin, got: {:?}",
tool_names
);
assert!(
tool_names.contains(&"source"),
"source should be listed (default tool), got: {:?}",
tool_names
);
let add_result = &responses[2]["result"]["content"][0]["text"];
let text = add_result.as_str().expect("add_spec should return text");
assert_eq!(text, "Spec added successfully.");
}
#[test]
fn test_mcp_source() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(temp_dir.path(), "pricing.lemma", pricing_spec());
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "source",
"arguments": {
"spec": "pricing"
}
}),
),
],
);
assert!(responses.len() >= 2, "Expected at least 2 responses");
let source_result = &responses[1]["result"]["content"][0]["text"];
let text = source_result.as_str().expect("source should return text");
assert!(
text.contains("spec pricing"),
"Should contain spec declaration, got: {text}"
);
assert!(
text.contains("data quantity"),
"Should contain data declarations, got: {text}"
);
assert!(
text.contains("rule total"),
"Should contain rule declarations, got: {text}"
);
}
#[test]
fn test_mcp_source_embedded_lemma_repository() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "source",
"arguments": {
"repository": "lemma"
}
}),
),
],
);
assert!(responses.len() >= 2);
let text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("source should return text");
assert!(
text.contains("repo lemma")
&& text.contains("spec units")
&& text.contains("trait duration"),
"Should return formatted embedded stdlib, got: {text}"
);
}
#[test]
fn test_mcp_source_without_admin() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(
temp_dir.path(),
"pricing.lemma",
"spec pricing\ndata x: 5\nrule y: x\n",
);
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "source",
"arguments": {
"spec": "pricing"
}
}),
),
],
);
assert!(responses.len() >= 2, "Expected at least 2 responses");
let text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("source should return text without --admin");
assert!(
text.contains("spec pricing"),
"source without --admin should return formatted source, got: {text}"
);
}
#[test]
fn test_mcp_initialize_response() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[make_request(1, "initialize", json!({}))],
);
assert_eq!(responses.len(), 1);
let result = &responses[0]["result"];
assert_eq!(result["protocolVersion"], "2024-11-05");
assert_eq!(result["serverInfo"]["name"], "lemma-mcp-server");
assert!(
result["serverInfo"]["version"].as_str().is_some(),
"Should include server version"
);
assert!(
result["capabilities"]["tools"].is_object(),
"Should advertise tools capability"
);
assert!(
result["capabilities"]["resources"].is_object(),
"Should advertise resources capability"
);
}
#[test]
fn test_mcp_show_full_spec() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(temp_dir.path(), "pricing.lemma", pricing_spec());
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "show",
"arguments": { "spec": "pricing" }
}),
),
],
);
assert!(responses.len() >= 2);
let text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("show should return text");
let show: serde_json::Value = serde_json::from_str(text).expect("show should return JSON Show");
assert_eq!(show["spec"], "pricing");
assert!(
show["data"]["quantity"].is_object(),
"Should list quantity data, got: {text}"
);
assert!(
show["data"]["base_price"].is_object(),
"Should list base_price data, got: {text}"
);
assert!(
show["rules"]["total"].is_object(),
"Should list total rule, got: {text}"
);
}
#[test]
fn test_mcp_show_full_spec_rules() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(
temp_dir.path(),
"multi.lemma",
"spec multi\ndata a: number\ndata b: number\nrule sum: a + b\nrule product: a * b\n",
);
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "show",
"arguments": { "spec": "multi" }
}),
),
],
);
assert!(responses.len() >= 2);
let text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("show should return text");
let show: serde_json::Value = serde_json::from_str(text).expect("show should return JSON Show");
assert!(
show["rules"]["sum"].is_object() && show["rules"]["product"].is_object(),
"Should list all rules, got: {text}"
);
}
#[test]
fn test_mcp_show_missing_spec() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "show",
"arguments": { "spec": "nonexistent" }
}),
),
],
);
assert!(responses.len() >= 2);
let error = &responses[1]["error"];
assert!(error.is_object(), "Should return an error for missing spec");
assert!(
error["message"].as_str().unwrap().contains("not found"),
"Error should say spec not found, got: {}",
error["message"]
);
}
#[test]
fn test_mcp_show_empty_spec_name() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "show",
"arguments": { "spec": "" }
}),
),
],
);
assert!(responses.len() >= 2);
let error = &responses[1]["error"];
assert!(
error.is_object(),
"Should return an error for empty spec name"
);
}
#[test]
fn test_mcp_evaluate_all_rules() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(
temp_dir.path(),
"multi.lemma",
"spec multi\ndata x: 3\nrule double: x * 2\nrule triple: x * 3\n",
);
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "evaluate",
"arguments": { "spec": "multi" }
}),
),
],
);
assert!(responses.len() >= 2);
let text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("evaluate should return text");
assert!(
text.contains("double:"),
"Should contain double rule, got: {text}"
);
assert!(
text.contains("triple:"),
"Should contain triple rule, got: {text}"
);
assert!(text.contains("6"), "double should be 6, got: {text}");
assert!(text.contains("9"), "triple should be 9, got: {text}");
}
#[test]
fn test_mcp_evaluate_missing_spec() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "evaluate",
"arguments": { "spec": "nonexistent" }
}),
),
],
);
assert!(responses.len() >= 2);
let error = &responses[1]["error"];
assert!(error.is_object(), "Should return an error for missing spec");
}
#[test]
fn test_mcp_evaluate_empty_spec_name() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "evaluate",
"arguments": { "spec": "" }
}),
),
],
);
assert!(responses.len() >= 2);
let error = &responses[1]["error"];
assert!(
error.is_object(),
"Should return an error for empty spec name"
);
assert!(
error["message"].as_str().unwrap().contains("empty"),
"Error should mention empty, got: {}",
error["message"]
);
}
#[test]
fn test_mcp_evaluate_veto_result() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(
temp_dir.path(),
"vetoed.lemma",
"spec vetoed\ndata price: -5\nrule validated: price\n unless price < 0 then veto \"Price cannot be negative\"\n",
);
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "evaluate",
"arguments": { "spec": "vetoed" }
}),
),
],
);
assert!(responses.len() >= 2);
let text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("evaluate should return text");
assert!(
text.contains("veto"),
"Should contain veto in output, got: {text}"
);
assert!(
text.contains("Price cannot be negative"),
"Should contain veto reason, got: {text}"
);
}
#[test]
fn test_mcp_evaluate_with_effective_datetime() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(
temp_dir.path(),
"simple.lemma",
"spec simple\ndata x: 42\nrule y: x\n",
);
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "evaluate",
"arguments": {
"spec": "simple",
"effective": "2026-01-01"
}
}),
),
],
);
assert!(responses.len() >= 2);
let text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("evaluate should return text");
assert!(
text.contains("y:"),
"Should contain rule result, got: {text}"
);
assert!(
text.contains("2026-01-01"),
"Should show effective datetime, got: {text}"
);
}
#[test]
fn test_mcp_list_empty_workspace() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "list",
"arguments": {}
}),
),
],
);
assert!(responses.len() >= 2);
let text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("list should return text");
let list: serde_json::Value = serde_json::from_str(text).expect("list should return JSON");
let embedded = list
.as_array()
.and_then(|groups| groups.iter().find(|g| g["repository"] == "lemma"))
.expect("embedded lemma repository group");
assert!(
embedded["specs"]
.as_array()
.and_then(|specs| specs.first())
.and_then(|row| row["name"].as_str())
== Some("units"),
"embedded stdlib must appear in list, got: {text}"
);
}
#[test]
fn test_mcp_list_empty_workspace_admin_suggests_add() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "list",
"arguments": {}
}),
),
],
);
assert!(responses.len() >= 2);
let text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("list should return text");
assert!(
text.contains("\"repository\": \"lemma\"") && text.contains("\"name\": \"units\""),
"embedded stdlib must appear, got: {text}"
);
assert!(
text.contains("add_spec"),
"Admin mode should suggest using add_spec when workspace is empty, got: {text}"
);
}
#[test]
fn test_mcp_defaults_prefix_to_cwd() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(temp_dir.path(), "pricing.lemma", pricing_spec());
let responses = mcp_session_in_dir(
None,
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "list",
"arguments": {}
}),
),
],
);
assert!(responses.len() >= 2);
let text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("list should return text");
let list: serde_json::Value = serde_json::from_str(text).expect("list should return JSON");
let workspace = list
.as_array()
.and_then(|groups| {
groups
.iter()
.find(|g| g["repository"].is_null())
.map(|g| g["specs"].as_array())
})
.flatten()
.expect("workspace group");
assert!(
workspace.iter().any(|row| row["name"] == "pricing"),
"workspace specs must load when --prefix is omitted, got: {text}"
);
}
#[test]
fn test_mcp_invalid_jsonrpc_version() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[json!({
"jsonrpc": "1.0",
"id": 1,
"method": "initialize",
"params": {}
})],
);
assert_eq!(responses.len(), 1);
let error = &responses[0]["error"];
assert!(
error.is_object(),
"Should return an error for bad JSON-RPC version"
);
assert_eq!(error["code"], -32600, "Should be invalid request code");
}
#[test]
fn test_mcp_unknown_method() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[make_request(1, "nonexistent/method", json!({}))],
);
assert_eq!(responses.len(), 1);
let error = &responses[0]["error"];
assert!(
error.is_object(),
"Should return an error for unknown method"
);
assert_eq!(error["code"], -32601, "Should be method not found code");
assert!(
error["message"]
.as_str()
.unwrap()
.contains("nonexistent/method"),
"Error should name the unknown method, got: {}",
error["message"]
);
}
#[test]
fn test_mcp_malformed_json() {
let temp_dir = tempfile::tempdir().unwrap();
let bin = env!("CARGO_BIN_EXE_lemma");
let mut cmd = Command::new(bin);
cmd.arg("mcp").current_dir(temp_dir.path());
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = cmd.spawn().expect("Failed to start MCP server");
let mut stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let reader = BufReader::new(stdout);
stdin.write_all(b"this is not json\n").unwrap();
drop(stdin);
let mut responses = Vec::new();
for line in reader.lines() {
let line = line.unwrap();
if line.trim().is_empty() {
continue;
}
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&line) {
responses.push(val);
}
}
child.wait().unwrap();
assert_eq!(responses.len(), 1);
let error = &responses[0]["error"];
assert!(error.is_object(), "Should return a parse error");
assert_eq!(error["code"], -32700, "Should be parse error code");
}
#[test]
fn test_mcp_tools_call_missing_params() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call"
}),
],
);
assert!(responses.len() >= 2);
let error = &responses[1]["error"];
assert!(
error.is_object(),
"Should return an error for missing params"
);
assert_eq!(error["code"], -32602, "Should be invalid params code");
}
#[test]
fn test_mcp_tools_call_missing_tool_name() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(2, "tools/call", json!({ "arguments": {} })),
],
);
assert!(responses.len() >= 2);
let error = &responses[1]["error"];
assert!(
error.is_object(),
"Should return an error for missing tool name"
);
assert_eq!(error["code"], -32602, "Should be invalid params code");
}
#[test]
fn test_mcp_tools_call_unknown_tool() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "nonexistent_tool",
"arguments": {}
}),
),
],
);
assert!(responses.len() >= 2);
let error = &responses[1]["error"];
assert!(error.is_object(), "Should return an error for unknown tool");
assert!(
error["message"]
.as_str()
.unwrap()
.contains("nonexistent_tool"),
"Error should name the unknown tool, got: {}",
error["message"]
);
}
#[test]
fn test_mcp_oversized_line_rejected_then_recovers() {
let temp_dir = tempfile::tempdir().unwrap();
let bin = env!("CARGO_BIN_EXE_lemma");
let mut cmd = Command::new(bin);
cmd.arg("mcp").current_dir(temp_dir.path());
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = cmd.spawn().expect("Failed to start MCP server");
let mut stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let reader = BufReader::new(stdout);
let mut input = Vec::with_capacity(11 * 1024 * 1024);
input.resize(10 * 1024 * 1024 + 1, b'x');
input.push(b'\n');
input.extend_from_slice(
serde_json::to_string(&make_request(1, "initialize", json!({})))
.unwrap()
.as_bytes(),
);
input.push(b'\n');
stdin.write_all(&input).unwrap();
drop(stdin);
let mut responses = Vec::new();
for line in reader.lines() {
let line = line.unwrap();
if line.trim().is_empty() {
continue;
}
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&line) {
responses.push(val);
}
}
child.wait().unwrap();
assert_eq!(responses.len(), 2, "expected error + initialize response");
assert_eq!(
responses[0]["error"]["code"], -32700,
"oversized line must yield parse error: {}",
responses[0]
);
assert!(
responses[0]["error"]["message"]
.as_str()
.unwrap()
.contains("exceeds"),
"error should mention the byte cap: {}",
responses[0]
);
assert!(
responses[1]["result"].is_object(),
"server must recover and answer the next request: {}",
responses[1]
);
}
#[test]
fn test_mcp_request_timeout_returns_error() {
let temp_dir = tempfile::tempdir().unwrap();
let mut spec = String::from("spec slow_spec\ndata x: number\nrule r0: x + 1\n");
for i in 1..100 {
spec.push_str(&format!("rule r{i}: r{} * 2 + {i}\n", i - 1));
}
write_spec(temp_dir.path(), "slow.lemma", &spec);
let bin = env!("CARGO_BIN_EXE_lemma");
let mut cmd = Command::new(bin);
cmd.arg("mcp")
.arg("--prefix")
.arg(temp_dir.path())
.arg("--request-timeout")
.arg("0");
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = cmd.spawn().expect("Failed to start MCP server");
let mut stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let reader = BufReader::new(stdout);
let request = make_request(
1,
"tools/call",
json!({
"name": "evaluate",
"arguments": { "spec": "slow_spec", "data": ["x=1"] }
}),
);
let mut input = serde_json::to_string(&request).unwrap();
input.push('\n');
stdin.write_all(input.as_bytes()).unwrap();
drop(stdin);
let mut responses = Vec::new();
for line in reader.lines() {
let line = line.unwrap();
if line.trim().is_empty() {
continue;
}
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&line) {
responses.push(val);
}
}
child.wait().unwrap();
assert_eq!(responses.len(), 1, "expected exactly one timeout response");
assert_eq!(responses[0]["id"], 1, "response id must match request");
assert!(
responses[0]["error"]["message"]
.as_str()
.unwrap()
.contains("timed out"),
"error should mention timeout: {}",
responses[0]
);
}
#[test]
fn test_mcp_add_spec_empty_code() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "add_spec",
"arguments": { "code": "" }
}),
),
],
);
assert!(responses.len() >= 2);
let error = &responses[1]["error"];
assert!(error.is_object(), "Should return an error for empty code");
assert!(
error["message"].as_str().unwrap().contains("empty"),
"Error should mention empty, got: {}",
error["message"]
);
}
#[test]
fn test_mcp_add_spec_invalid_code() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "add_spec",
"arguments": {
"code": "this is not valid lemma code !!!",
"source_id": "invalid.lemma"
}
}),
),
],
);
assert!(responses.len() >= 2);
let result = &responses[1]["result"];
assert_eq!(
result["isError"], true,
"Invalid Lemma should return isError tool result, got: {result}"
);
let text = result["content"][0]["text"]
.as_str()
.expect("diagnostics text");
let diagnostics: serde_json::Value =
serde_json::from_str(text).expect("diagnostics should be JSON");
assert!(
diagnostics.as_array().is_some_and(|a| !a.is_empty()),
"Should return at least one diagnostic, got: {text}"
);
assert!(
diagnostics[0]["message"].as_str().is_some(),
"Diagnostic should include message, got: {text}"
);
}
#[test]
fn test_mcp_tools_list_read_only_tools() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(2, "tools/list", json!({})),
],
);
assert!(responses.len() >= 2);
let tools = responses[1]["result"]["tools"]
.as_array()
.expect("tools should be an array");
let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
assert!(
tool_names.contains(&"evaluate"),
"Should list evaluate tool"
);
assert!(tool_names.contains(&"list"), "Should list list tool");
assert!(tool_names.contains(&"show"), "Should list show tool");
assert!(tool_names.contains(&"source"), "Should list source tool");
assert!(tool_names.contains(&"check"), "Should list check tool");
assert!(tool_names.contains(&"guide"), "Should list guide tool");
assert_eq!(
tool_names.len(),
6,
"Read-only mode should have exactly 6 tools, got: {:?}",
tool_names
);
}
#[test]
fn test_mcp_remove_spec_and_clear() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "add_spec",
"arguments": {
"code": "spec draft\ndata x: number\nrule y: x\n",
"source_id": "draft.lemma"
}
}),
),
make_request(3, "tools/call", json!({ "name": "list", "arguments": {} })),
make_request(
4,
"tools/call",
json!({
"name": "remove_spec",
"arguments": { "spec": "draft" }
}),
),
make_request(5, "tools/call", json!({ "name": "list", "arguments": {} })),
make_request(
6,
"tools/call",
json!({
"name": "add_spec",
"arguments": {
"code": "spec again\ndata z: 1\nrule r: z\n",
"source_id": "again.lemma"
}
}),
),
make_request(7, "tools/call", json!({ "name": "clear", "arguments": {} })),
make_request(8, "tools/call", json!({ "name": "list", "arguments": {} })),
],
);
assert!(responses.len() >= 8);
assert_eq!(
responses[1]["result"]["content"][0]["text"],
"Spec added successfully."
);
let list_after_add = responses[2]["result"]["content"][0]["text"]
.as_str()
.expect("list text");
assert!(
list_after_add.contains("draft"),
"draft must appear after add, got: {list_after_add}"
);
let remove_text = responses[3]["result"]["content"][0]["text"]
.as_str()
.expect("remove text");
assert!(
remove_text.contains("removed"),
"remove must confirm, got: {remove_text}"
);
assert!(
responses[3]["result"].get("isError").is_none()
|| responses[3]["result"]["isError"] != true,
"remove must succeed"
);
assert!(
!temp_dir.path().join("draft.lemma").exists(),
"remove_spec must delete draft.lemma from disk"
);
let list_after_remove = responses[4]["result"]["content"][0]["text"]
.as_str()
.expect("list text");
assert!(
!list_after_remove.contains("draft"),
"draft must be gone after remove, got: {list_after_remove}"
);
let clear_text = responses[6]["result"]["content"][0]["text"]
.as_str()
.expect("clear text");
assert_eq!(
clear_text, "Removed all specs.",
"clear must confirm remove-all without stdlib chatter, got: {clear_text}"
);
assert!(
!temp_dir.path().join("again.lemma").exists(),
"clear must delete again.lemma from disk"
);
let list_after_clear = responses[7]["result"]["content"][0]["text"]
.as_str()
.expect("list text");
assert!(
!list_after_clear.contains("again"),
"workspace specs must be gone after clear, got: {list_after_clear}"
);
}
#[test]
fn test_mcp_remove_spec_rewrites_shared_path_file() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "add_spec",
"arguments": {
"code": "spec first\ndata a: 1\nrule x: a\n\nspec second\ndata b: 2\nrule y: b\n",
"source_id": "pair.lemma"
}
}),
),
make_request(
3,
"tools/call",
json!({
"name": "remove_spec",
"arguments": { "spec": "first" }
}),
),
make_request(4, "tools/call", json!({ "name": "list", "arguments": {} })),
],
);
assert!(responses.len() >= 4);
assert!(
temp_dir.path().join("pair.lemma").exists(),
"shared Path file must remain after removing one of two specs"
);
let on_disk = std::fs::read_to_string(temp_dir.path().join("pair.lemma")).unwrap();
assert!(
on_disk.contains("spec second") && !on_disk.contains("spec first"),
"file must be rewritten without first, got: {on_disk}"
);
let list_text = responses[3]["result"]["content"][0]["text"]
.as_str()
.expect("list text");
assert!(
list_text.contains("second") && !list_text.contains("first"),
"engine must keep only second, got: {list_text}"
);
}
#[test]
fn test_mcp_remove_spec_deletes_startup_loaded_file() {
let temp_dir = tempfile::tempdir().unwrap();
let policy = temp_dir.path().join("workspace_policy.lemma");
std::fs::write(
&policy,
"spec workspace_policy\ndata x: number\nrule y: x\n",
)
.unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "remove_spec",
"arguments": { "spec": "workspace_policy" }
}),
),
make_request(3, "tools/call", json!({ "name": "list", "arguments": {} })),
],
);
assert!(responses.len() >= 3);
assert_eq!(
responses[1]["result"]["content"][0]["text"],
"Spec 'workspace_policy' removed."
);
assert!(
!policy.exists(),
"remove_spec must delete startup-loaded .lemma from disk"
);
let list_after = responses[2]["result"]["content"][0]["text"]
.as_str()
.expect("list text");
assert!(
!list_after.contains("workspace_policy"),
"spec must be gone after remove, got: {list_after}"
);
}
#[test]
fn test_mcp_clear_deletes_startup_loaded_workspace_files() {
let temp_dir = tempfile::tempdir().unwrap();
let policy = temp_dir.path().join("workspace_policy.lemma");
std::fs::write(
&policy,
"spec workspace_policy\ndata x: number\nrule y: x\n",
)
.unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(2, "tools/call", json!({ "name": "list", "arguments": {} })),
make_request(3, "tools/call", json!({ "name": "clear", "arguments": {} })),
make_request(4, "tools/call", json!({ "name": "list", "arguments": {} })),
],
);
assert!(responses.len() >= 4);
let list_before = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("list text");
assert!(
list_before.contains("workspace_policy"),
"startup load must see workspace file, got: {list_before}"
);
assert_eq!(
responses[2]["result"]["content"][0]["text"],
"Removed all specs."
);
assert!(
!policy.exists(),
"clear must delete startup-loaded .lemma from disk"
);
let list_after = responses[3]["result"]["content"][0]["text"]
.as_str()
.expect("list text");
assert!(
!list_after.contains("workspace_policy"),
"spec must be gone after clear, got: {list_after}"
);
}
#[test]
fn test_mcp_clear_description_leads_with_remove_all() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(2, "tools/list", json!({})),
],
);
let tools = responses[1]["result"]["tools"].as_array().expect("tools");
let clear = tools
.iter()
.find(|t| t["name"] == "clear")
.expect("clear tool");
let description = clear["description"].as_str().expect("description");
assert_eq!(
description, "Remove all specs.",
"clear description must be remove-all only, got: {description}"
);
}
#[test]
fn test_mcp_remove_spec_blocked_without_admin() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "remove_spec",
"arguments": { "spec": "anything" }
}),
),
make_request(
3,
"tools/call",
json!({
"name": "clear",
"arguments": {}
}),
),
],
);
assert!(responses.len() >= 3);
for i in [1, 2] {
let error = &responses[i]["error"];
assert!(
error.is_object(),
"admin tool call {i} must error without --admin"
);
assert!(
error["message"]
.as_str()
.unwrap()
.contains("Admin tools are disabled"),
"got: {}",
error["message"]
);
}
}
#[test]
fn test_mcp_fetch_writes_file_and_loads() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_fetch_session(
temp_dir.path(),
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "fetch",
"arguments": { "dependency": "@iso/countries" }
}),
),
make_request(3, "tools/call", json!({ "name": "list", "arguments": {} })),
],
);
assert!(responses.len() >= 3);
let fetch_text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("fetch text");
assert!(
fetch_text.contains("Fetched @iso/countries"),
"got: {fetch_text}"
);
let dep_path = temp_dir
.path()
.join("lemma_deps")
.join("@iso")
.join("countries.lemma");
assert!(dep_path.exists(), "fetch must write {}", dep_path.display());
let on_disk = std::fs::read_to_string(&dep_path).unwrap();
assert!(
on_disk.contains("spec alpha2"),
"disk content must include fixture specs, got: {on_disk}"
);
let list_text = responses[2]["result"]["content"][0]["text"]
.as_str()
.expect("list text");
assert!(
list_text.contains("@iso/countries") && list_text.contains("alpha2"),
"engine must list fetched dependency, got: {list_text}"
);
}
#[test]
fn test_mcp_fetch_blocked_without_admin() {
let temp_dir = tempfile::tempdir().unwrap();
let fixtures = registry_fixtures_dir();
let responses = mcp_session_with_env(
Some(temp_dir.path()),
None,
false,
&[("LEMMA_REGISTRY_FIXTURES", fixtures.to_str().unwrap())],
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "fetch",
"arguments": { "dependency": "@iso/countries" }
}),
),
],
);
assert!(responses.len() >= 2);
let error = &responses[1]["error"];
assert!(error.is_object(), "fetch without admin must error");
assert!(
error["message"]
.as_str()
.unwrap()
.contains("Admin tools are disabled"),
"got: {}",
error["message"]
);
assert!(
!temp_dir.path().join("lemma_deps").exists(),
"fetch without admin must not write lemma_deps"
);
}
#[test]
fn test_mcp_fetch_skips_unchanged_unless_force() {
let temp_dir = tempfile::tempdir().unwrap();
let first = mcp_fetch_session(
temp_dir.path(),
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "fetch",
"arguments": { "dependency": "@iso/countries" }
}),
),
],
);
let first_text = first[1]["result"]["content"][0]["text"]
.as_str()
.expect("first fetch");
assert!(
first_text.contains("Fetched @iso/countries"),
"got: {first_text}"
);
let second = mcp_fetch_session(
temp_dir.path(),
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "fetch",
"arguments": { "dependency": "@iso/countries" }
}),
),
make_request(
3,
"tools/call",
json!({
"name": "fetch",
"arguments": { "dependency": "@iso/countries", "force": true }
}),
),
],
);
let skip_text = second[1]["result"]["content"][0]["text"]
.as_str()
.expect("skip fetch");
assert!(
skip_text.contains("Already up to date"),
"second fetch without force must skip, got: {skip_text}"
);
let force_text = second[2]["result"]["content"][0]["text"]
.as_str()
.expect("force fetch");
assert!(
force_text.contains("Fetched @iso/countries") || force_text.contains("Already up to date"),
"force with identical content may rewrite or report up to date, got: {force_text}"
);
}
#[test]
fn test_mcp_fetch_missing_registry_spec_errors() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_fetch_session(
temp_dir.path(),
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "fetch",
"arguments": { "dependency": "@org/does-not-exist" }
}),
),
],
);
assert!(responses.len() >= 2);
let error = &responses[1]["error"];
assert!(
error.is_object(),
"missing registry dependency must error, got: {}",
responses[1]
);
let message = error["message"].as_str().unwrap();
assert!(
message.contains("@org/does-not-exist") || message.contains("Registry error"),
"got: {message}"
);
assert!(
!temp_dir
.path()
.join("lemma_deps")
.join("@org")
.join("does-not-exist.lemma")
.exists(),
"failed fetch must not write lemma_deps file"
);
}
#[test]
fn test_mcp_tools_list_admin_tools() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(2, "tools/list", json!({})),
],
);
assert!(responses.len() >= 2);
let tools = responses[1]["result"]["tools"]
.as_array()
.expect("tools should be an array");
let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
assert!(
tool_names.contains(&"evaluate"),
"Should list evaluate tool"
);
assert!(tool_names.contains(&"list"), "Should list list tool");
assert!(tool_names.contains(&"show"), "Should list show tool");
assert!(tool_names.contains(&"check"), "Should list check tool");
assert!(tool_names.contains(&"guide"), "Should list guide tool");
assert!(
tool_names.contains(&"add_spec"),
"Should list add_spec tool in admin mode"
);
assert!(
tool_names.contains(&"update_spec"),
"Should list update_spec tool in admin mode"
);
assert!(
tool_names.contains(&"remove_spec"),
"Should list remove_spec tool in admin mode"
);
assert!(
tool_names.contains(&"clear"),
"Should list clear tool in admin mode"
);
assert!(
tool_names.contains(&"fetch"),
"Should list fetch tool in admin mode"
);
assert!(tool_names.contains(&"source"), "Should list source tool");
assert_eq!(
tool_names.len(),
11,
"Admin mode should have exactly 11 tools, got: {:?}",
tool_names
);
}
#[test]
fn test_mcp_tools_have_input_schemas() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(2, "tools/list", json!({})),
],
);
assert!(responses.len() >= 2);
let tools = responses[1]["result"]["tools"]
.as_array()
.expect("tools should be an array");
for tool in tools {
let name = tool["name"].as_str().unwrap();
assert!(
tool["description"].as_str().is_some_and(|d| !d.is_empty()),
"Tool '{}' should have a non-empty description",
name
);
assert!(
tool["inputSchema"].is_object(),
"Tool '{}' should have an inputSchema",
name
);
assert_eq!(
tool["inputSchema"]["type"], "object",
"Tool '{}' inputSchema type should be 'object'",
name
);
}
}
#[test]
fn test_mcp_evaluate_with_data_overrides() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(temp_dir.path(), "pricing.lemma", pricing_spec());
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "evaluate",
"arguments": {
"spec": "pricing",
"data": ["quantity=5"]
}
}),
),
],
);
assert!(responses.len() >= 2);
let text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("evaluate should return text");
assert!(
text.contains("total:"),
"Should contain rule result, got: {text}"
);
assert!(
text.contains("50"),
"total should be 5 * 10 = 50, got: {text}"
);
}
#[test]
fn test_mcp_add_spec_then_evaluate() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "add_spec",
"arguments": {
"code": "spec dynamic\ndata n: 7\nrule doubled: n * 2\n",
"source_id": "dynamic.lemma"
}
}),
),
make_request(
3,
"tools/call",
json!({
"name": "evaluate",
"arguments": { "spec": "dynamic" }
}),
),
],
);
assert!(responses.len() >= 3);
let add_text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("add_spec should return text");
assert_eq!(add_text, "Spec added successfully.");
let eval_text = responses[2]["result"]["content"][0]["text"]
.as_str()
.expect("evaluate should return text");
assert!(
eval_text.contains("doubled:"),
"Should contain rule, got: {eval_text}"
);
assert!(
eval_text.contains("14"),
"doubled should be 14, got: {eval_text}"
);
}
#[test]
fn test_mcp_update_spec_with_dependents() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(
temp_dir.path(),
"workspace.lemma",
r#"
spec dep
data value: 10
rule out: value
spec consumer
uses d: dep
rule total: d.value
"#,
);
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "update_spec",
"arguments": {
"spec": "dep",
"code": "spec dep\ndata value: 20\nrule out: value\n",
"source_id": "dep.lemma"
}
}),
),
make_request(
3,
"tools/call",
json!({
"name": "evaluate",
"arguments": { "spec": "consumer" }
}),
),
],
);
assert!(responses.len() >= 3);
let update_text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("update_spec should return text");
assert_eq!(update_text, "Spec updated successfully.");
let eval_text = responses[2]["result"]["content"][0]["text"]
.as_str()
.expect("evaluate should return text");
assert!(
eval_text.contains("total:"),
"Should contain total rule, got: {eval_text}"
);
assert!(
eval_text.contains("20"),
"total should be 20 after update, got: {eval_text}"
);
}
#[test]
fn test_add_spec_persists_to_disk() {
let temp_dir = tempfile::tempdir().unwrap();
let code = "spec dynamic\ndata n: 7\nrule doubled: n * 2\n";
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "add_spec",
"arguments": {
"code": code,
"source_id": "dynamic.lemma"
}
}),
),
],
);
assert!(responses.len() >= 2);
assert_eq!(
responses[1]["result"]["content"][0]["text"],
"Spec added successfully."
);
let path = temp_dir.path().join("dynamic.lemma");
let on_disk = std::fs::read_to_string(&path).expect("dynamic.lemma must exist after add_spec");
let expected = lemma::format_source(
code,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
"dynamic.lemma",
))),
)
.expect("fixture must format");
assert_eq!(on_disk, expected);
}
#[test]
fn test_update_spec_persists_to_disk() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(
temp_dir.path(),
"dep.lemma",
"spec dep\ndata value: 10\nrule out: value\n",
);
let new_code = "spec dep\ndata value: 20\nrule out: value\n";
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "update_spec",
"arguments": {
"spec": "dep",
"code": new_code,
"source_id": "dep.lemma"
}
}),
),
],
);
assert!(responses.len() >= 2);
assert_eq!(
responses[1]["result"]["content"][0]["text"],
"Spec updated successfully."
);
let on_disk =
std::fs::read_to_string(temp_dir.path().join("dep.lemma")).expect("dep.lemma must exist");
let expected = lemma::format_source(
new_code,
lemma::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("dep.lemma"))),
)
.expect("fixture must format");
assert_eq!(on_disk, expected);
assert!(on_disk.contains("20"));
}
#[test]
fn test_add_spec_path_traversal_rejected() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "add_spec",
"arguments": {
"code": "spec escape\ndata x: 1\nrule y: x\n",
"source_id": "../escape.lemma"
}
}),
),
],
);
assert!(responses.len() >= 2);
let error = &responses[1]["error"];
assert!(
error.is_object(),
"path traversal must return JSON-RPC error, got: {}",
responses[1]
);
let message = error["message"].as_str().unwrap_or("");
assert!(
message.contains(".."),
"error should mention '..', got: {message}"
);
assert!(
!temp_dir.path().join("../escape.lemma").exists()
|| !std::fs::read_to_string(temp_dir.path().join("../escape.lemma"))
.unwrap_or_default()
.contains("spec escape"),
"must not write escaped path"
);
}
#[test]
fn test_add_spec_write_failure_rolls_back_engine() {
let temp_dir = tempfile::tempdir().unwrap();
std::fs::write(temp_dir.path().join("blocked"), "not a directory").unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "add_spec",
"arguments": {
"code": "spec trapped\ndata x: 1\nrule y: x\n",
"source_id": "blocked/trapped.lemma"
}
}),
),
make_request(
3,
"tools/call",
json!({
"name": "list",
"arguments": {}
}),
),
],
);
assert!(responses.len() >= 3);
let error = &responses[1]["error"];
assert!(
error.is_object(),
"write failure must return JSON-RPC error, got: {}",
responses[1]
);
assert!(
error["message"]
.as_str()
.unwrap_or("")
.contains("Failed to persist"),
"error should mention persist failure, got: {}",
error["message"]
);
let list_text = responses[2]["result"]["content"][0]["text"]
.as_str()
.expect("list should return text");
assert!(
!list_text.contains("trapped"),
"engine must roll back failed persist; list was: {list_text}"
);
assert!(
!temp_dir.path().join("blocked/trapped.lemma").exists(),
"failed write must leave no target file"
);
}
#[test]
fn test_mcp_source_missing_spec() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "source",
"arguments": { "spec": "nonexistent" }
}),
),
],
);
assert!(responses.len() >= 2);
let error = &responses[1]["error"];
assert!(error.is_object(), "Should return an error for missing spec");
assert!(
error["message"].as_str().unwrap().contains("not found"),
"Error should say spec not found, got: {}",
error["message"]
);
}
#[test]
fn test_mcp_evaluate_invalid_effective() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(
temp_dir.path(),
"simple.lemma",
"spec simple\ndata x: 1\nrule y: x\n",
);
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "evaluate",
"arguments": {
"spec": "simple",
"effective": "not-a-date"
}
}),
),
],
);
assert!(responses.len() >= 2);
let error = &responses[1]["error"];
assert!(
error.is_object(),
"Should return an error for invalid effective datetime"
);
assert!(
error["message"]
.as_str()
.unwrap()
.contains("Invalid effective"),
"Error should mention invalid effective, got: {}",
error["message"]
);
}
#[test]
fn test_mcp_evaluate_respects_effective_for_versioned_spec() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(
temp_dir.path(),
"temporal.lemma",
r#"spec pricing 2025-01-01
data base: 10
rule total: base
spec pricing 2026-01-01
data base: 99
rule total: base
"#,
);
let run_eval = |effective: &str| -> String {
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "evaluate",
"arguments": {
"spec": "pricing",
"effective": effective,
"rule": "total"
}
}),
),
],
);
assert!(responses.len() >= 2, "expected evaluate response");
responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("evaluate text")
.to_string()
};
let out_2025 = run_eval("2025-06-01");
let out_2026 = run_eval("2026-06-01");
assert!(
out_2025.contains("10") && !out_2025.contains("99"),
"2025 body should use v2025 base=10; got:\n{out_2025}"
);
assert!(
out_2026.contains("99"),
"2026 body should use v2026 base=99; got:\n{out_2026}"
);
}
#[test]
fn test_mcp_response_ids_match_request_ids() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(
temp_dir.path(),
"simple.lemma",
"spec simple\ndata x: 1\nrule y: x\n",
);
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(10, "initialize", json!({})),
make_request(20, "tools/list", json!({})),
make_request(
30,
"tools/call",
json!({
"name": "list",
"arguments": {}
}),
),
],
);
assert_eq!(responses.len(), 3);
assert_eq!(responses[0]["id"], 10, "First response should have id 10");
assert_eq!(responses[1]["id"], 20, "Second response should have id 20");
assert_eq!(responses[2]["id"], 30, "Third response should have id 30");
}
#[test]
fn mcp_add_spec_without_source_id_must_require_source_id() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
true,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "add_spec",
"arguments": {
"code": "spec test_spec\ndata x: 5\nrule y: x * 2"
}
}),
),
],
);
assert!(responses.len() >= 2);
assert!(
responses[1]["error"].is_object(),
"add_spec without source_id must return error, got: {}",
responses[1]
);
}
#[test]
fn mcp_evaluate_veto_must_not_invent_vetoed_placeholder() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(
temp_dir.path(),
"veto_no_message.lemma",
"spec veto_no_message\ndata value: -5\nrule r: value > 0\n unless value < 0 then veto\n",
);
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "evaluate",
"arguments": {
"spec": "veto_no_message"
}
}),
),
],
);
assert!(responses.len() >= 2);
let eval_result = &responses[1]["result"]["content"][0]["text"];
let text = eval_result.as_str().expect("evaluate should return text");
assert!(
!text.contains("Vetoed"),
"MCP must not invent 'Vetoed' placeholder when veto_reason missing, got: {text}"
);
}
#[test]
fn test_mcp_check_invalid_returns_diagnostics() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "check",
"arguments": {
"sources": [["new_spec", "this is not valid lemma code !!!"]]
}
}),
),
],
);
assert!(responses.len() >= 2);
let result = &responses[1]["result"];
assert_eq!(result["isError"], true);
let text = result["content"][0]["text"].as_str().expect("text");
let diagnostics: serde_json::Value = serde_json::from_str(text).expect("JSON diagnostics");
let first = &diagnostics[0];
assert!(first["message"].as_str().is_some());
assert!(
first["source"]["line"].as_u64().is_some(),
"diagnostic must include line, got: {text}"
);
assert!(
first["source"]["column"].as_u64().is_some(),
"diagnostic must include column, got: {text}"
);
}
#[test]
fn test_mcp_check_does_not_mutate_list() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(temp_dir.path(), "pricing.lemma", pricing_spec());
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(2, "tools/call", json!({ "name": "list", "arguments": {} })),
make_request(
3,
"tools/call",
json!({
"name": "check",
"arguments": {
"sources": [["draft", "spec draft\ndata x: number\nrule y: x"]]
}
}),
),
make_request(4, "tools/call", json!({ "name": "list", "arguments": {} })),
],
);
assert!(responses.len() >= 4);
let list_before = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("list text");
let check_text = responses[2]["result"]["content"][0]["text"]
.as_str()
.expect("check text");
assert!(
responses[2]["result"].get("isError").is_none()
|| responses[2]["result"]["isError"] != true,
"valid check must succeed"
);
assert!(
check_text.contains("Parsed and planned"),
"check should return success message, got: {check_text}"
);
let list_after = responses[3]["result"]["content"][0]["text"]
.as_str()
.expect("list text");
assert_eq!(
list_before, list_after,
"check must not mutate loaded specs"
);
assert!(
!list_after.contains("draft"),
"draft must not appear in list after check"
);
}
#[test]
fn test_mcp_check_resolves_workspace_and_units() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(
temp_dir.path(),
"base.lemma",
"spec base\ndata flag: boolean\nrule ok: flag\n",
);
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "check",
"arguments": {
"sources": [
["base", "spec base\ndata flag: boolean\nrule ok: flag\n"],
["ship", "spec ship\nuses lemma units\nuses base\ndata package_weight: units.mass\nrule heavy: package_weight > 0 units.kilogram\n"]
]
}
}),
),
],
);
assert!(responses.len() >= 2);
let result = &responses[1]["result"];
assert!(
result.get("isError").is_none() || result["isError"] != true,
"check with uses lemma units + cross-spec uses must succeed, got: {result}"
);
let text = result["content"][0]["text"].as_str().expect("text");
assert!(
text.contains("Parsed and planned"),
"check should return success message, got: {text}"
);
}
#[test]
fn test_mcp_check_resolves_registry_dependency() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "check",
"arguments": {
"sources": [
["@test/base", "repo @test/base\n\nspec base\n\ndata flag: boolean\n\nrule is_set: flag\n"],
["ship", "spec ship\n\nuses base: @test/base base\n\nrule ok: base.is_set\n"]
]
}
}),
),
],
);
assert!(responses.len() >= 2);
let result = &responses[1]["result"];
assert!(
result.get("isError").is_none() || result["isError"] != true,
"check with @owner/repo dependency must succeed, got: {result}"
);
}
#[test]
fn test_mcp_check_reports_veto_cascade_recommendation() {
let temp_dir = tempfile::tempdir().unwrap();
let code = r#"spec eligibility 2026-01-01
"""
Age gate.
"""
data age: number
-> help "Customer age."
-> suggest 30
rule is_eligible: true
unless age < 18 then veto "Must be 18+"
"#;
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "check",
"arguments": {
"sources": [["eligibility.lemma", code]]
}
}),
),
],
);
assert!(responses.len() >= 2);
let result = &responses[1]["result"];
assert!(
result.get("isError").is_none() || result["isError"] != true,
"check must succeed with recommendations, got: {result}"
);
let text = result["content"][0]["text"].as_str().expect("text");
assert!(
text.contains("Parsed and planned"),
"success path must keep advisory framing, got: {text}"
);
assert!(
text.contains("Recommendations:"),
"must list recommendations, got: {text}"
);
assert!(
text.contains("is_eligible") && text.contains("veto"),
"must report veto-as-rejection cascade, got: {text}"
);
}
#[test]
fn test_mcp_check_clean_spec_has_no_recommendations() {
let temp_dir = tempfile::tempdir().unwrap();
let code = r#"spec pricing 2026-01-01
"""
Bulk pricing.
"""
data qty: number
-> minimum 0
-> help "Order quantity."
-> suggest 10
rule total: qty
"#;
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "check",
"arguments": {
"sources": [["pricing.lemma", code]]
}
}),
),
],
);
assert!(responses.len() >= 2);
let result = &responses[1]["result"];
assert!(
result.get("isError").is_none() || result["isError"] != true,
"clean check must succeed, got: {result}"
);
let text = result["content"][0]["text"].as_str().expect("text");
assert!(text.contains("Parsed and planned"), "got: {text}");
assert!(
!text.contains("Recommendations:"),
"clean spec must not emit recommendations, got: {text}"
);
}
#[test]
fn test_mcp_check_invalid_skips_recommendations() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "check",
"arguments": {
"sources": [["bad.lemma", "this is not valid lemma code !!!"]]
}
}),
),
],
);
assert!(responses.len() >= 2);
let result = &responses[1]["result"];
assert_eq!(result["isError"], true);
let text = result["content"][0]["text"].as_str().expect("text");
assert!(
!text.contains("Recommendations:"),
"failed plan must not include recommendations, got: {text}"
);
}
#[test]
fn test_mcp_check_rejects_duplicate_source_label() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "check",
"arguments": {
"sources": [
["base", "spec base\ndata x: 5"],
["base", "spec other\ndata y: 10"]
]
}
}),
),
],
);
assert!(responses.len() >= 2);
let result = &responses[1]["result"];
assert_eq!(result["isError"], true, "duplicate source label must fail");
let text = result["content"][0]["text"].as_str().expect("text");
assert!(
text.to_lowercase().contains("duplicate") || text.to_lowercase().contains("repeated"),
"diagnostic must mention duplicate source, got: {text}"
);
}
#[test]
fn test_mcp_show_json_includes_rule_units() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(
temp_dir.path(),
"money.lemma",
"spec money\ndata amount: measure\n -> unit eur 1\n -> unit cent 0.01\nrule total: amount\n",
);
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "show",
"arguments": { "spec": "money" }
}),
),
],
);
let text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("show text");
let show: serde_json::Value = serde_json::from_str(text).expect("JSON Show");
let units = &show["rules"]["total"]["units"];
assert!(
units.is_array() && units.as_array().unwrap().len() >= 2,
"rule total must expose unit map in JSON Show, got: {text}"
);
}
#[test]
fn test_mcp_evaluate_renders_unit_map() {
let temp_dir = tempfile::tempdir().unwrap();
write_spec(
temp_dir.path(),
"money.lemma",
"spec money\ndata amount: measure\n -> unit eur 1\n -> unit cent 0.01\nrule total: amount\n",
);
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "evaluate",
"arguments": {
"spec": "money",
"rule": "total",
"data": ["amount=84 eur"]
}
}),
),
],
);
assert!(
responses.len() >= 2,
"expected initialize + evaluate responses, got: {responses:?}"
);
let text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("evaluate text");
assert!(
text.contains("eur") && text.contains("cent"),
"evaluate must render every declared unit, got: {text}"
);
assert!(
text.contains('(') && text.contains(')'),
"unit map should be parenthesized, got: {text}"
);
}
#[test]
fn test_mcp_guide_topics() {
let temp_dir = tempfile::tempdir().unwrap();
let topics = [
"method",
"syntax",
"data",
"rules",
"units",
"veto",
"composition",
"natural_language",
"anti_patterns",
"evaluate",
"full",
];
let mut messages = vec![make_request(1, "initialize", json!({}))];
for (i, topic) in topics.iter().enumerate() {
messages.push(make_request(
(i + 2) as u64,
"tools/call",
json!({
"name": "guide",
"arguments": { "topic": topic }
}),
));
}
let responses = mcp_session(Some(temp_dir.path()), false, &messages);
assert!(responses.len() > topics.len());
for (i, topic) in topics.iter().enumerate() {
let text = responses[i + 1]["result"]["content"][0]["text"]
.as_str()
.unwrap_or("");
assert!(!text.is_empty(), "guide topic {topic} must not be empty");
match *topic {
"method" => {
assert!(
text.contains("**Method: write as a policy consultant"),
"method must include consultant heading"
);
assert!(
text.contains("**Output contract (mandatory)**"),
"method must include output contract"
);
assert!(
text.contains("**What to ask (and what not to)**"),
"method must teach ask-only-real-gaps filter"
);
assert!(
!text.contains("**Consuming loaded specs**"),
"method must not include consuming section (moved to evaluate guide)"
);
assert!(
!text.contains("Defective on arrival?"),
"method must not train laundry-list edge-case questions"
);
assert!(
text.contains("Always paste the full Lemma source"),
"method must require pasting full source at deliver"
);
assert!(
text.contains("user can verify what was saved"),
"method must require user verify of saved/loaded source"
);
assert!(
text.contains("if anything requires adjustment"),
"method must invite adjustment with a statement, not a confirm question"
);
}
"evaluate" => {
assert!(
text.contains("**Evaluating loaded specs**"),
"evaluate must include evaluating heading"
);
assert!(
text.contains("**User-facing language**"),
"evaluate must include User-facing language"
);
assert!(
text.contains("Do not say bindings"),
"evaluate must forbid saying bindings to the user"
);
assert!(
text.contains("Never ask the user what the policy means"),
"evaluate must forbid asking the user to interpret policy"
);
assert!(
text.contains("Never dispose your interpretation as the truth"),
"evaluate must forbid disposing interpretation as truth"
);
assert!(
text.contains("use *should*"),
"evaluate must require should when judgment call cannot be answered"
);
assert!(
text.contains("A reply can close many fields"),
"evaluate must teach multi-bind from replies"
);
assert!(
text.contains("After every user turn (primary loop)"),
"evaluate must make utterance multi-bind the primary loop"
);
assert!(
text.contains("Synonym probes of the same claim are forbidden"),
"evaluate must forbid synonym leaf-walk"
);
assert!(
text.contains("At most one unanswered question in flight"),
"evaluate must limit open questions"
);
assert!(
text.contains("When the rule answers (verify before done)"),
"evaluate must require verify before done"
);
assert!(
text.contains("**Details**") && text.contains("**Answer**"),
"evaluate must require details+answer verify table"
);
assert!(
text.contains("Close with a statement, not a question"),
"evaluate must close verify with a statement, not a question"
);
assert!(
text.contains("if anything requires adjustment"),
"evaluate must invite adjustment after verify table"
);
assert!(
text.contains("missing_data"),
"evaluate must mention missing_data"
);
assert!(
!text.contains("copy help verbatim"),
"evaluate must not force dumping help as the entire message"
);
}
"full" => {
assert!(
text.contains("**Method: write as a policy consultant"),
"full must include authoring method"
);
assert!(
text.contains("Always paste the full Lemma source"),
"full must require pasting full source at deliver"
);
assert!(
text.contains("user can verify what was saved"),
"full must require user verify of saved/loaded source"
);
assert!(
text.contains("if anything requires adjustment"),
"full must invite adjustment with a statement, not a confirm question"
);
assert!(
text.contains("**Mandatory spec opening order:**"),
"full must include syntax"
);
assert!(
text.contains("## See also"),
"full must include See also footer"
);
assert!(
!text.contains("**Evaluating loaded specs**"),
"full authoring guide must not include evaluate guide"
);
}
"syntax" => {
assert!(
text.contains("**Mandatory spec opening order:**"),
"syntax must include opening order"
);
}
"composition" => {
assert!(
text.contains("**Example A"),
"composition must include Example A"
);
assert!(
text.contains("**Example B"),
"composition must include Example B"
);
assert!(
text.contains("**LemmaBase"),
"composition must include LemmaBase"
);
}
"natural_language" => {
assert!(
text.contains("**Natural language"),
"natural_language must include heading"
);
assert!(
text.contains("**Example C"),
"natural_language must include Example C"
);
}
"data" => {
assert!(text.contains("**Example D"), "data must include Example D");
assert!(text.contains("**Example E"), "data must include Example E");
}
"units" => {
assert!(text.contains("**Ranges"), "units must include Ranges");
assert!(
text.contains("**Derived measures"),
"units must include Derived measures"
);
}
"rules" => {
assert!(text.contains("**Example F"), "rules must include Example F");
assert!(text.contains("**Example G"), "rules must include Example G");
assert!(text.contains("**Example H"), "rules must include Example H");
}
"veto" => {
assert!(text.contains("**Example I"), "veto must include Example I");
assert!(
text.contains("**Workflow checklist"),
"veto must include Workflow checklist"
);
}
"anti_patterns" => {
assert!(
text.contains("Inline comments (WRONG"),
"anti_patterns must include inline comments example"
);
assert!(
!text.contains("## See also"),
"anti_patterns must not include footer"
);
}
_ => panic!("unknown topic: {topic}"),
}
}
}
#[test]
fn test_mcp_guide_default_is_evaluate() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "guide",
"arguments": {}
}),
),
],
);
assert!(responses.len() >= 2);
let text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("default guide text");
assert!(
text.contains("**Evaluating loaded specs**"),
"default guide (no topic) must be evaluate guide"
);
assert!(
text.contains("**User-facing language**"),
"default guide must include User-facing language"
);
assert!(
text.contains("Do not say bindings"),
"default guide must forbid saying bindings to the user"
);
assert!(
text.contains("Never ask the user what the policy means"),
"default guide must forbid asking the user to interpret policy"
);
assert!(
text.contains("Never dispose your interpretation as the truth"),
"default guide must forbid disposing interpretation as truth"
);
assert!(
text.contains("use *should*"),
"default guide must require should when judgment call cannot be answered"
);
assert!(
text.contains("A reply can close many fields"),
"default guide must teach multi-bind from replies"
);
assert!(
text.contains("When the rule answers (verify before done)"),
"default guide must require verify before done"
);
assert!(
text.contains("Close with a statement, not a question"),
"default guide must close verify with a statement, not a question"
);
assert!(
text.contains("if anything requires adjustment"),
"default guide must invite adjustment after verify table"
);
assert!(
!text.contains("copy help verbatim"),
"default guide must not force dumping help as the entire message"
);
assert!(
!text.contains("**Mandatory spec opening order:**"),
"default guide must not be the full authoring guide"
);
}
#[test]
fn test_mcp_guide_full_topic_is_authoring() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(
2,
"tools/call",
json!({
"name": "guide",
"arguments": { "topic": "full" }
}),
),
],
);
assert!(responses.len() >= 2);
let text = responses[1]["result"]["content"][0]["text"]
.as_str()
.expect("full guide text");
assert!(
text.contains("**Mandatory spec opening order:**"),
"full guide must include syntax section"
);
assert!(
text.contains("**Method: write as a policy consultant"),
"full guide must include method section"
);
assert!(
!text.contains("**Evaluating loaded specs**"),
"full authoring guide must not include evaluate guide"
);
assert!(
!text.contains("**Consuming loaded specs**"),
"full authoring guide must not include old consuming section"
);
assert!(
text.contains("**Workflow checklist**"),
"full guide must include veto section"
);
assert!(
text.contains("## See also"),
"full guide must include See also footer"
);
}
#[test]
fn test_mcp_resources_list_and_read() {
let temp_dir = tempfile::tempdir().unwrap();
let responses = mcp_session(
Some(temp_dir.path()),
false,
&[
make_request(1, "initialize", json!({})),
make_request(2, "resources/list", json!({})),
make_request(
3,
"resources/read",
json!({ "uri": "lemma://guide/syntax" }),
),
make_request(
4,
"resources/read",
json!({ "uri": "lemma://examples/01_coffee_order.lemma" }),
),
make_request(
5,
"resources/read",
json!({ "uri": "lemma://examples/does_not_exist.lemma" }),
),
],
);
assert!(responses.len() >= 5);
let resources = responses[1]["result"]["resources"]
.as_array()
.expect("resources list");
assert!(
resources.iter().any(|r| r["uri"] == "lemma://guide"),
"must list lemma://guide"
);
assert!(
resources
.iter()
.any(|r| r["uri"] == "lemma://examples/nl/tax/net_salary.lemma"),
"must list nested example"
);
let syntax = responses[2]["result"]["contents"][0]["text"]
.as_str()
.expect("syntax resource");
assert!(syntax.contains("Mandatory spec opening order"));
let coffee = responses[3]["result"]["contents"][0]["text"]
.as_str()
.expect("example resource");
assert!(coffee.contains("spec coffee_order"));
assert!(
responses[4]["error"].is_object(),
"unknown resource URI must error"
);
}