use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU32, Ordering};
static DB_COUNTER: AtomicU32 = AtomicU32::new(0);
struct McpClient {
child: std::process::Child,
stdin: std::process::ChildStdin,
stdout: std::process::ChildStdout,
db_path: String,
}
impl Drop for McpClient {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
for ext in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{}{}", self.db_path, ext));
}
}
}
fn spawn_server() -> McpClient {
let n = DB_COUNTER.fetch_add(1, Ordering::SeqCst);
let db_path = format!("/tmp/test_e2e_{n}.db");
for ext in ["", "-wal", "-shm"] {
let p = format!("{db_path}{ext}");
let _ = std::fs::remove_file(&p);
}
let bin = std::env::var("CARGO_BIN_EXE_MCP_MEMORY")
.unwrap_or_else(|_| "target/debug/mcp-memory".into());
let mut child = Command::new(&bin)
.arg("-f")
.arg(&db_path)
.arg("--transport")
.arg("stdio")
.arg("--log-level")
.arg("error")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn mcp-memory");
McpClient {
stdin: child.stdin.take().unwrap(),
stdout: child.stdout.take().unwrap(),
child,
db_path,
}
}
impl McpClient {
fn send(&mut self, msg: &str) {
use std::io::Write;
writeln!(self.stdin, "{msg}").expect("write to stdin");
self.stdin.flush().expect("flush stdin");
}
fn recv(&mut self) -> String {
use std::io::{BufRead, BufReader};
let mut buf = String::new();
BufReader::new(&mut self.stdout)
.read_line(&mut buf)
.expect("read from stdout");
buf.trim().to_string()
}
fn call_tool(&mut self, name: &str, args: serde_json::Value) -> serde_json::Value {
let req = serde_json::json!({
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": name,
"arguments": args
},
"id": 2
});
let line = serde_json::to_string(&req).expect("serialize request");
self.send(&line);
let resp = self.recv();
serde_json::from_str(&resp).expect("parse response")
}
fn tool_text(&mut self, name: &str, args: serde_json::Value) -> String {
let resp = self.call_tool(name, args);
resp["result"]["content"][0]["text"]
.as_str()
.unwrap_or_else(|| {
panic!("expected result.content[0].text, got: {resp}")
})
.to_string()
}
}
#[test]
fn e2e_create_and_read_graph() {
let mut c = spawn_server();
let text = c.tool_text(
"create_entities",
serde_json::json!({"entities": [
{"name": "Ada", "entityType": "person", "observations": ["mathematician"]}
]}),
);
assert!(!text.contains("error"), "create_entities failed: {text}");
let text = c.tool_text("read_graph", serde_json::json!({}));
assert!(text.contains("Ada"), "read_graph missing Ada: {text}");
let text = c.tool_text("search_nodes", serde_json::json!({"query": "mathematician"}));
assert!(text.contains("Ada"), "search_nodes missing Ada: {text}");
let text = c.tool_text("open_nodes", serde_json::json!({"names": ["Ada"]}));
assert!(text.contains("Ada"), "open_nodes missing Ada: {text}");
}
#[test]
fn e2e_add_delete_observations() {
let mut c = spawn_server();
c.tool_text(
"create_entities",
serde_json::json!({"entities": [
{"name": "E", "entityType": "t", "observations": ["a"]}
]}),
);
let text = c.tool_text(
"add_observations",
serde_json::json!({"observations": [
{"entityName": "E", "contents": ["b", "c"]}
]}),
);
assert!(!text.contains("error"), "add_observations failed: {text}");
let text = c.tool_text("read_graph", serde_json::json!({}));
assert!(text.contains("\"a\""), "read_graph missing a: {text}");
assert!(text.contains("\"b\""), "read_graph missing b: {text}");
assert!(text.contains("\"c\""), "read_graph missing c: {text}");
c.tool_text(
"delete_observations",
serde_json::json!({"deletions": [
{"entityName": "E", "observations": ["b"]}
]}),
);
let text = c.tool_text("read_graph", serde_json::json!({}));
assert!(text.contains("\"a\""), "should still have a: {text}");
assert!(!text.contains("\"b\""), "should not have b: {text}");
assert!(text.contains("\"c\""), "should still have c: {text}");
}
#[test]
fn e2e_relations_and_paths() {
let mut c = spawn_server();
c.tool_text(
"create_entities",
serde_json::json!({"entities": [
{"name": "A", "entityType": "node", "observations": []},
{"name": "B", "entityType": "node", "observations": []},
{"name": "C", "entityType": "node", "observations": []}
]}),
);
c.tool_text(
"create_relations",
serde_json::json!({"relations": [
{"from": "A", "to": "B", "relationType": "edge"},
{"from": "B", "to": "C", "relationType": "edge"}
]}),
);
let text = c.tool_text("find_path", serde_json::json!({"from": "A", "to": "C"}));
assert!(!text.contains("error"), "find_path failed: {text}");
assert!(text.contains("A") && text.contains("C"), "find_path: {text}");
let text = c.tool_text("graph_stats", serde_json::json!({}));
assert!(text.contains("\"entities\":3"), "graph_stats: {text}");
assert!(text.contains("\"relations\":2"), "graph_stats: {text}");
}
#[test]
fn e2e_search_filtered() {
let mut c = spawn_server();
c.tool_text(
"create_entities",
serde_json::json!({"entities": [
{"name": "E1", "entityType": "person", "observations": ["math"]},
{"name": "E2", "entityType": "place", "observations": ["math"]}
]}),
);
let text = c.tool_text("search_nodes", serde_json::json!({"query": "math"}));
assert!(text.contains("E1") && text.contains("E2"), "search both: {text}");
let text = c.tool_text("open_nodes", serde_json::json!({"names": ["E1", "E2"]}));
assert!(text.contains("E1") && text.contains("E2"), "open both: {text}");
}
#[test]
fn e2e_delete_and_stats() {
let mut c = spawn_server();
c.tool_text("create_entities", serde_json::json!({"entities": [
{"name": "X", "entityType": "alpha", "observations": ["x-obs"]},
{"name": "Y", "entityType": "beta", "observations": ["y-obs"]},
{"name": "Z", "entityType": "alpha", "observations": []}
]}));
c.tool_text("create_relations", serde_json::json!({"relations": [
{"from": "X", "to": "Y", "relationType": "linked"},
{"from": "Y", "to": "Z", "relationType": "linked"}
]}));
let st = c.tool_text("graph_stats", serde_json::json!({}));
assert!(st.contains("\"entities\":3"), "3 entities: {st}");
assert!(st.contains("\"relations\":2"), "2 relations: {st}");
let types = c.tool_text("list_entity_types", serde_json::json!({}));
assert!(types.contains("\"count\":2"), "alpha has 2: {types}");
let exist = c.tool_text("entity_exists", serde_json::json!({"names": ["X","Y","Missing"]}));
assert_eq!(exist, "[true,true,false]", "entity_exists: {exist}");
c.tool_text("delete_relations", serde_json::json!({"relations": [
{"from": "X", "to": "Y", "relationType": "linked"}
]}));
let st = c.tool_text("graph_stats", serde_json::json!({}));
assert!(st.contains("\"relations\":1"), "1 relation remains: {st}");
c.tool_text("delete_entities", serde_json::json!({"entityNames": ["Y", "Z"]}));
let st = c.tool_text("graph_stats", serde_json::json!({}));
assert!(st.contains("\"entities\":1"), "1 entity left: {st}");
assert!(st.contains("\"relations\":0"), "0 relations: {st}");
let open = c.tool_text("open_nodes", serde_json::json!({"names": ["X"]}));
assert!(open.contains("x-obs"), "X obs remain: {open}");
let rtypes = c.tool_text("list_relation_types", serde_json::json!({}));
assert!(rtypes.contains("\"type\":\"linked\""), "linked type exists: {rtypes}");
}
#[test]
fn e2e_upsert_merge_and_wipe() {
let mut c = spawn_server();
c.tool_text("create_entities", serde_json::json!({"entities": [
{"name": "Src", "entityType": "old", "observations": ["a", "b"]},
{"name": "Tgt", "entityType": "old", "observations": ["c"]}
]}));
c.tool_text("upsert_entities", serde_json::json!({"entities": [
{"name": "Tgt", "entityType": "new", "observations": ["c", "d"]}
]}));
let open = c.tool_text("open_nodes", serde_json::json!({"names": ["Tgt"]}));
assert!(open.contains("new"), "type changed: {open}");
assert!(open.contains("\"c\""), "c preserved: {open}");
assert!(open.contains("\"d\""), "d added: {open}");
c.tool_text("merge_entities", serde_json::json!({"source": "Src", "target": "Tgt"}));
let st = c.tool_text("graph_stats", serde_json::json!({}));
assert!(st.contains("\"entities\":1"), "only Tgt remains: {st}");
let open = c.tool_text("open_nodes", serde_json::json!({"names": ["Tgt"]}));
assert!(open.contains("\"a\""), "merged a: {open}");
assert!(open.contains("\"d\""), "merged d: {open}");
let deg = c.tool_text("degree", serde_json::json!({"name": "Tgt"}));
assert!(deg.contains("\"degree\":0"), "degree 0: {deg}");
let exp = c.tool_text("export_graph", serde_json::json!({"format": "json"}));
assert!(exp.contains("Tgt"), "export contains Tgt: {exp}");
assert!(exp.contains("new"), "export contains new type: {exp}");
}
#[test]
fn e2e_relations_and_describe() {
let mut c = spawn_server();
c.tool_text("create_entities", serde_json::json!({"entities": [
{"name": "A", "entityType": "t", "observations": []},
{"name": "B", "entityType": "t", "observations": []},
{"name": "C", "entityType": "t", "observations": []}
]}));
c.tool_text("create_relations", serde_json::json!({"relations": [
{"from": "A", "to": "B", "relationType": "knows"},
{"from": "B", "to": "C", "relationType": "knows"},
{"from": "A", "to": "C", "relationType": "likes"}
]}));
let r = c.tool_text("search_relations", serde_json::json!({"from": "A"}));
assert!(r.contains("knows"), "A→B knows: {r}");
assert!(r.contains("likes"), "A→C likes: {r}");
let r = c.tool_text("search_relations", serde_json::json!({"from": "A", "relationType": "likes"}));
assert!(!r.contains("knows"), "filtered knows out: {r}");
assert!(r.contains("likes"), "filtered likes in: {r}");
let r = c.tool_text("search_relations", serde_json::json!({"to": "C"}));
assert!(r.contains("knows"), "B→C knows: {r}");
assert!(r.contains("likes"), "A→C likes: {r}");
let n = c.tool_text("get_neighbors", serde_json::json!({"name": "A", "direction": "OUTGOING"}));
assert!(n.contains("B"), "A neighbor B: {n}");
assert!(n.contains("C"), "A neighbor C: {n}");
let n = c.tool_text("get_neighbors", serde_json::json!({"name": "C", "direction": "INCOMING"}));
assert!(n.contains("A"), "C incoming A: {n}");
assert!(n.contains("B"), "C incoming B: {n}");
let d = c.tool_text("describe_entity", serde_json::json!({"name": "A"}));
assert!(d.contains("\"name\":\"A\""), "desc has A: {d}");
let deg = c.tool_text("degree", serde_json::json!({"name": "A"}));
assert!(deg.contains("\"degree\":2"), "A degree 2: {deg}");
let deg = c.tool_text("degree", serde_json::json!({"name": "B", "direction": "BOTH"}));
assert!(deg.contains("\"degree\":2"), "B degree 2: {deg}");
let deg = c.tool_text("degree", serde_json::json!({"name": "A", "direction": "INCOMING"}));
assert!(deg.contains("\"degree\":0"), "A incoming 0: {deg}");
let p = c.tool_text("find_all_paths", serde_json::json!({"from": "A", "to": "C"}));
assert!(p.contains("A"), "paths include A: {p}");
assert!(p.contains("C"), "paths include C: {p}");
let bg = c.tool_text("batch_get_entities", serde_json::json!({"names": ["A", "B", "C"]}));
assert!(bg.contains("A") && bg.contains("B") && bg.contains("C"), "batch get all: {bg}");
}
#[test]
fn e2e_read_graph_relations_scoped_to_page() {
let mut c = spawn_server();
c.tool_text("create_entities", serde_json::json!({"entities": [
{"name": "A", "entityType": "n", "observations": []},
{"name": "B", "entityType": "n", "observations": []},
{"name": "C", "entityType": "n", "observations": []}
]}));
c.tool_text("create_relations", serde_json::json!({"relations": [
{"from": "A", "to": "B", "relationType": "edge"},
{"from": "B", "to": "C", "relationType": "edge"}
]}));
let full = c.tool_text("read_graph", serde_json::json!({}));
let v: serde_json::Value = serde_json::from_str(&full).unwrap();
assert_eq!(v["entities"].as_array().unwrap().len(), 3, "all entities: {full}");
assert_eq!(v["relations"].as_array().unwrap().len(), 2, "both edges: {full}");
let page = c.tool_text("read_graph", serde_json::json!({"limit": 1}));
let v: serde_json::Value = serde_json::from_str(&page).unwrap();
assert_eq!(v["entities"].as_array().unwrap().len(), 1, "one entity: {page}");
assert_eq!(v["relations"].as_array().unwrap().len(), 0, "no scoped edges: {page}");
}
#[test]
fn e2e_search_edge_cases() {
let mut c = spawn_server();
let s = c.tool_text("search_nodes", serde_json::json!({"query": "anything"}));
assert_eq!(s, "[]", "empty search: {s}");
c.tool_text("create_entities", serde_json::json!({"entities": [
{"name": "Alice", "entityType": "person", "observations": ["likes math"]},
{"name": "Bob", "entityType": "person", "observations": ["likes math", "likes science"]}
]}));
let s = c.tool_text("search_nodes", serde_json::json!({"query": "likes", "entityType": "person"}));
assert!(s.contains("Alice"), "filtered search Alice: {s}");
let s = c.tool_text("search_nodes", serde_json::json!({"query": "likes", "offset": 1, "limit": 1}));
assert!(!s.contains("Alice"), "offset skips Alice: {s}");
assert!(s.contains("Bob"), "limit includes Bob: {s}");
let g = c.tool_text("read_graph", serde_json::json!({"type": "person"}));
assert!(g.contains("Alice"), "filtered graph: {g}");
let g = c.tool_text("read_graph", serde_json::json!({"offset": 1, "limit": 1}));
assert!(!g.contains("Alice"), "offset graph: {g}");
let d = c.tool_text("describe_entity", serde_json::json!({"name": "Alice"}));
assert!(d.contains("Alice"), "describe: {d}");
}