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> {
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");
}
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 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_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(&"source"),
"source should not 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(&"source"),
"source should be listed with --admin, 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()),
true,
&[
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()),
true,
&[
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_blocked_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 error = &responses[1]["error"];
assert!(
error.is_object(),
"source should return an error without --admin"
);
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_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(&"check"), "Should list check tool");
assert!(tool_names.contains(&"guide"), "Should list guide tool");
assert_eq!(
tool_names.len(),
5,
"Read-only mode should have exactly 5 tools, got: {:?}",
tool_names
);
}
#[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(&"source"),
"Should list source tool in admin mode"
);
assert_eq!(
tool_names.len(),
7,
"Admin mode should have exactly 7 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_source_missing_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/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_eq!(
check_text, "All sources parsed and planned successfully.",
"check should return success message"
);
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_eq!(
text, "All sources parsed and planned successfully.",
"check should return success message"
);
}
#[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_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 = [
"syntax",
"data",
"rules",
"units",
"veto",
"composition",
"anti_patterns",
];
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 {
"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"
);
}
"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");
}
"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("## Docs"),
"anti_patterns must not include Docs footer"
);
}
_ => panic!("unknown topic: {topic}"),
}
}
}
#[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"
);
}