use mnem_mcp::{Server, tool_names};
use serde_json::{Value, json};
use tempfile::TempDir;
fn rpc(method: &str, params: Value, id: u64) -> String {
serde_json::to_string(&serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
}))
.expect("serialise rpc")
}
fn fresh_server(allow_labels: bool) -> (Server, TempDir) {
let tmp = TempDir::new().expect("mktemp");
let mut server = Server::new(tmp.path().to_path_buf());
server.allow_labels = allow_labels;
(server, tmp)
}
fn tools_call(server: &mut Server, name: &str, args: Value, id: u64) -> Value {
let req = rpc(
"tools/call",
json!({
"name": name,
"arguments": args,
}),
id,
);
let line = server
.handle_line(&req)
.expect("tools/call must produce a response");
serde_json::from_str(&line).expect("response must be JSON")
}
#[test]
fn tools_list_advertises_every_registered_tool() {
let tmp = TempDir::new().expect("mktemp");
let mut server = Server::new(tmp.path().to_path_buf());
let expected: Vec<&'static str> = tool_names(server.allow_labels);
assert!(
!expected.is_empty(),
"tool_names() returned an empty list; registry regression"
);
let req = rpc("tools/list", serde_json::json!({}), 1);
let line = server
.handle_line(&req)
.expect("tools/list should produce a response");
let resp: Value = serde_json::from_str(&line).expect("parse response as JSON");
assert_eq!(resp["jsonrpc"], "2.0");
assert_eq!(resp["id"], 1);
let tools = resp["result"]["tools"]
.as_array()
.expect("result.tools must be an array");
let got: Vec<String> = tools
.iter()
.map(|t| {
t["name"]
.as_str()
.expect("each tool must have a string name")
.to_string()
})
.collect();
for name in &expected {
assert!(
got.iter().any(|g| g == name),
"tools/list response is missing `{name}`; got {got:?}"
);
}
assert_eq!(
got.len(),
expected.len(),
"tool count drift: registry reports {}, handler returned {}",
expected.len(),
got.len()
);
}
#[test]
fn initialize_reports_protocol_version() {
let tmp = TempDir::new().expect("mktemp");
let mut server = Server::new(tmp.path().to_path_buf());
let req = rpc("initialize", serde_json::json!({}), 42);
let line = server
.handle_line(&req)
.expect("initialize should produce a response");
let resp: Value = serde_json::from_str(&line).expect("parse response as JSON");
assert_eq!(resp["id"], 42);
assert_eq!(
resp["result"]["protocolVersion"],
mnem_mcp::MCP_PROTOCOL_VERSION,
"handshake must expose the crate-level protocol version constant"
);
assert_eq!(resp["result"]["serverInfo"]["name"], "mnem mcp");
}
#[test]
fn unknown_method_returns_method_not_found() {
let tmp = TempDir::new().expect("mktemp");
let mut server = Server::new(tmp.path().to_path_buf());
let req = rpc("nope/not-real", serde_json::json!({}), 7);
let line = server.handle_line(&req).expect("response expected");
let resp: Value = serde_json::from_str(&line).expect("parse response as JSON");
assert_eq!(resp["id"], 7);
assert_eq!(
resp["error"]["code"], -32601,
"unknown method must map to JSON-RPC METHOD_NOT_FOUND"
);
}
fn assert_success_response(resp: &Value, tool: &str) {
assert_eq!(resp["jsonrpc"], "2.0", "tool {tool}: jsonrpc must be 2.0");
assert!(
resp.get("error").is_none(),
"tool {tool}: unexpected error field: {resp:?}"
);
let content = &resp["result"]["content"];
let arr = content
.as_array()
.unwrap_or_else(|| panic!("tool {tool}: result.content must be an array, got {content:?}"));
assert!(
!arr.is_empty(),
"tool {tool}: result.content must not be empty"
);
assert_eq!(
arr[0]["type"], "text",
"tool {tool}: result.content[0].type must be `text`"
);
let meta = &resp["result"]["_meta"];
for key in ["bytes", "latency_micros", "tokens_estimate"] {
assert!(
meta.get(key).is_some(),
"tool {tool}: _meta.{key} missing (telemetry contract broken): {meta:?}"
);
}
}
#[test]
fn roundtrip_mnem_stats_returns_success() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(&mut s, "mnem_stats", json!({}), 1);
assert_success_response(&resp, "mnem_stats");
}
#[test]
fn roundtrip_mnem_schema_returns_success() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(&mut s, "mnem_schema", json!({}), 1);
assert_success_response(&resp, "mnem_schema");
}
#[test]
fn roundtrip_mnem_search_empty_repo_returns_success() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(&mut s, "mnem_search", json!({}), 1);
assert_success_response(&resp, "mnem_search");
}
#[test]
fn roundtrip_mnem_list_nodes_returns_success() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(&mut s, "mnem_list_nodes", json!({}), 1);
assert_success_response(&resp, "mnem_list_nodes");
}
#[test]
fn roundtrip_mnem_recent_returns_success() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(&mut s, "mnem_recent", json!({ "limit": 5 }), 1);
assert_success_response(&resp, "mnem_recent");
}
#[test]
fn roundtrip_mnem_commit_creates_node_and_returns_success() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(
&mut s,
"mnem_commit",
json!({
"agent_id": "round-trip-test",
"nodes": [
{ "summary": "hello" }
]
}),
1,
);
assert_success_response(&resp, "mnem_commit");
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("Node ") || text.contains("- Node"),
"mnem_commit round-trip text should show default ntype when gate off: {text}"
);
}
#[test]
fn roundtrip_mnem_resolve_or_create_returns_success() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(
&mut s,
"mnem_resolve_or_create",
json!({
"agent_id": "roc-test",
"prop_name": "name",
"value": "alice"
}),
1,
);
assert_success_response(&resp, "mnem_resolve_or_create");
}
#[test]
fn roundtrip_mnem_get_node_missing_id_returns_tool_error() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(
&mut s,
"mnem_get_node",
json!({ "id": "00000000-0000-0000-0000-000000000000" }),
1,
);
assert_success_response(&resp, "mnem_get_node");
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("no node") || text.contains("not found"),
"mnem_get_node on an absent id should say so in text: {text}"
);
}
#[test]
fn roundtrip_mnem_vector_search_without_embed_reports_error() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(
&mut s,
"mnem_vector_search",
json!({ "query": "anything", "k": 3 }),
1,
);
assert_eq!(resp["jsonrpc"], "2.0");
assert!(
resp.get("error").is_none(),
"mnem_vector_search must never return JSON-RPC error: {resp:?}"
);
assert!(
resp["result"]["content"].is_array(),
"mnem_vector_search tool-error response must keep content[] shape"
);
}
#[test]
fn roundtrip_mnem_retrieve_empty_returns_success_or_tool_error() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(&mut s, "mnem_retrieve", json!({}), 1);
assert_eq!(resp["jsonrpc"], "2.0");
assert!(
resp.get("error").is_none(),
"mnem_retrieve empty call must not return JSON-RPC error: {resp:?}"
);
let meta = &resp["result"]["_meta"];
for key in ["bytes", "latency_micros", "tokens_estimate"] {
assert!(
meta.get(key).is_some(),
"_meta.{key} missing on mnem_retrieve tool-error: {meta:?}"
);
}
}
#[test]
fn roundtrip_mnem_ingest_markdown_file_returns_success() {
let (mut s, td) = fresh_server(false);
let file = td.path().join("hello.md");
std::fs::write(
&file,
"# Title\n\nAlice Johnson met Bob Lee at Acme Corp on 2026-04-24.\n",
)
.expect("write fixture");
let resp = tools_call(
&mut s,
"mnem_ingest",
json!({
"path": file.to_string_lossy(),
"agent_id": "rt-ingest",
}),
1,
);
assert_success_response(&resp, "mnem_ingest");
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("chunk_count"),
"mnem_ingest text should report chunk_count: {text}"
);
assert!(
text.contains("commit_cid"),
"mnem_ingest text should report commit_cid: {text}"
);
}
#[test]
fn roundtrip_mnem_ingest_missing_path_returns_tool_error() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(&mut s, "mnem_ingest", json!({}), 1);
assert_eq!(resp["jsonrpc"], "2.0");
assert!(
resp.get("error").is_none(),
"mnem_ingest without `path` must not return JSON-RPC error: {resp:?}"
);
assert!(resp["result"]["content"].is_array());
}
#[test]
fn roundtrip_mnem_delete_node_absent_is_graceful() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(
&mut s,
"mnem_delete_node",
json!({
"agent_id": "rt-test",
"id": "00000000-0000-0000-0000-000000000000"
}),
1,
);
assert_eq!(resp["jsonrpc"], "2.0");
assert!(resp.get("error").is_none());
assert!(resp["result"]["content"].is_array());
}
#[test]
fn roundtrip_mnem_tombstone_node_absent_is_graceful() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(
&mut s,
"mnem_tombstone_node",
json!({
"agent_id": "rt-test",
"id": "00000000-0000-0000-0000-000000000000",
"reason": "test"
}),
1,
);
assert_eq!(resp["jsonrpc"], "2.0");
assert!(resp.get("error").is_none());
assert!(resp["result"]["content"].is_array());
}
#[test]
fn malformed_mnem_get_node_missing_id_returns_tool_error() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(&mut s, "mnem_get_node", json!({}), 1);
assert_eq!(resp["jsonrpc"], "2.0");
assert!(
resp.get("error").is_none(),
"expected tool-error not JSON-RPC error"
);
assert_eq!(
resp["result"]["isError"], true,
"mnem_get_node with no `id` must set isError=true: {resp:?}"
);
}
#[test]
fn malformed_mnem_get_node_invalid_uuid_returns_tool_error() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(&mut s, "mnem_get_node", json!({ "id": "not-a-uuid" }), 1);
assert_eq!(resp["jsonrpc"], "2.0");
assert_eq!(
resp["result"]["isError"], true,
"mnem_get_node with invalid UUID must set isError=true: {resp:?}"
);
}
#[test]
fn malformed_mnem_traverse_missing_start_returns_tool_error() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(&mut s, "mnem_traverse", json!({}), 1);
assert_eq!(resp["jsonrpc"], "2.0");
assert_eq!(resp["result"]["isError"], true);
}
#[test]
fn malformed_mnem_resolve_or_create_missing_prop_name_returns_tool_error() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(
&mut s,
"mnem_resolve_or_create",
json!({ "agent_id": "t", "value": "v" }),
1,
);
assert_eq!(resp["jsonrpc"], "2.0");
assert_eq!(resp["result"]["isError"], true);
}
#[test]
fn malformed_tools_call_missing_name_returns_invalid_params() {
let (mut s, _td) = fresh_server(false);
let req = rpc("tools/call", json!({ "arguments": {} }), 1);
let line = s.handle_line(&req).expect("response expected");
let resp: Value = serde_json::from_str(&line).expect("parse response");
assert_eq!(resp["jsonrpc"], "2.0");
assert_eq!(
resp["error"]["code"], -32602,
"missing `name` must map to JSON-RPC INVALID_PARAMS"
);
}
#[test]
fn malformed_tools_call_unknown_tool_returns_tool_error() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(&mut s, "mnem_not_a_real_tool", json!({}), 1);
assert_eq!(resp["jsonrpc"], "2.0");
assert!(resp.get("error").is_none());
assert_eq!(resp["result"]["isError"], true);
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("unknown tool"),
"unknown-tool error text should mention 'unknown tool': {text}"
);
}
#[test]
fn boundary_tool_count_is_stable_across_gate() {
let off = tool_names(false);
let on = tool_names(true);
assert_eq!(
off.len(),
on.len(),
"tool count must be stable across gate: off={off:?}, on={on:?}"
);
for name in &off {
assert!(
on.contains(name),
"tool `{name}` present under gate-off but missing under gate-on; registry asymmetric"
);
}
}
#[test]
fn boundary_tools_list_schema_is_stable_across_gate() {
let (mut s_off, _td_off) = fresh_server(false);
let (mut s_on, _td_on) = fresh_server(true);
let req = rpc("tools/list", json!({}), 1);
let resp_off: Value =
serde_json::from_str(&s_off.handle_line(&req).expect("response expected"))
.expect("parse off");
let resp_on: Value = serde_json::from_str(&s_on.handle_line(&req).expect("response expected"))
.expect("parse on");
let schema_off = serde_json::to_string(&resp_off["result"]["tools"]).unwrap();
let schema_on = serde_json::to_string(&resp_on["result"]["tools"]).unwrap();
assert_eq!(
schema_off, schema_on,
"tools/list schemas must be identical across MNEM_BENCH gate"
);
assert!(
schema_off.contains("\"label\""),
"mnem_search schema should always expose `label` post-audit"
);
}
#[test]
fn boundary_tools_list_schema_exposes_label_when_gate_on() {
let (mut s, _td) = fresh_server(true);
let req = rpc("tools/list", json!({}), 1);
let line = s.handle_line(&req).expect("response expected");
let resp: Value = serde_json::from_str(&line).expect("parse response");
let tools = resp["result"]["tools"].as_array().expect("tools array");
let search = tools
.iter()
.find(|t| t["name"] == "mnem_search")
.expect("mnem_search present");
let schema_str = serde_json::to_string(&search["inputSchema"]).unwrap();
assert!(
schema_str.contains("\"label\""),
"mnem_search schema must expose `label` under gate-on: {schema_str}"
);
}
#[test]
fn boundary_commit_coerces_ntype_when_gate_off() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(
&mut s,
"mnem_commit",
json!({
"agent_id": "boundary-test",
"nodes": [
{ "ntype": "SecretLabel", "summary": "nope" }
]
}),
1,
);
assert_success_response(&resp, "mnem_commit");
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(
!text.contains("SecretLabel"),
"caller-supplied `ntype` must NOT survive gate-off: {text}"
);
}
#[test]
fn boundary_commit_honours_ntype_when_gate_on() {
let (mut s, _td) = fresh_server(true);
let resp = tools_call(
&mut s,
"mnem_commit",
json!({
"agent_id": "boundary-test",
"nodes": [
{ "ntype": "Person", "summary": "alice" }
]
}),
1,
);
assert_success_response(&resp, "mnem_commit");
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("Person"),
"caller-supplied `ntype` MUST survive gate-on: {text}"
);
}
#[test]
fn resolve_or_create_preserves_existing_props_on_re_resolve() {
let (mut s, _td) = fresh_server(false);
let resp1 = tools_call(
&mut s,
"mnem_resolve_or_create",
json!({
"agent_id": "bug1-test",
"prop_name": "name",
"value": "Alice",
"extra_props": { "role": "engineer" }
}),
1,
);
assert_success_response(&resp1, "mnem_resolve_or_create");
let id = {
let text = resp1["result"]["content"][0]["text"].as_str().unwrap();
text.lines()
.find(|l| l.trim_start().starts_with("id:"))
.and_then(|l| l.split_whitespace().nth(1))
.expect("id must appear in response")
.to_string()
};
let resp2 = tools_call(
&mut s,
"mnem_resolve_or_create",
json!({
"agent_id": "bug1-test",
"prop_name": "name",
"value": "Alice",
"extra_props": { "department": "infra" }
}),
2,
);
assert_success_response(&resp2, "mnem_resolve_or_create");
let id2 = {
let text = resp2["result"]["content"][0]["text"].as_str().unwrap();
text.lines()
.find(|l| l.trim_start().starts_with("id:"))
.and_then(|l| l.split_whitespace().nth(1))
.expect("id must appear in response")
.to_string()
};
assert_eq!(id, id2, "both calls must resolve to the SAME node id");
let resp3 = tools_call(&mut s, "mnem_get_node", json!({ "id": id }), 3);
assert_success_response(&resp3, "mnem_get_node");
let text = resp3["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("engineer"),
"BUG-1 regression: `role` from first call must survive second call; got: {text}"
);
assert!(
text.contains("infra"),
"BUG-1 regression: `department` from second call must be present; got: {text}"
);
assert!(
text.contains("Alice"),
"anchor prop `name=Alice` must survive; got: {text}"
);
}
fn extract_text(resp: &Value, tool: &str) -> String {
assert_eq!(resp["jsonrpc"], "2.0", "{tool}: jsonrpc must be 2.0");
assert!(
resp.get("error").is_none(),
"{tool}: unexpected JSON-RPC error: {resp:?}"
);
resp["result"]["content"][0]["text"]
.as_str()
.unwrap_or_else(|| panic!("{tool}: content[0].text must be a string; got {resp:?}"))
.to_string()
}
#[test]
fn schema_no_edges_shows_index_not_built() {
let (mut s, _td) = fresh_server(true);
let resp = tools_call(
&mut s,
"mnem_commit",
json!({
"agent_id": "schema-no-edge-test",
"nodes": [{ "ntype": "Entity:Person", "summary": "Alice" }]
}),
1,
);
assert_success_response(&resp, "mnem_commit");
let resp2 = tools_call(&mut s, "mnem_schema", json!({}), 2);
let text = extract_text(&resp2, "mnem_schema");
assert!(
text.contains("node labels:"),
"schema must contain 'node labels:' section; got: {text}"
);
assert!(
text.contains("edge types:"),
"schema must contain 'edge types:' section; got: {text}"
);
assert!(
text.contains("index not built"),
"schema must show 'index not built' when no edges exist; got: {text}"
);
}
#[test]
fn schema_with_edge_shows_etype() {
let (mut s, _td) = fresh_server(true);
let resp = tools_call(
&mut s,
"mnem_commit_relation",
json!({
"subject": "Alice",
"subject_kind": "Entity:Person",
"predicate": "works_at",
"object": "Globex",
"object_kind": "Entity:Organization",
"agent_id": "schema-etype-test"
}),
1,
);
assert_success_response(&resp, "mnem_commit_relation");
let resp2 = tools_call(&mut s, "mnem_schema", json!({}), 2);
let text = extract_text(&resp2, "mnem_schema");
assert!(
text.contains("edge types:"),
"schema must have 'edge types:' section after commit_relation; got: {text}"
);
assert!(
text.contains("works_at"),
"schema must list 'works_at' edge type after commit_relation; got: {text}"
);
assert!(
!text.contains("index not built"),
"schema must NOT show 'index not built' after edges have been committed; got: {text}"
);
}
#[test]
fn lifecycle_commit_output_contains_op_id_and_commit_cid() {
let (mut s, _td) = fresh_server(true);
let resp = tools_call(
&mut s,
"mnem_commit",
json!({
"agent_id": "lc-test",
"nodes": [{ "ntype": "Fact", "summary": "The sky is blue" }]
}),
1,
);
assert_success_response(&resp, "mnem_commit");
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("mnem_commit: ok"),
"commit output must start with 'mnem_commit: ok'; got: {text}"
);
assert!(
text.contains("op_id:"),
"commit output must include 'op_id:'; got: {text}"
);
let op_id_val = text
.lines()
.find(|line| line.contains("op_id:"))
.and_then(|line| line.splitn(2, "op_id:").nth(1))
.map(str::trim)
.filter(|v| !v.is_empty())
.expect("op_id: line must have a non-empty value");
assert!(
op_id_val.len() > 10,
"op_id value must be CID-shaped (length > 10); got: '{op_id_val}'"
);
assert!(
text.contains("commit_cid:"),
"commit output must include 'commit_cid:'; got: {text}"
);
assert!(
text.contains("nodes added: 1"),
"commit output must report 'nodes added: 1'; got: {text}"
);
}
#[test]
fn lifecycle_commit_output_lists_node_ntype_and_uuid() {
let (mut s, _td) = fresh_server(true);
let resp = tools_call(
&mut s,
"mnem_commit",
json!({
"agent_id": "lc-test",
"nodes": [{ "ntype": "Entity:Person", "summary": "Alice" }]
}),
1,
);
assert_success_response(&resp, "mnem_commit");
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("Entity:Person"),
"commit output must list the ntype 'Entity:Person'; got: {text}"
);
let uuid_str = text
.lines()
.find(|line| line.trim_start().starts_with("- ") && line.contains("Entity:Person"))
.and_then(|line| line.split_whitespace().last())
.expect("commit output must include a '- Entity:Person <uuid>' line")
.to_string();
assert_eq!(
uuid_str.len(),
36,
"UUID must be 36 chars; got '{uuid_str}'"
);
assert_eq!(
uuid_str.chars().filter(|&c| c == '-').count(),
4,
"UUID must have 4 hyphens; got '{uuid_str}'"
);
assert!(
uuid_str.chars().all(|c| c == '-' || c.is_ascii_hexdigit()),
"UUID must be lowercase hex with hyphens; got '{uuid_str}'"
);
}
#[test]
fn lifecycle_retrieve_where_filter_finds_committed_node() {
let (mut s, _td) = fresh_server(true);
let commit_resp = tools_call(
&mut s,
"mnem_commit",
json!({
"agent_id": "lc-retrieve-test",
"nodes": [{
"ntype": "Fact",
"summary": "Findable fact",
"props": { "topic": "lifecycle-retrieve" }
}]
}),
1,
);
assert_success_response(&commit_resp, "mnem_commit");
let ret_resp = tools_call(
&mut s,
"mnem_retrieve",
json!({
"where": { "topic": "lifecycle-retrieve" }
}),
2,
);
assert_success_response(&ret_resp, "mnem_retrieve");
let text = ret_resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("1 item(s)"),
"retrieve must return 1 item after committing a matching node; got: {text}"
);
assert!(
text.contains("score="),
"retrieve output must include score for each item; got: {text}"
);
assert!(
text.contains("Findable fact") || text.contains("lifecycle-retrieve"),
"retrieve output must include the node's content; got: {text}"
);
}
#[test]
fn lifecycle_retrieve_where_filter_no_match_returns_zero_items() {
let (mut s, _td) = fresh_server(true);
let _ = tools_call(
&mut s,
"mnem_commit",
json!({
"agent_id": "lc-no-match",
"nodes": [{ "ntype": "Fact", "summary": "Priming the repo" }]
}),
1,
);
let resp = tools_call(
&mut s,
"mnem_retrieve",
json!({
"where": { "topic": "this-value-does-not-exist" }
}),
2,
);
assert_success_response(&resp, "mnem_retrieve");
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("0 item(s)"),
"retrieve with no matches must report '0 item(s)'; got: {text}"
);
}
#[test]
fn lifecycle_tombstone_hides_node_from_retrieve() {
let (mut s, _td) = fresh_server(true);
let commit_resp = tools_call(
&mut s,
"mnem_commit",
json!({
"agent_id": "lc-tombstone-test",
"nodes": [{
"ntype": "Fact",
"summary": "Soon to be forgotten",
"props": { "topic": "tombstone-lifecycle" }
}]
}),
1,
);
assert_success_response(&commit_resp, "mnem_commit");
let commit_text = commit_resp["result"]["content"][0]["text"]
.as_str()
.unwrap();
let node_id = commit_text
.lines()
.find(|line| line.trim_start().starts_with("- ") && line.contains("Fact"))
.and_then(|line| line.split_whitespace().last())
.expect("commit output must include a '- Fact <uuid>' line")
.to_string();
let ret_before = tools_call(
&mut s,
"mnem_retrieve",
json!({ "where": { "topic": "tombstone-lifecycle" } }),
2,
);
assert_success_response(&ret_before, "mnem_retrieve");
let text_before = ret_before["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text_before.contains("1 item(s)"),
"node must appear in retrieve before tombstone; got: {text_before}"
);
let tomb_resp = tools_call(
&mut s,
"mnem_tombstone_node",
json!({
"agent_id": "lc-tombstone-test",
"id": node_id,
"reason": "lifecycle test forget"
}),
3,
);
assert_success_response(&tomb_resp, "mnem_tombstone_node");
let tomb_text = tomb_resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(
tomb_text.contains("mnem_tombstone_node: ok"),
"tombstone output must say 'mnem_tombstone_node: ok'; got: {tomb_text}"
);
assert!(
tomb_text.contains(&node_id),
"tombstone output must include the node id; got: {tomb_text}"
);
assert!(
tomb_text.contains("lifecycle test forget"),
"tombstone output must include the reason; got: {tomb_text}"
);
assert!(
tomb_text.contains("op_id:"),
"tombstone output must include op_id; got: {tomb_text}"
);
let tomb_op_id_val = tomb_text
.lines()
.find(|line| line.contains("op_id:"))
.and_then(|line| line.splitn(2, "op_id:").nth(1))
.map(str::trim)
.filter(|v| !v.is_empty())
.expect("tombstone op_id: line must have a non-empty value");
assert!(
tomb_op_id_val.len() > 10,
"tombstone op_id value must be CID-shaped (length > 10); got: '{tomb_op_id_val}'"
);
let ret_after = tools_call(
&mut s,
"mnem_retrieve",
json!({ "where": { "topic": "tombstone-lifecycle" } }),
4,
);
assert_success_response(&ret_after, "mnem_retrieve");
let text_after = ret_after["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text_after.contains("0 item(s)"),
"tombstoned node must not appear in retrieve; got: {text_after}"
);
}
#[test]
fn lifecycle_tombstone_already_tombstoned_returns_tool_error() {
let (mut s, _td) = fresh_server(true);
let commit_resp = tools_call(
&mut s,
"mnem_commit",
json!({
"agent_id": "lc-double-tomb",
"nodes": [{ "ntype": "Fact", "summary": "Double tombstone test" }]
}),
1,
);
assert_success_response(&commit_resp, "mnem_commit");
let commit_text = commit_resp["result"]["content"][0]["text"]
.as_str()
.unwrap();
let node_id = commit_text
.lines()
.find(|line| line.trim_start().starts_with("- ") && line.contains("Fact"))
.and_then(|line| line.split_whitespace().last())
.expect("commit output must include a '- Fact <uuid>' line")
.to_string();
let resp1 = tools_call(
&mut s,
"mnem_tombstone_node",
json!({
"agent_id": "lc-double-tomb",
"id": node_id,
"reason": "first tombstone"
}),
2,
);
assert_success_response(&resp1, "mnem_tombstone_node");
let resp2 = tools_call(
&mut s,
"mnem_tombstone_node",
json!({
"agent_id": "lc-double-tomb",
"id": node_id,
"reason": "second tombstone"
}),
3,
);
assert_eq!(resp2["jsonrpc"], "2.0");
assert!(
resp2.get("error").is_none(),
"double-tombstone must not produce JSON-RPC error; got: {resp2:?}"
);
assert_eq!(
resp2["result"]["isError"], true,
"double-tombstone must set isError=true; got: {resp2:?}"
);
let text = resp2["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("already tombstoned"),
"double-tombstone error must mention 'already tombstoned'; got: {text}"
);
}
#[test]
fn lifecycle_delete_removes_node_from_get_node() {
let (mut s, _td) = fresh_server(true);
let commit_resp = tools_call(
&mut s,
"mnem_commit",
json!({
"agent_id": "lc-delete-test",
"nodes": [{ "ntype": "Fact", "summary": "To be deleted" }]
}),
1,
);
assert_success_response(&commit_resp, "mnem_commit");
let commit_text = commit_resp["result"]["content"][0]["text"]
.as_str()
.unwrap();
let node_id = commit_text
.lines()
.find(|line| line.trim_start().starts_with("- ") && line.contains("Fact"))
.and_then(|line| line.split_whitespace().last())
.expect("commit output must include a '- Fact <uuid>' line")
.to_string();
let get_before = tools_call(&mut s, "mnem_get_node", json!({ "id": node_id }), 2);
assert_success_response(&get_before, "mnem_get_node");
let get_before_text = get_before["result"]["content"][0]["text"].as_str().unwrap();
assert!(
get_before_text.contains("To be deleted"),
"get_node must show summary before delete; got: {get_before_text}"
);
let del_resp = tools_call(
&mut s,
"mnem_delete_node",
json!({
"agent_id": "lc-delete-test",
"id": node_id
}),
3,
);
assert_success_response(&del_resp, "mnem_delete_node");
let del_text = del_resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(
del_text.contains("mnem_delete_node: ok"),
"delete output must say 'mnem_delete_node: ok'; got: {del_text}"
);
assert!(
del_text.contains(&node_id),
"delete output must include the node id; got: {del_text}"
);
assert!(
del_text.contains("op_id:"),
"delete output must include op_id; got: {del_text}"
);
let get_after = tools_call(&mut s, "mnem_get_node", json!({ "id": node_id }), 4);
assert_success_response(&get_after, "mnem_get_node");
let get_after_text = get_after["result"]["content"][0]["text"].as_str().unwrap();
assert!(
get_after_text.contains("no node") || get_after_text.contains("not found"),
"get_node must report not found after delete; got: {get_after_text}"
);
}
#[test]
fn lifecycle_delete_nonexistent_node_returns_tool_error() {
let (mut s, _td) = fresh_server(true);
let resp = tools_call(
&mut s,
"mnem_delete_node",
json!({
"agent_id": "lc-delete-absent",
"id": "11111111-1111-1111-1111-111111111111"
}),
1,
);
assert_eq!(resp["jsonrpc"], "2.0");
assert!(
resp.get("error").is_none(),
"delete of absent node must not produce JSON-RPC error; got: {resp:?}"
);
assert_eq!(
resp["result"]["isError"], true,
"delete of absent node must set isError=true; got: {resp:?}"
);
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("no node"),
"delete absent-node error must mention 'no node'; got: {text}"
);
}
#[test]
fn lifecycle_tombstone_missing_id_returns_tool_error() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(
&mut s,
"mnem_tombstone_node",
json!({ "agent_id": "lc-tomb-missing", "reason": "no id given" }),
1,
);
assert_eq!(resp["jsonrpc"], "2.0");
assert!(resp.get("error").is_none());
assert_eq!(
resp["result"]["isError"], true,
"tombstone without 'id' must set isError=true; got: {resp:?}"
);
}
#[test]
fn lifecycle_tombstone_missing_agent_id_returns_tool_error() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(
&mut s,
"mnem_tombstone_node",
json!({
"id": "00000000-0000-0000-0000-000000000000",
"reason": "no agent"
}),
1,
);
assert_eq!(resp["jsonrpc"], "2.0");
assert!(resp.get("error").is_none());
assert_eq!(
resp["result"]["isError"], true,
"tombstone without 'agent_id' must set isError=true; got: {resp:?}"
);
}
#[test]
fn lifecycle_delete_missing_agent_id_returns_tool_error() {
let (mut s, _td) = fresh_server(false);
let resp = tools_call(
&mut s,
"mnem_delete_node",
json!({ "id": "00000000-0000-0000-0000-000000000000" }),
1,
);
assert_eq!(resp["jsonrpc"], "2.0");
assert!(resp.get("error").is_none());
assert_eq!(
resp["result"]["isError"], true,
"delete without 'agent_id' must set isError=true; got: {resp:?}"
);
}
#[test]
fn lifecycle_full_commit_retrieve_tombstone_retrieve() {
let (mut s, _td) = fresh_server(true);
let c = tools_call(
&mut s,
"mnem_commit",
json!({
"agent_id": "lc-full",
"nodes": [{
"ntype": "Event",
"summary": "Full lifecycle event",
"props": { "marker": "full-lifecycle-001" }
}]
}),
1,
);
assert_success_response(&c, "mnem_commit");
let c_text = c["result"]["content"][0]["text"].as_str().unwrap();
let node_id = c_text
.lines()
.find(|line| line.trim_start().starts_with("- ") && line.contains("Event"))
.and_then(|line| line.split_whitespace().last())
.expect("step 1: commit output must include a '- Event <uuid>' line")
.to_string();
let r1 = tools_call(
&mut s,
"mnem_retrieve",
json!({ "where": { "marker": "full-lifecycle-001" } }),
2,
);
assert_success_response(&r1, "mnem_retrieve");
let r1_text = r1["result"]["content"][0]["text"].as_str().unwrap();
assert!(
r1_text.contains("1 item(s)"),
"step 2: retrieve must find committed node; got: {r1_text}"
);
assert!(
r1_text.contains(&node_id),
"step 2: retrieve output must include the node UUID; got: {r1_text}"
);
let t = tools_call(
&mut s,
"mnem_tombstone_node",
json!({
"agent_id": "lc-full",
"id": node_id,
"reason": "full lifecycle tombstone"
}),
3,
);
assert_success_response(&t, "mnem_tombstone_node");
let t_text = t["result"]["content"][0]["text"].as_str().unwrap();
assert!(
t_text.contains("op_id:"),
"step 3: tombstone output must include op_id; got: {t_text}"
);
let t_op_id_val = t_text
.lines()
.find(|line| line.contains("op_id:"))
.and_then(|line| line.splitn(2, "op_id:").nth(1))
.map(str::trim)
.filter(|v| !v.is_empty())
.expect("step 3: tombstone op_id: line must have a non-empty value");
assert!(
t_op_id_val.len() > 10,
"step 3: tombstone op_id value must be CID-shaped (length > 10); got: '{t_op_id_val}'"
);
let r2 = tools_call(
&mut s,
"mnem_retrieve",
json!({ "where": { "marker": "full-lifecycle-001" } }),
4,
);
assert_success_response(&r2, "mnem_retrieve");
let r2_text = r2["result"]["content"][0]["text"].as_str().unwrap();
assert!(
r2_text.contains("0 item(s)"),
"step 4: tombstoned node must not appear in retrieve; got: {r2_text}"
);
}
#[test]
fn lifecycle_selective_tombstone_preserves_other_nodes() {
let (mut s, _td) = fresh_server(true);
let c_keep = tools_call(
&mut s,
"mnem_commit",
json!({
"agent_id": "lc-selective",
"nodes": [{
"ntype": "Fact",
"summary": "Keep this",
"props": { "marker": "selective-keep" }
}]
}),
1,
);
assert_success_response(&c_keep, "mnem_commit");
let keep_text = c_keep["result"]["content"][0]["text"].as_str().unwrap();
let keep_id = keep_text
.lines()
.find(|line| line.trim_start().starts_with("- ") && line.contains("Fact"))
.and_then(|line| line.split_whitespace().last())
.expect("keep commit must include a '- Fact <uuid>' line")
.to_string();
let c_drop = tools_call(
&mut s,
"mnem_commit",
json!({
"agent_id": "lc-selective",
"nodes": [{
"ntype": "Fact",
"summary": "Delete this",
"props": { "marker": "selective-drop" }
}]
}),
2,
);
assert_success_response(&c_drop, "mnem_commit");
let drop_text = c_drop["result"]["content"][0]["text"].as_str().unwrap();
let drop_id = drop_text
.lines()
.find(|line| line.trim_start().starts_with("- ") && line.contains("Fact"))
.and_then(|line| line.split_whitespace().last())
.expect("drop commit must include a '- Fact <uuid>' line")
.to_string();
let t = tools_call(
&mut s,
"mnem_tombstone_node",
json!({
"agent_id": "lc-selective",
"id": drop_id,
"reason": "selective tombstone"
}),
3,
);
assert_success_response(&t, "mnem_tombstone_node");
let r_keep = tools_call(
&mut s,
"mnem_retrieve",
json!({ "where": { "marker": "selective-keep" } }),
4,
);
assert_success_response(&r_keep, "mnem_retrieve");
let r_keep_text = r_keep["result"]["content"][0]["text"].as_str().unwrap();
assert!(
r_keep_text.contains("1 item(s)"),
"non-tombstoned node must still appear in retrieve; got: {r_keep_text}"
);
assert!(
r_keep_text.contains(&keep_id),
"retrieve result must include the kept node's UUID; got: {r_keep_text}"
);
let r_drop = tools_call(
&mut s,
"mnem_retrieve",
json!({ "where": { "marker": "selective-drop" } }),
5,
);
assert_success_response(&r_drop, "mnem_retrieve");
let r_drop_text = r_drop["result"]["content"][0]["text"].as_str().unwrap();
assert!(
r_drop_text.contains("0 item(s)"),
"tombstoned node must not appear in retrieve; got: {r_drop_text}"
);
}
#[test]
fn lifecycle_delete_invisible_to_retrieve() {
let (mut s, _td) = fresh_server(true);
let commit_resp = tools_call(
&mut s,
"mnem_commit",
json!({
"agent_id": "lc-delete-retrieve-test",
"nodes": [{
"ntype": "Fact",
"summary": "Hard-delete retrieve visibility check",
"props": { "delete_test_marker": "hard-delete-verify-xk7q" }
}]
}),
1,
);
assert_success_response(&commit_resp, "mnem_commit");
let commit_text = commit_resp["result"]["content"][0]["text"]
.as_str()
.unwrap();
let retrieve_before = tools_call(
&mut s,
"mnem_retrieve",
json!({ "where": { "delete_test_marker": "hard-delete-verify-xk7q" } }),
2,
);
assert_success_response(&retrieve_before, "mnem_retrieve");
let retrieve_before_text = retrieve_before["result"]["content"][0]["text"]
.as_str()
.unwrap();
assert!(
retrieve_before_text.contains("1 item(s)"),
"retrieve must find node before delete; got: {retrieve_before_text}"
);
let node_id = commit_text
.lines()
.find(|line| line.trim_start().starts_with("- ") && line.contains("Fact"))
.and_then(|line| line.split_whitespace().last())
.expect("commit output must include a '- Fact <uuid>' line")
.to_string();
let del_resp = tools_call(
&mut s,
"mnem_delete_node",
json!({
"agent_id": "lc-delete-retrieve-test",
"id": node_id
}),
3,
);
assert_success_response(&del_resp, "mnem_delete_node");
let retrieve_after = tools_call(
&mut s,
"mnem_retrieve",
json!({ "where": { "delete_test_marker": "hard-delete-verify-xk7q" } }),
4,
);
assert_success_response(&retrieve_after, "mnem_retrieve");
let retrieve_after_text = retrieve_after["result"]["content"][0]["text"]
.as_str()
.unwrap();
assert!(
retrieve_after_text.contains("0 item(s)"),
"mnem_retrieve must return 0 items for hard-deleted node; got: {retrieve_after_text}"
);
}
#[test]
fn lifecycle_retrieve_text_only_without_base_ranker_is_graceful() {
let (mut s, _td) = fresh_server(true);
let commit_resp = tools_call(
&mut s,
"mnem_commit",
json!({
"agent_id": "lc-text-only-test",
"nodes": [{
"ntype": "Fact",
"summary": "Text-only retrieve graceful contract node xv9q"
}]
}),
1,
);
assert_success_response(&commit_resp, "mnem_commit");
let ret_resp = tools_call(
&mut s,
"mnem_retrieve",
json!({
"text": "Text-only retrieve graceful contract node xv9q"
}),
2,
);
assert_eq!(ret_resp["jsonrpc"], "2.0");
assert!(
ret_resp.get("error").is_none(),
"mnem_retrieve with text= only must not produce JSON-RPC error; got: {ret_resp:?}"
);
assert!(
ret_resp["result"]["content"].is_array(),
"mnem_retrieve response must keep content[] shape; got: {ret_resp:?}"
);
assert!(
ret_resp["error"].is_null(),
"mnem_retrieve with text= must not produce a JSON-RPC error; got: {ret_resp}"
);
assert!(
ret_resp["result"]["isError"] != serde_json::json!(true),
"mnem_retrieve with text= must not return isError=true in result; got: {ret_resp}"
);
let ret_text = ret_resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(
ret_text.contains("mnem_retrieve"),
"mnem_retrieve with text= only must produce a valid retrieve response; got: {ret_text}"
);
assert!(
ret_text.contains("item(s)"),
"mnem_retrieve with text= only must report item count; got: {ret_text}"
);
}
#[test]
fn lifecycle_retrieve_text_param_does_not_affect_where_results() {
let (mut s, _td) = fresh_server(true);
let commit_resp = tools_call(
&mut s,
"mnem_commit",
json!({
"agent_id": "lc-text-where-test",
"nodes": [{
"ntype": "Fact",
"summary": "Text param where result parity test",
"props": { "text_test_marker": "lifecycle-text-param-test" }
}]
}),
1,
);
assert_success_response(&commit_resp, "mnem_commit");
let ret_no_text = tools_call(
&mut s,
"mnem_retrieve",
json!({
"where": { "text_test_marker": "lifecycle-text-param-test" }
}),
2,
);
assert_success_response(&ret_no_text, "mnem_retrieve");
let text_no_text = ret_no_text["result"]["content"][0]["text"]
.as_str()
.unwrap();
assert!(
text_no_text.contains("1 item(s)"),
"retrieve with where= only must return 1 item; got: {text_no_text}"
);
let ret_with_text = tools_call(
&mut s,
"mnem_retrieve",
json!({
"where": { "text_test_marker": "lifecycle-text-param-test" },
"text": "lifecycle-text-param-test"
}),
3,
);
assert_success_response(&ret_with_text, "mnem_retrieve");
let text_with_text = ret_with_text["result"]["content"][0]["text"]
.as_str()
.unwrap();
assert!(
text_with_text.contains("1 item(s)"),
"retrieve with where= AND text= must still return 1 item \
(text= is a non-destructive no-op addon to the where filter); got: {text_with_text}"
);
}
#[test]
fn schema_edge_types_are_deduplicated() {
let (mut s, _td) = fresh_server(true);
for (subject, object, id) in [("Alice", "Bob", 1u64), ("Bob", "Carol", 2u64)] {
let resp = tools_call(
&mut s,
"mnem_commit_relation",
json!({
"subject": subject,
"subject_kind": "Entity:Person",
"predicate": "knows",
"object": object,
"object_kind": "Entity:Person",
"agent_id": "schema-dedup-test"
}),
id,
);
assert_success_response(&resp, "mnem_commit_relation");
}
let resp = tools_call(&mut s, "mnem_schema", json!({}), 10);
let text = extract_text(&resp, "mnem_schema");
let knows_count = text.matches("knows").count();
assert_eq!(
knows_count, 1,
"edge type 'knows' should appear exactly once (deduplication); got {knows_count} in: {text}"
);
}
#[test]
fn schema_empty_repo_reports_no_index_set() {
let (mut s, _td) = fresh_server(false); let resp = tools_call(&mut s, "mnem_schema", json!({}), 1);
let text = extract_text(&resp, "mnem_schema");
assert!(
text.contains("no IndexSet"),
"Expected 'no IndexSet' message for empty repo, got: {text}"
);
}
#[test]
fn schema_nodes_only_no_edge_index_shows_not_built() {
let (mut s, _td) = fresh_server(true); let _ = tools_call(
&mut s,
"mnem_commit",
json!({
"summary": "A standalone node with no edges",
"ntype": "Entity:Person",
"agent_id": "test"
}),
1,
);
let resp = tools_call(&mut s, "mnem_schema", json!({}), 2);
let text = extract_text(&resp, "mnem_schema");
assert!(
text.contains("index not built"),
"Expected 'index not built' for node-only commit, got: {text}"
);
assert!(
!text.contains("<none - index present"),
"Should not show empty-index message when no outgoing index CID exists: {text}"
);
}
#[test]
fn schema_multiple_etypes_all_listed() {
let (mut s, _td) = fresh_server(true);
let resp1 = tools_call(
&mut s,
"mnem_commit_relation",
json!({
"subject": "Alice",
"subject_kind": "Entity:Person",
"predicate": "works_at",
"object": "Globex",
"object_kind": "Entity:Organization",
"agent_id": "schema-multi-test"
}),
1,
);
assert_success_response(&resp1, "mnem_commit_relation");
let resp2 = tools_call(
&mut s,
"mnem_commit_relation",
json!({
"subject": "Alice",
"subject_kind": "Entity:Person",
"predicate": "knows",
"object": "Bob",
"object_kind": "Entity:Person",
"agent_id": "schema-multi-test"
}),
2,
);
assert_success_response(&resp2, "mnem_commit_relation");
let resp = tools_call(&mut s, "mnem_schema", json!({}), 3);
let text = extract_text(&resp, "mnem_schema");
assert!(
text.contains("works_at"),
"schema must list 'works_at' edge type; got: {text}"
);
assert!(
text.contains("knows"),
"schema must list 'knows' edge type; got: {text}"
);
assert!(
!text.contains("index not built"),
"schema must not show 'index not built' when edges exist; got: {text}"
);
}