1use crate::json::{
52 edge_history_result_json, namespace_arg, node_history_json, node_info_json, params_from_json,
53 parse_ingest_edges, result_set_json, rule_def_from_json, stamp_namespace, stamp_namespace_row,
54};
55use core_api::{
56 json_to_rows, json_to_value, AsOfScope, AutoFk, GraphError, IngestOptions, MaskMode, NodeMask,
57 SharedDb, Value, NS_PROP,
58};
59use serde_json::{json, Value as Js};
60use std::collections::BTreeMap;
61use std::io::{self, BufRead, Write};
62use std::path::{Path, PathBuf};
63
64pub fn run_mcp_stdio(
71 db: SharedDb,
72 db_dir: Option<PathBuf>,
73 reader: impl BufRead,
74 writer: impl Write,
75) -> io::Result<()> {
76 run_mcp_stdio_with(db, db_dir, false, reader, writer)
77}
78
79pub fn run_mcp_stdio_with(
91 db: SharedDb,
92 db_dir: Option<PathBuf>,
93 all_tools: bool,
94 mut reader: impl BufRead,
95 mut writer: impl Write,
96) -> io::Result<()> {
97 let surface = surface_of(&db);
98 let mut buf = Vec::new();
99 loop {
100 buf.clear();
101 let n = reader.read_until(b'\n', &mut buf)?;
102 if n == 0 {
103 return Ok(());
104 }
105 match std::str::from_utf8(&buf) {
106 Ok(s) if s.trim().is_empty() => continue,
107 Ok(s) => handle_line(
108 &db,
109 db_dir.as_deref(),
110 all_tools,
111 surface,
112 s.trim(),
113 &mut writer,
114 )?,
115 Err(_) => write_error(&mut writer, None, -32700, "Parse error")?,
116 }
117 }
118}
119
120fn handle_line(
121 db: &SharedDb,
122 db_dir: Option<&Path>,
123 all_tools: bool,
124 surface: Surface,
125 line: &str,
126 writer: &mut impl Write,
127) -> io::Result<()> {
128 let msg: Js = match serde_json::from_str(line) {
129 Ok(v) => v,
130 Err(_) => return write_error(writer, None, -32700, "Parse error"),
131 };
132 let Some(obj) = msg.as_object() else {
133 return write_error(writer, None, -32600, "Invalid Request");
134 };
135 let is_request = obj.contains_key("id");
136 let id = obj.get("id").cloned();
137 let method = match obj.get("method").and_then(Js::as_str) {
138 Some(m) => m,
139 None => {
140 if is_request {
141 write_error(writer, id, -32600, "Invalid Request")?;
142 }
143 return Ok(());
144 }
145 };
146 match method {
147 "initialize" => {
148 if is_request {
149 write_result(writer, id, initialize_result())?;
150 }
151 }
152 "notifications/initialized" => {
153 if is_request {
154 write_result(writer, id, json!({}))?;
155 }
156 }
157 "tools/list" => {
158 if is_request {
159 write_result(writer, id, tools_list(all_tools, surface))?;
160 }
161 }
162 "tools/call" => {
163 if is_request {
164 match dispatch_call(db, db_dir, obj.get("params")) {
165 CallOutcome::Protocol { code, message } => {
166 write_error(writer, id, code, &message)?;
167 }
168 CallOutcome::ToolOk(payload) => {
169 write_result(writer, id, tool_ok(payload))?;
170 }
171 CallOutcome::TaskOk { text } => {
172 write_result(writer, id, task_ok(&text))?;
173 }
174 CallOutcome::ToolErr(message) => {
175 write_result(writer, id, tool_err(&message))?;
176 }
177 }
178 }
179 }
180 _ => {
181 if is_request {
182 write_error(writer, id, -32601, "Method not found")?;
183 }
184 }
185 }
186 Ok(())
187}
188
189pub(crate) enum CallOutcome {
190 Protocol {
191 code: i64,
192 message: String,
193 },
194 ToolOk(Js),
196 TaskOk {
199 text: String,
200 },
201 ToolErr(String),
202}
203
204fn dispatch_call(db: &SharedDb, db_dir: Option<&Path>, params: Option<&Js>) -> CallOutcome {
205 let Some(params) = params.and_then(Js::as_object) else {
206 return protocol_invalid();
207 };
208 let Some(name) = params.get("name").and_then(Js::as_str) else {
209 return protocol_invalid();
210 };
211 let empty = json!({});
212 let args = match params.get("arguments") {
213 None => &empty,
214 Some(a) if a.is_object() => a,
215 Some(_) => return protocol_invalid(),
216 };
217 if let Some(outcome) = crate::mcp_tasks::dispatch(db, db_dir, name, args) {
219 return outcome;
220 }
221 match name {
222 "query" => tool_query(db, args),
223 "ingest_json" => tool_ingest(db, args),
224 "create_rule" => tool_create_rule(db, args),
225 "explain" => tool_explain(db, args),
226 "stats" => tool_stats(db, args),
227 "node_info" => tool_node_info(db, args),
228 "upsert_entity" => tool_upsert_entity(db, args),
229 "find_similar" => tool_find_similar(db, args),
230 "hybrid_search" => tool_hybrid_search(db, args),
231 "node_history" => tool_node_history(db, args),
232 "edge_history" => tool_edge_history(db, args),
233 "was_linked" => tool_was_linked(db, args),
234 "rename_node" => tool_rename_node(db, args),
235 _ => protocol_invalid(),
236 }
237}
238
239fn protocol_invalid() -> CallOutcome {
240 CallOutcome::Protocol {
241 code: -32602,
242 message: "Invalid params".into(),
243 }
244}
245
246fn tool_query(db: &SharedDb, args: &Js) -> CallOutcome {
247 let Some(cypher) = args.get("cypher").and_then(Js::as_str) else {
248 return CallOutcome::ToolErr("missing cypher".into());
249 };
250 let params = match params_from_json(args.get("params")) {
251 Ok(p) => p,
252 Err(e) => return CallOutcome::ToolErr(e),
253 };
254
255 let role = match args.get("role") {
261 None | Some(Js::Null) => None,
262 Some(Js::String(s)) if !s.is_empty() => Some(s.as_str()),
263 Some(_) => return CallOutcome::ToolErr("role must be a non-empty string".into()),
264 };
265 let mask_keys = match args.get("mask") {
266 None => None,
267 Some(v) => match mask_key_list(v) {
268 Ok(keys) => Some(keys),
269 Err(e) => return CallOutcome::ToolErr(e),
270 },
271 };
272 if role.is_some() && mask_keys.is_some() {
273 return CallOutcome::ToolErr("pass role or mask, not both".into());
274 }
275
276 let namespace = match namespace_arg(args.get("namespace")) {
283 Ok(n) => n,
284 Err(e) => return CallOutcome::ToolErr(e),
285 };
286
287 let as_of = match args.get("as_of") {
291 None | Some(Js::Null) => None,
292 Some(v) => match v.as_u64() {
293 Some(n) => Some(n),
294 None => {
295 return CallOutcome::ToolErr(
296 "as_of must be a non-negative integer commit index".into(),
297 )
298 }
299 },
300 };
301
302 if let Some(commit) = as_of {
303 if args
306 .get("stub_hidden")
307 .and_then(|v| v.as_bool())
308 .unwrap_or(false)
309 {
310 return CallOutcome::ToolErr(
311 "as_of (time-travel) does not compose with stub_hidden".into(),
312 );
313 }
314 let scope = match (role, &mask_keys) {
315 (Some(role), _) => AsOfScope::Role(role),
316 (None, Some(keys)) => AsOfScope::Keys(keys),
317 (None, None) => match namespace.as_deref() {
318 Some(ns) => AsOfScope::Namespace(ns),
320 None => {
321 return match db.read().query_at(commit, cypher, ¶ms) {
322 Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
323 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
324 }
325 }
326 },
327 };
328 let g = db.read();
329 let out = match (namespace.as_deref(), role.is_some() || mask_keys.is_some()) {
330 (Some(ns), true) => g.query_at_scoped_in_namespace(commit, cypher, ¶ms, scope, ns),
332 _ => g.query_at_scoped(commit, cypher, ¶ms, scope),
333 };
334 return match out {
335 Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
336 Err(GraphError::KeyNotFound { key }) if key.starts_with("role:") => {
337 CallOutcome::ToolErr(format!("unknown role '{}'", &key["role:".len()..]))
338 }
339 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
340 };
341 }
342
343 if role.is_some() || mask_keys.is_some() || namespace.is_some() {
344 let stub_hidden = args
345 .get("stub_hidden")
346 .and_then(|v| v.as_bool())
347 .unwrap_or(false);
348 let g = db.read();
349 let mask = match (role, &mask_keys) {
350 (Some(role), _) => match g.mask_for_role(role) {
351 Ok(m) => m,
352 Err(GraphError::KeyNotFound { .. }) => {
355 return CallOutcome::ToolErr(format!("unknown role '{role}'"))
356 }
357 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
358 },
359 (None, Some(keys)) => NodeMask::from_keys(&*g, keys.iter().map(String::as_str)),
360 (None, None) => g.mask_for_namespace(
362 namespace
363 .as_deref()
364 .expect("one of the three is Some in this branch"),
365 ),
366 };
367 let mask = match (namespace.as_deref(), role.is_some() || mask_keys.is_some()) {
371 (Some(ns), true) => mask.intersect(&g.mask_for_namespace(ns)),
372 _ => mask,
373 };
374 let mask = if stub_hidden {
375 mask.with_mode(MaskMode::Stub)
376 } else {
377 mask
378 };
379 return match g.query_masked(cypher, ¶ms, &mask) {
380 Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
381 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
382 };
383 }
384
385 let is_write = match core_api::is_write_query(cypher) {
386 Ok(b) => b,
387 Err(e) => return CallOutcome::ToolErr(e),
388 };
389 let rs = if is_write {
390 let mut g = db.write();
391 g.query_write(cypher, ¶ms)
392 } else {
393 let g = db.read();
394 g.query(cypher, ¶ms)
395 };
396 match rs {
397 Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
398 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
399 }
400}
401
402fn mask_key_list(mask: &Js) -> Result<Vec<String>, String> {
405 let arr = mask
406 .as_array()
407 .ok_or_else(|| "mask must be an array of strings".to_string())?;
408 arr.iter()
409 .map(|v| {
410 v.as_str()
411 .map(str::to_string)
412 .ok_or_else(|| "mask must be an array of strings".to_string())
413 })
414 .collect()
415}
416
417fn tool_ingest(db: &SharedDb, args: &Js) -> CallOutcome {
418 let Some(label) = args.get("label").and_then(Js::as_str) else {
419 return CallOutcome::ToolErr("missing label".into());
420 };
421 let Some(rows_json) = args.get("rows_json").and_then(Js::as_str) else {
422 return CallOutcome::ToolErr("missing rows_json".into());
423 };
424 let mut opts = IngestOptions::default();
425 if let Some(kf) = args.get("key_field") {
426 match kf.as_str() {
427 Some(s) => opts.key_field = s.to_string(),
428 None => return CallOutcome::ToolErr("key_field must be a string".into()),
429 }
430 }
431 if let Some(suf) = args.get("auto_fk_suffix") {
432 match suf.as_str() {
433 Some(s) => {
434 opts.auto_fk = AutoFk::Auto {
435 suffix: s.to_string(),
436 }
437 }
438 None => return CallOutcome::ToolErr("auto_fk_suffix must be a string".into()),
439 }
440 }
441 let edges = match args.get("edges") {
442 None | Some(Js::Null) => Vec::new(),
443 Some(raw) => match parse_ingest_edges(raw) {
444 Ok(e) => e,
445 Err(e) => return CallOutcome::ToolErr(e),
446 },
447 };
448 let parsed: Js = match serde_json::from_str(rows_json) {
449 Ok(v) => v,
450 Err(e) => {
451 return CallOutcome::ToolErr(graph_err_msg(GraphError::IngestError {
452 detail: e.to_string(),
453 }))
454 }
455 };
456 let mut converted = match json_to_rows(&parsed) {
457 Ok(c) => c,
458 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
459 };
460 let namespace = match namespace_arg(args.get("namespace")) {
462 Ok(n) => n,
463 Err(e) => return CallOutcome::ToolErr(e),
464 };
465 if let Err(e) = stamp_namespace(&mut converted.rows, namespace.as_deref()) {
466 return CallOutcome::ToolErr(e);
467 }
468 let taken = std::mem::take(&mut converted.rows);
469 let report = {
470 let mut g = db.write();
471 g.ingest_with_edges(label, taken, &opts, &edges)
472 };
473 match report.map(|r| converted.into_report(r)) {
474 Ok(r) => match serde_json::to_value(&r) {
475 Ok(v) => CallOutcome::ToolOk(v),
476 Err(e) => CallOutcome::ToolErr(e.to_string()),
477 },
478 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
479 }
480}
481
482fn tool_create_rule(db: &SharedDb, args: &Js) -> CallOutcome {
483 let def = match rule_def_from_json(args.clone()) {
484 Ok(d) => d,
485 Err(e) => return CallOutcome::ToolErr(e),
486 };
487 let name = def.name.clone();
488 let res = {
489 let mut g = db.write();
490 g.create_rule(def)
491 };
492 if let Err(e) = res {
493 return CallOutcome::ToolErr(graph_err_msg(e));
494 }
495 let building = db
500 .read()
501 .builds_in_progress()
502 .into_iter()
503 .find(|b| b.rule == name);
504 match building {
505 Some(b) => CallOutcome::ToolOk(json!({
506 "ok": true,
507 "name": name,
508 "building": {"indexed": b.indexed, "total": b.total},
509 "note": format!(
510 "the vector index for {name:?} is still being built ({}/{} vectors); \
511 this rule derives no edges until it finishes. Every write advances it, \
512 and `mushroomdb build-index <db-dir>` finishes it now. Poll `stats` — \
513 the rule's `building` field disappears when its edges are in.",
514 b.indexed, b.total
515 ),
516 })),
517 None => CallOutcome::ToolOk(json!({"ok": true, "name": name})),
518 }
519}
520
521fn tool_explain(db: &SharedDb, args: &Js) -> CallOutcome {
522 let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
523 return CallOutcome::ToolErr("missing a".into());
524 };
525 let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
526 return CallOutcome::ToolErr("missing b".into());
527 };
528 let out = {
529 let g = db.read();
530 g.explain(a, b)
531 };
532 match out {
533 Ok(v) => match serde_json::to_value(&v) {
534 Ok(j) => CallOutcome::ToolOk(j),
535 Err(e) => CallOutcome::ToolErr(e.to_string()),
536 },
537 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
538 }
539}
540
541fn tool_stats(db: &SharedDb, args: &Js) -> CallOutcome {
551 let role = match args.get("role") {
552 None | Some(Js::Null) => None,
553 Some(Js::String(s)) if !s.is_empty() => Some(s.clone()),
554 Some(_) => return CallOutcome::ToolErr("role must be a non-empty string".into()),
555 };
556 let namespace = match namespace_arg(args.get("namespace")) {
557 Ok(n) => n,
558 Err(e) => return CallOutcome::ToolErr(e),
559 };
560 let (snap, role_def) = {
561 let g = db.read();
562 let def = match &role {
563 Some(r) => {
564 if let Err(e) = g.mask_for_role(r) {
571 return match e {
572 GraphError::KeyNotFound { .. } => {
573 CallOutcome::ToolErr(format!("unknown role '{r}'"))
574 }
575 other => CallOutcome::ToolErr(graph_err_msg(other)),
576 };
577 }
578 g.roles().into_iter().find(|d| &d.name == r)
579 }
580 None => None,
581 };
582 (g.stats(), def)
583 };
584 let mut snap = snap;
585 if role_def.is_some() || namespace.is_some() {
586 snap.namespaces.retain(|n| {
587 role_def.as_ref().is_none_or(|d| d.sees_namespace(&n.name))
588 && namespace.as_deref().is_none_or(|ns| ns == n.name)
589 });
590 }
591 match serde_json::to_value(&snap) {
592 Ok(v) => CallOutcome::ToolOk(v),
593 Err(e) => CallOutcome::ToolErr(e.to_string()),
594 }
595}
596
597fn tool_node_info(db: &SharedDb, args: &Js) -> CallOutcome {
598 let Some(key) = args.get("key").and_then(Js::as_str) else {
599 return CallOutcome::ToolErr("missing key".into());
600 };
601 let info = {
602 let g = db.read();
603 g.node_info(key)
604 };
605 match info {
606 Some(info) => CallOutcome::ToolOk(node_info_json(&info)),
607 None => CallOutcome::ToolErr(graph_err_msg(GraphError::KeyNotFound {
608 key: key.to_string(),
609 })),
610 }
611}
612
613fn tool_upsert_entity(db: &SharedDb, args: &Js) -> CallOutcome {
638 let Some(key) = args.get("key").and_then(Js::as_str) else {
639 return CallOutcome::ToolErr("missing key".into());
640 };
641 let label_opt = args.get("label").and_then(Js::as_str);
642 let Some(props_obj) = args.get("props").and_then(Js::as_object) else {
643 return CallOutcome::ToolErr("missing props".into());
644 };
645 let namespace = match namespace_arg(args.get("namespace")) {
646 Ok(n) => n,
647 Err(e) => return CallOutcome::ToolErr(e),
648 };
649
650 let mut row: BTreeMap<String, Value> = BTreeMap::new();
654 for (field, json_val) in props_obj {
655 if field == "id" {
656 continue;
657 }
658 match json_to_value(json_val.clone()) {
659 Some(v) => {
660 row.insert(field.clone(), v);
661 }
662 None => {
663 return CallOutcome::ToolErr(format!("prop {field} is not a supported value type"))
664 }
665 }
666 }
667 if let Some(ns) = namespace.as_deref() {
668 if let Err(e) = stamp_namespace_row(&mut row, ns) {
669 return CallOutcome::ToolErr(e);
670 }
671 }
672
673 let exists = {
674 let g = db.read();
675 g.has_node(key)
676 };
677
678 if exists {
679 let mut g = db.write();
680 let mut to_set: Vec<(String, Value)> = Vec::new();
681 for (field, v) in row {
682 if field == NS_PROP && Some(&v) == g.namespace_of(key).map(Value::Str).as_ref() {
688 continue;
689 }
690 to_set.push((field, v));
691 }
692 let count = to_set.len();
693 if let Err(e) = g.set_props(key, to_set) {
694 return CallOutcome::ToolErr(graph_err_msg(e));
695 }
696 CallOutcome::ToolOk(json!({
697 "ok": true,
698 "key": key,
699 "created": false,
700 "updated_fields": count
701 }))
702 } else {
703 let Some(label) = label_opt else {
704 return CallOutcome::ToolErr("label required when creating a new entity".into());
705 };
706 row.insert("id".to_string(), Value::Str(key.to_string()));
707 let opts = IngestOptions {
708 key_field: "id".to_string(),
709 auto_fk: AutoFk::Off,
710 };
711 let mut g = db.write();
712 match g.ingest(label, vec![row], &opts) {
713 Ok(_) => CallOutcome::ToolOk(json!({ "ok": true, "key": key, "created": true })),
714 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
715 }
716 }
717}
718
719fn tool_find_similar(db: &SharedDb, args: &Js) -> CallOutcome {
726 let mask_keys: Option<Vec<String>> = if let Some(mask_val) = args.get("mask") {
729 match mask_val.as_array() {
730 Some(arr) => {
731 let mut ks: Vec<String> = Vec::with_capacity(arr.len());
732 for v in arr {
733 match v.as_str() {
734 Some(s) => ks.push(s.to_string()),
735 None => {
736 return CallOutcome::ToolErr("mask must be an array of strings".into())
737 }
738 }
739 }
740 Some(ks)
741 }
742 None => return CallOutcome::ToolErr("mask must be an array of strings".into()),
743 }
744 } else {
745 None
746 };
747
748 if let Some(vec_js) = args.get("vector").and_then(Js::as_array) {
751 let q: Vec<f64> = vec_js.iter().filter_map(|v| v.as_f64()).collect();
752 if q.is_empty() {
753 return CallOutcome::ToolErr("vector must be a non-empty array of numbers".into());
754 }
755 let field = args
756 .get("field")
757 .and_then(Js::as_str)
758 .unwrap_or("embedding");
759 let label_str = args.get("label").and_then(Js::as_str).unwrap_or("");
760 let label = if label_str.is_empty() {
761 None
762 } else {
763 Some(label_str)
764 };
765 let k = args
766 .get("k")
767 .and_then(Js::as_u64)
768 .map(|n| n as usize)
769 .unwrap_or(10);
770 let min = args.get("min").and_then(Js::as_f64).unwrap_or(0.8);
771
772 let hits = {
773 let g = db.read();
774 if let Some(ref keys) = mask_keys {
775 let node_mask = NodeMask::from_keys(&*g, keys.iter().map(String::as_str));
776 g.find_similar_vector_masked(field, label, &q, k, min, &node_mask)
777 } else {
778 g.find_similar_vector(field, label, &q, k, min)
779 }
780 };
781 let results: Vec<Js> = hits
782 .into_iter()
783 .map(|(key, score)| json!({ "key": key, "score": score }))
784 .collect();
785 return CallOutcome::ToolOk(json!({
786 "mode": "vector",
787 "field": field,
788 "label": label,
789 "k": k,
790 "min": min,
791 "results": results
792 }));
793 }
794
795 let Some(key) = args.get("key").and_then(Js::as_str) else {
797 return CallOutcome::ToolErr("missing key (or provide vector for vector search)".into());
798 };
799 let edge_type = args
800 .get("edge_type")
801 .and_then(Js::as_str)
802 .unwrap_or("SIMILAR");
803 let limit = args
804 .get("limit")
805 .and_then(Js::as_u64)
806 .map(|n| n as usize)
807 .unwrap_or(10);
808
809 if let Some(ref mask) = mask_keys {
812 let mask_set: std::collections::HashSet<&str> = mask.iter().map(String::as_str).collect();
813 if !mask_set.contains(key) {
814 return CallOutcome::ToolErr(graph_err_msg(GraphError::KeyNotFound {
815 key: key.into(),
816 }));
817 }
818 let out = {
819 let g = db.read();
820 g.node_edges(key)
821 };
822 return match out {
823 Ok(edges) => {
824 let similar: Vec<Js> = edges
825 .iter()
826 .filter(|e| e.edge_type == edge_type)
827 .filter(|e| {
828 let neighbor_key = if e.src_key == key {
830 &e.dst_key
831 } else {
832 &e.src_key
833 };
834 mask_set.contains(neighbor_key.as_str())
835 })
836 .take(limit)
837 .map(|e| {
838 let neighbor_key = if e.src_key == key {
839 &e.dst_key
840 } else {
841 &e.src_key
842 };
843 let direction = if e.src_key == key { "out" } else { "in" };
844 json!({
845 "neighbor_key": neighbor_key,
846 "direction": direction,
847 "edge_type": e.edge_type,
848 "derived": e.derived,
849 })
850 })
851 .collect();
852 CallOutcome::ToolOk(json!({
853 "key": key,
854 "edge_type": edge_type,
855 "similar": similar
856 }))
857 }
858 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
859 };
860 }
861
862 let out = {
863 let g = db.read();
864 g.node_edges(key)
865 };
866 match out {
867 Ok(edges) => {
868 let similar: Vec<Js> = edges
869 .iter()
870 .filter(|e| e.edge_type == edge_type)
871 .take(limit)
872 .map(|e| {
873 let neighbor_key = if e.src_key == key {
874 &e.dst_key
875 } else {
876 &e.src_key
877 };
878 let direction = if e.src_key == key { "out" } else { "in" };
879 json!({
880 "neighbor_key": neighbor_key,
881 "direction": direction,
882 "edge_type": e.edge_type,
883 "derived": e.derived,
884 })
885 })
886 .collect();
887 CallOutcome::ToolOk(json!({
888 "key": key,
889 "edge_type": edge_type,
890 "similar": similar
891 }))
892 }
893 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
894 }
895}
896
897fn tool_hybrid_search(db: &SharedDb, args: &Js) -> CallOutcome {
898 let Some(query_text) = args.get("query_text").and_then(Js::as_str) else {
899 return CallOutcome::ToolErr("missing required field: query_text".into());
900 };
901 let Some(text_field) = args.get("text_field").and_then(Js::as_str) else {
902 return CallOutcome::ToolErr("missing required field: text_field".into());
903 };
904
905 let vector_field = args
906 .get("vector_field")
907 .and_then(Js::as_str)
908 .unwrap_or("embedding");
909 let label = args.get("label").and_then(Js::as_str);
910 let k = args
911 .get("k")
912 .and_then(Js::as_u64)
913 .map(|n| n as usize)
914 .unwrap_or(10);
915
916 let query_vec: Vec<f64> = args
917 .get("vector")
918 .and_then(Js::as_array)
919 .map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect())
920 .unwrap_or_default();
921
922 let hits = {
923 let g = db.read();
924 g.search_hybrid(text_field, query_text, vector_field, &query_vec, label, k)
925 };
926
927 let results: Vec<Js> = hits
928 .into_iter()
929 .map(|(key, score)| json!({ "key": key, "score": score }))
930 .collect();
931
932 CallOutcome::ToolOk(json!({
933 "query_text": query_text,
934 "text_field": text_field,
935 "vector_field": vector_field,
936 "label": label,
937 "k": k,
938 "results": results
939 }))
940}
941
942fn tool_node_history(db: &SharedDb, args: &Js) -> CallOutcome {
943 let Some(key) = args.get("key").and_then(Js::as_str) else {
944 return CallOutcome::ToolErr("missing key".into());
945 };
946 let g = db.read();
947 let result = match g.node_history(key) {
948 Ok(e) => e,
949 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
950 };
951 CallOutcome::ToolOk(node_history_json(key, &result))
952}
953
954fn tool_edge_history(db: &SharedDb, args: &Js) -> CallOutcome {
955 let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
956 return CallOutcome::ToolErr("missing a".into());
957 };
958 let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
959 return CallOutcome::ToolErr("missing b".into());
960 };
961 let result = {
962 let g = db.read();
963 g.edge_history(a, b)
964 };
965 match result {
966 Ok(hr) => CallOutcome::ToolOk(edge_history_result_json(a, b, &hr)),
967 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
968 }
969}
970
971fn tool_was_linked(db: &SharedDb, args: &Js) -> CallOutcome {
972 let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
973 return CallOutcome::ToolErr("missing a".into());
974 };
975 let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
976 return CallOutcome::ToolErr("missing b".into());
977 };
978 let Some(edge_type) = args
979 .get("edge_type")
980 .and_then(Js::as_str)
981 .filter(|s| !s.is_empty())
982 else {
983 return CallOutcome::ToolErr("missing edge_type".into());
984 };
985 let at_commit = match args.get("at_commit").and_then(Js::as_u64) {
986 Some(n) => n,
987 None => return CallOutcome::ToolErr("missing or invalid at_commit".into()),
988 };
989 let result = {
990 let g = db.read();
991 g.was_linked(a, b, edge_type, at_commit)
992 };
993 match result {
994 Ok(linked) => CallOutcome::ToolOk(json!({
995 "a": a,
996 "b": b,
997 "edge_type": edge_type,
998 "at_commit": at_commit,
999 "linked": linked,
1000 })),
1001 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1002 }
1003}
1004
1005fn tool_rename_node(db: &SharedDb, args: &Js) -> CallOutcome {
1006 let Some(old_key) = args.get("old_key").and_then(Js::as_str) else {
1007 return CallOutcome::ToolErr("missing old_key".into());
1008 };
1009 let Some(new_key) = args.get("new_key").and_then(Js::as_str) else {
1010 return CallOutcome::ToolErr("missing new_key".into());
1011 };
1012 let mut g = db.write();
1013 match g.rename_node(old_key, new_key) {
1014 Ok(()) => CallOutcome::ToolOk(json!({
1015 "ok": true,
1016 "old_key": old_key,
1017 "new_key": new_key,
1018 })),
1019 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1020 }
1021}
1022
1023pub(crate) fn graph_err_msg(e: GraphError) -> String {
1024 match e {
1025 GraphError::QueryError { detail } | GraphError::IngestError { detail } => detail,
1026 other => other.to_string(),
1027 }
1028}
1029
1030fn initialize_result() -> Js {
1031 json!({
1032 "protocolVersion": "2024-11-05",
1033 "capabilities": { "tools": {} },
1034 "serverInfo": { "name": "mushroomdb", "version": env!("CARGO_PKG_VERSION") }
1035 })
1036}
1037
1038const ADVANCED_PREFIX: &str = "Advanced: ";
1044
1045pub const CODE_GRAPH_TOOLS: [&str; 3] = ["explore", "query", "stats"];
1053
1054pub const ASSOCIATION_TOOLS: [&str; 15] = [
1079 "query",
1080 "explain_association",
1081 "neighborhood",
1082 "node_info",
1083 "node_edges",
1084 "was_linked",
1085 "edges_at",
1086 "what_if",
1087 "node_history",
1088 "edge_history",
1089 "find_similar",
1090 "hybrid_search",
1091 "remember",
1092 "recall",
1093 "stats",
1094];
1095
1096#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1102pub(crate) enum Surface {
1103 CodeGraph,
1106 Memory,
1109}
1110
1111impl Surface {
1112 fn listing(self) -> &'static [&'static str] {
1115 match self {
1116 Surface::CodeGraph => &CODE_GRAPH_TOOLS,
1117 Surface::Memory => &ASSOCIATION_TOOLS,
1118 }
1119 }
1120}
1121
1122fn surface_of(db: &SharedDb) -> Surface {
1125 let ingested = {
1126 let g = db.read();
1127 g.has_node(crate::mcp_tasks::SYNC_KEY)
1128 };
1129 if ingested {
1130 Surface::CodeGraph
1131 } else {
1132 Surface::Memory
1133 }
1134}
1135
1136fn tools_list(all: bool, surface: Surface) -> Js {
1154 let mut served: Vec<Js> = crate::mcp_tasks::task_tools();
1155 for mut tool in graph_tools() {
1156 if let Some(d) = tool.get("description").and_then(Js::as_str) {
1157 let prefixed = format!("{ADVANCED_PREFIX}{d}");
1158 tool["description"] = Js::String(prefixed);
1159 }
1160 served.push(tool);
1161 }
1162 if all {
1163 return json!({ "tools": served });
1164 }
1165 let listing = surface.listing();
1166 let mut tools: Vec<Js> = Vec::with_capacity(listing.len());
1167 for name in listing {
1168 let Some(tool) = served
1169 .iter()
1170 .find(|t| t.get("name").and_then(Js::as_str) == Some(*name))
1171 else {
1172 debug_assert!(false, "{surface:?} lists {name}, which is not served");
1173 continue;
1174 };
1175 tools.push(tool.clone());
1176 }
1177 json!({ "tools": tools })
1178}
1179
1180fn graph_tools() -> Vec<Js> {
1183 let Js::Array(tools) = json!([
1184 {
1185 "name": "query",
1186 "description": "Who may see this, and anything else one pattern can answer — run a Cypher query (read or write) against the graph. Pass 'role' to answer as one of the store's roles: only the nodes that role may see, writes refused. 'mask' is the same restriction written out as an explicit key allow-list. Pass 'as_of' to answer from a past commit; it composes with 'role' or with 'mask'. Pass 'namespace' to answer from one namespace only. Cypher dialect: MATCH/WHERE/RETURN, CREATE, MERGE, SET, DELETE, with $named parameters in 'params'. A node's key and label read as properties (n.key, n.label) or as key(n)/labels(n). One MATCH takes comma-separated patterns that share variables — MATCH (t)-[:A]->(c), (t)-[:B]->(c) is the intersection of both, and count(DISTINCT t) after WITH counts each t once. WHERE takes STARTS WITH, ENDS WITH, CONTAINS, IN, and a list subscript (n.location[0]) — which is null when the index is out of range, the property is not a list, or the index is not an integer, so a subscript never errors and never matches.",
1187 "inputSchema": {
1188 "type": "object",
1189 "properties": {
1190 "cypher": { "type": "string", "description": "Cypher query text." },
1191 "params": {
1192 "type": "object",
1193 "description": "Named JSON-scalar query parameters."
1194 },
1195 "mask": {
1196 "type": "array",
1197 "items": { "type": "string" },
1198 "description": "Optional node key allow-list. When present, only these nodes are visible; write statements are rejected."
1199 },
1200 "role": {
1201 "type": "string",
1202 "description": "Answer as this role from the store's roles: only the nodes it may see. A role may also be narrowed by one property test (`status in [...]`), declared in the store's roles."
1203 },
1204 "as_of": {
1205 "type": "integer",
1206 "minimum": 0,
1207 "description": "0-based WAL commit index: answer from the graph as it was at that commit. Composes with 'role' or with 'mask' — never both, which is refused as it is without 'as_of' — and whichever is passed is resolved against the graph as it was then. Deleting a node does not remove it from a role's past, and a role's 'keys' resolve to whichever node held the key at that commit. Writes are refused."
1208 },
1209 "namespace": {
1210 "type": "string",
1211 "description": "Answer only from this namespace. Intersects with 'role' and 'mask' — it can only narrow what they already allow. A role bound to namespaces honours them with no argument here. 'default' is the namespace of every node that names none; a name no node uses answers with nothing."
1212 }
1213 },
1214 "required": ["cypher"]
1215 }
1216 },
1217 {
1218 "name": "ingest_json",
1219 "description": "Ingest a JSON array of objects as nodes of one label.",
1220 "inputSchema": {
1221 "type": "object",
1222 "properties": {
1223 "label": { "type": "string" },
1224 "rows_json": {
1225 "type": "string",
1226 "description": "JSON text of an array of objects."
1227 },
1228 "key_field": { "type": "string" },
1229 "auto_fk_suffix": { "type": "string" },
1230 "edges": {
1231 "type": "array",
1232 "description": "Optional user edges [{edge_type, src, dst}]."
1233 },
1234 "namespace": {
1235 "type": "string",
1236 "description": "Namespace for every node this call creates. Omitted means the 'default' namespace. A row that carries its own 'ns' must name the same namespace. A namespace is set at insert and cannot be changed afterwards."
1237 }
1238 },
1239 "required": ["label", "rows_json"]
1240 }
1241 },
1242 {
1243 "name": "create_rule",
1244 "description": "How should this kind of relationship be derived from now on — declare a rule (RuleDef JSON) and the engine maintains its edges as the data changes. Propose it and show the edges it would derive before creating one.",
1245 "inputSchema": {
1246 "type": "object",
1247 "properties": {
1248 "name": { "type": "string" },
1249 "src_label": { "type": "string" },
1250 "dst_label": { "type": "string" },
1251 "predicate": { "type": "object" },
1252 "edge_type": { "type": "string" },
1253 "weight_prop": {
1254 "type": ["string", "null"],
1255 "description": "Edge property that stores the score (default: weight)."
1256 },
1257 "max_edges": { "type": ["integer", "null"] },
1258 "namespace": {
1259 "type": "string",
1260 "description": "Scope the rule to one namespace: it sees only that namespace's nodes — source, via hop and destination — so every edge it derives stays inside. Omitted means a global rule, which is the only kind that may derive an edge across a boundary."
1261 }
1262 },
1263 "required": ["name", "src_label", "dst_label", "predicate", "edge_type"]
1264 }
1265 },
1266 {
1267 "name": "explain",
1268 "description": "Why are A and B related, as a raw array — the same rule-derived edges explain_association renders, for a caller that wants the JSON without asking.",
1269 "inputSchema": {
1270 "type": "object",
1271 "properties": {
1272 "a": { "type": "string", "minLength": 1 },
1273 "b": { "type": "string", "minLength": 1 }
1274 },
1275 "required": ["a", "b"]
1276 }
1277 },
1278 {
1279 "name": "stats",
1280 "description": "How big is this store — live node, edge and rule counts, plus `history_floor`, the oldest commit history still reaches (0 when nothing has been pruned), and `namespaces`, every namespace with at least one live node and its count. Pass 'role' or 'namespace' to be told about those namespaces only.",
1281 "inputSchema": {
1282 "type": "object",
1283 "properties": {
1284 "role": {
1285 "type": "string",
1286 "description": "Report only the namespaces this role may see. The store-wide counts beside them are unchanged."
1287 },
1288 "namespace": {
1289 "type": "string",
1290 "description": "Report only this namespace. Intersects with 'role'."
1291 }
1292 }
1293 }
1294 },
1295 {
1296 "name": "node_info",
1297 "description": "What is K — its label and every property it holds.",
1298 "inputSchema": {
1299 "type": "object",
1300 "properties": {
1301 "key": { "type": "string" }
1302 },
1303 "required": ["key"]
1304 }
1305 },
1306 {
1307 "name": "upsert_entity",
1308 "description": "Record what is now true about K — insert or update a node by key. If the key exists, updates the supplied properties atomically: every property is checked before any is written, so a refusal leaves the node unchanged. If not, creates a new node with the given label and properties. 'id' in 'props' is ignored on both paths: a created node stores 'id' as its key, and 'rename_node' is the only way to change it. Useful for agent memory: store or refresh an entity without checking existence first.",
1309 "inputSchema": {
1310 "type": "object",
1311 "properties": {
1312 "key": { "type": "string", "description": "Unique node key." },
1313 "label": { "type": "string", "description": "Node label (required when creating a new entity)." },
1314 "props": {
1315 "type": "object",
1316 "description": "Properties to set. Values must be scalars (string, number, bool) or arrays of scalars."
1317 },
1318 "namespace": {
1319 "type": "string",
1320 "description": "Namespace for a node this call creates. Omitted means the 'default' namespace. On a node that already exists, naming the namespace it is in is a no-op and naming another one is refused — a namespace is set at insert and cannot be changed."
1321 }
1322 },
1323 "required": ["key", "props"]
1324 }
1325 },
1326 {
1327 "name": "find_similar",
1328 "description": "What is most like this — two modes: (1) Vector search — provide `vector` (and optionally `field`, `label`, `k`, `min`) to find the k most similar nodes by cosine similarity using the HNSW index when available, brute-force otherwise. (2) Edge traversal — provide `key` (and optionally `edge_type`, `limit`) to return neighbors previously connected by a derived rule edge. Results from mode 2 come only from edges already derived by a VectorSimilar rule. In both modes, the optional `mask` array limits visibility: hidden nodes never appear in results, and a hidden query key in edge mode behaves identically to a nonexistent key.",
1329 "inputSchema": {
1330 "type": "object",
1331 "properties": {
1332 "vector": {
1333 "type": "array",
1334 "items": { "type": "number" },
1335 "description": "Query embedding vector for vector-similarity search. When present, vector-search mode is used and `key` is ignored."
1336 },
1337 "field": { "type": "string", "description": "Property field holding the embedding vectors (default: embedding). Used in vector-search mode." },
1338 "label": { "type": "string", "description": "Restrict search to nodes with this label. Empty string means all labels. Used in vector-search mode." },
1339 "k": { "type": "integer", "description": "Maximum results to return in vector-search mode (default: 10)." },
1340 "min": { "type": "number", "description": "Minimum cosine similarity threshold in vector-search mode (default: 0.8)." },
1341 "mask": {
1342 "type": "array",
1343 "items": { "type": "string" },
1344 "description": "Optional node key allow-list for vector-search mode. When present, only nodes whose key appears in this list are eligible for results. Hidden nodes are excluded before k-truncation. The beam widens until it has k visible hits, then falls back to an exhaustive masked scan at the same cap an exact VectorSimilar rule uses, so the result is not short while more visible hits exist. Unknown keys are silently ignored."
1345 },
1346 "key": { "type": "string", "description": "Source node key for edge-traversal mode." },
1347 "edge_type": { "type": "string", "description": "Edge type to filter by in edge-traversal mode (default: SIMILAR)." },
1348 "limit": { "type": "integer", "description": "Maximum neighbors to return in edge-traversal mode (default: 10)." }
1349 }
1350 }
1351 },
1352 {
1353 "name": "hybrid_search",
1354 "description": "What matches these words and this vector at once — Reciprocal Rank Fusion (RRF) over fulltext + vector results. Provide `query_text` and `text_field` for the fulltext leg. Optionally provide `vector` (embedding array) and `vector_field` (default: embedding) for the vector leg; omitting `vector` gives text-only ranking through the same RRF path. `label` restricts the vector search to nodes with that label (required for brute-force; omit to rely on HNSW rules). `k` controls result count (default: 10). RRF constant is fixed at 60; scores are 1/(60+rank) summed over lists a node appears in.",
1355 "inputSchema": {
1356 "type": "object",
1357 "properties": {
1358 "query_text": { "type": "string", "description": "Fulltext query string." },
1359 "text_field": { "type": "string", "description": "Property field to search with fulltext." },
1360 "vector": {
1361 "type": "array",
1362 "items": { "type": "number" },
1363 "description": "Query embedding vector. Omit for text-only ranking."
1364 },
1365 "vector_field": { "type": "string", "description": "Property field holding embedding vectors (default: embedding)." },
1366 "label": { "type": "string", "description": "Restrict vector search to nodes with this label. Required when relying on brute-force (no HNSW rule covers the field). If omitted, the vector leg always returns empty results (no rule-created HNSW index covers the unlabeled path); ranking is text-only in that case." },
1367 "k": { "type": "integer", "description": "Maximum results to return (default: 10)." }
1368 },
1369 "required": ["query_text", "text_field"]
1370 }
1371 },
1372 {
1373 "name": "node_history",
1374 "description": "What has happened to K — every recorded change to one node, newest last. Events include NodeInserted, PropSet, PropRemoved, EdgeAdded, EdgeRemoved, and NodeDeleted. The response includes `total_commits` (the horizon upper bound) and `horizon`, the oldest commit still retained; events before it are gone. History is WAL-scoped — pre-snapshot commits are not visible.",
1375 "inputSchema": {
1376 "type": "object",
1377 "properties": {
1378 "key": { "type": "string", "description": "Node key to look up." }
1379 },
1380 "required": ["key"]
1381 }
1382 },
1383 {
1384 "name": "edge_history",
1385 "description": "When did A and B become linked, and when did it break — the full add/retract lifecycle for every edge between the two keys. Includes derived (rule-attributed) edges via DerivedEdgeAdded/DerivedEdgeRetracted WAL markers. The response includes `total_commits` (the horizon upper bound) and `horizon`, the oldest commit still retained; events before it are gone.",
1386 "inputSchema": {
1387 "type": "object",
1388 "properties": {
1389 "a": { "type": "string", "minLength": 1, "description": "First node key." },
1390 "b": { "type": "string", "minLength": 1, "description": "Second node key." }
1391 },
1392 "required": ["a", "b"]
1393 }
1394 },
1395 {
1396 "name": "was_linked",
1397 "description": "Were A and B linked at commit C — whether an edge of `edge_type` existed between the two keys (either direction) at that WAL commit. Returns an error when `at_commit` is outside the retained horizon (`horizon..total_commits`).",
1398 "inputSchema": {
1399 "type": "object",
1400 "properties": {
1401 "a": { "type": "string", "minLength": 1, "description": "First node key." },
1402 "b": { "type": "string", "minLength": 1, "description": "Second node key." },
1403 "edge_type": { "type": "string", "minLength": 1, "description": "Edge type to check." },
1404 "at_commit": { "type": "integer", "minimum": 0, "description": "0-based WAL commit index to query." }
1405 },
1406 "required": ["a", "b", "edge_type", "at_commit"]
1407 }
1408 },
1409 {
1410 "name": "rename_node",
1411 "description": "Rename K — the key changes and nothing else does. The dense id and all edges/properties remain stable. Returns 404 if `old_key` does not exist, 409 if `new_key` is already taken.",
1412 "inputSchema": {
1413 "type": "object",
1414 "properties": {
1415 "old_key": { "type": "string", "minLength": 1, "description": "Current node key." },
1416 "new_key": { "type": "string", "minLength": 1, "description": "Desired new node key." }
1417 },
1418 "required": ["old_key", "new_key"]
1419 }
1420 }
1421 ]) else {
1422 unreachable!("the literal above is an array")
1423 };
1424 tools
1425}
1426
1427fn tool_ok(payload: Js) -> Js {
1428 json!({
1429 "content": [{ "type": "text", "text": payload.to_string() }]
1430 })
1431}
1432
1433fn task_ok(text: &str) -> Js {
1441 json!({
1442 "content": [{ "type": "text", "text": text }]
1443 })
1444}
1445
1446fn tool_err(message: &str) -> Js {
1447 json!({
1448 "content": [{ "type": "text", "text": message }],
1449 "isError": true
1450 })
1451}
1452
1453fn write_result(writer: &mut impl Write, id: Option<Js>, result: Js) -> io::Result<()> {
1454 write_json(
1455 writer,
1456 &json!({
1457 "jsonrpc": "2.0",
1458 "id": id.unwrap_or(Js::Null),
1459 "result": result
1460 }),
1461 )
1462}
1463
1464fn write_error(
1465 writer: &mut impl Write,
1466 id: Option<Js>,
1467 code: i64,
1468 message: &str,
1469) -> io::Result<()> {
1470 write_json(
1471 writer,
1472 &json!({
1473 "jsonrpc": "2.0",
1474 "id": id.unwrap_or(Js::Null),
1475 "error": { "code": code, "message": message }
1476 }),
1477 )
1478}
1479
1480fn write_json(writer: &mut impl Write, value: &Js) -> io::Result<()> {
1481 let s = serde_json::to_string(value).map_err(io::Error::other)?;
1482 writeln!(writer, "{s}")?;
1483 writer.flush()
1484}
1485
1486#[cfg(test)]
1491mod tests {
1492 use super::*;
1493 use core_api::{AutoFk, IngestOptions, Predicate, RuleDef, Value};
1494 use std::path::PathBuf;
1495 use std::sync::atomic::{AtomicU64, Ordering};
1496
1497 fn tmp_dir() -> PathBuf {
1498 static SEQ: AtomicU64 = AtomicU64::new(0);
1499 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1500 let d = std::env::temp_dir().join(format!("mcp-test-{}-{}", std::process::id(), n));
1501 let _ = std::fs::remove_dir_all(&d);
1505 d
1506 }
1507
1508 fn demo_db() -> SharedDb {
1510 let db = SharedDb::open(&tmp_dir()).expect("open");
1511 {
1512 let mut g = db.write();
1513 let opts = IngestOptions {
1514 key_field: "id".into(),
1515 auto_fk: AutoFk::Off,
1516 };
1517 let people: Vec<BTreeMap<String, Value>> = vec![
1519 [
1520 ("id", Value::Str("alice".into())),
1521 ("name", Value::Str("Alice".into())),
1522 (
1523 "emb",
1524 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1525 ),
1526 ]
1527 .into_iter()
1528 .map(|(k, v)| (k.to_string(), v))
1529 .collect(),
1530 [
1531 ("id", Value::Str("bob".into())),
1532 ("name", Value::Str("Bob".into())),
1533 (
1534 "emb",
1535 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1536 ),
1537 ]
1538 .into_iter()
1539 .map(|(k, v)| (k.to_string(), v))
1540 .collect(),
1541 ];
1542 g.ingest("Person", people, &opts).expect("ingest");
1543
1544 g.create_rule(RuleDef {
1546 name: "sim_emb".into(),
1547 src_label: "Person".into(),
1548 dst_label: "Person".into(),
1549 predicate: Predicate::VectorSimilar {
1550 field: "emb".into(),
1551 min: 0.9,
1552 },
1553 edge_type: "SIMILAR".into(),
1554 weight_prop: Some("score".into()),
1555 max_edges: None,
1556 approximate: false,
1557 via_label: None,
1558 via_edge: None,
1559 via_dir: None,
1560 namespace: None,
1561 })
1562 .expect("rule");
1563 }
1564 db
1565 }
1566
1567 fn roundtrip(db: &SharedDb, request: &str) -> Js {
1568 roundtrip_with(db, false, request)
1569 }
1570
1571 fn roundtrip_with(db: &SharedDb, all_tools: bool, request: &str) -> Js {
1572 let input = format!("{request}\n");
1573 let mut output = Vec::new();
1574 run_mcp_stdio_with(db.clone(), None, all_tools, input.as_bytes(), &mut output)
1575 .expect("mcp");
1576 let s = std::str::from_utf8(&output).expect("utf8");
1577 serde_json::from_str(s.trim()).expect("json response")
1578 }
1579
1580 fn tool_call(db: &SharedDb, id: u64, tool: &str, args: Js) -> Js {
1581 let req = json!({
1582 "jsonrpc": "2.0",
1583 "id": id,
1584 "method": "tools/call",
1585 "params": { "name": tool, "arguments": args }
1586 });
1587 roundtrip(db, &req.to_string())
1588 }
1589
1590 fn tool_text(resp: &Js) -> Js {
1592 let text = resp["result"]["content"][0]["text"]
1593 .as_str()
1594 .expect("content[0].text");
1595 serde_json::from_str(text).expect("tool text is json")
1596 }
1597
1598 fn is_error(resp: &Js) -> bool {
1599 resp["result"]["isError"].as_bool().unwrap_or(false)
1600 }
1601
1602 fn tool_err_text(resp: &Js) -> String {
1603 resp["result"]["content"][0]["text"]
1604 .as_str()
1605 .unwrap_or("")
1606 .to_string()
1607 }
1608
1609 #[test]
1612 fn test_tools_list_includes_all_expected() {
1613 let db = demo_db();
1614 let resp = roundtrip_with(
1615 &db,
1616 true,
1617 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
1618 );
1619 let tools = resp["result"]["tools"].as_array().expect("tools array");
1620 let names: Vec<&str> = tools
1621 .iter()
1622 .map(|t| t["name"].as_str().expect("name"))
1623 .collect();
1624 for expected in &[
1625 "explore",
1627 "map",
1628 "context",
1629 "impact",
1630 "owners",
1631 "why",
1632 "explain_association",
1633 "node_edges",
1634 "neighborhood",
1635 "edges_at",
1636 "what_if",
1637 "recall",
1638 "remember",
1639 "sync",
1640 "query",
1642 "ingest_json",
1643 "create_rule",
1644 "explain",
1645 "stats",
1646 "node_info",
1647 "upsert_entity",
1648 "find_similar",
1649 "hybrid_search",
1650 "node_history",
1651 "edge_history",
1652 "was_linked",
1653 "rename_node",
1654 ] {
1655 assert!(names.contains(expected), "missing tool: {expected}");
1656 }
1657 assert_eq!(
1658 names.len(),
1659 27,
1660 "expected exactly 27 tools, got {}",
1661 names.len()
1662 );
1663 assert_eq!(
1664 &names[..14],
1665 [
1666 "explore",
1667 "map",
1668 "context",
1669 "impact",
1670 "owners",
1671 "why",
1672 "explain_association",
1673 "node_edges",
1674 "neighborhood",
1675 "edges_at",
1676 "what_if",
1677 "recall",
1678 "remember",
1679 "sync"
1680 ],
1681 "the task tools come first, in order"
1682 );
1683 assert_eq!(names[14], "query", "the graph tools follow them");
1684 }
1685
1686 #[test]
1690 fn tools_list_defaults_to_fifteen_on_a_memory_store() {
1691 let db = demo_db();
1692 let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1693 let names: Vec<&str> = resp["result"]["tools"]
1694 .as_array()
1695 .expect("tools array")
1696 .iter()
1697 .map(|t| t["name"].as_str().expect("name"))
1698 .collect();
1699 assert_eq!(names, ASSOCIATION_TOOLS.to_vec());
1700 }
1701
1702 #[test]
1709 fn the_association_surface_is_entity_tools_only() {
1710 for kept in ["remember", "recall", "explain_association"] {
1711 assert!(
1712 ASSOCIATION_TOOLS.contains(&kept),
1713 "{kept} answers on an entity graph and must be listed"
1714 );
1715 }
1716 for code_only in [
1717 "explore", "map", "context", "impact", "owners", "why", "sync",
1718 ] {
1719 assert!(
1720 !ASSOCIATION_TOOLS.contains(&code_only),
1721 "{code_only} reads a code graph and must not be listed on a memory store"
1722 );
1723 }
1724 let served: Vec<String> = crate::mcp_tasks::task_tools()
1725 .iter()
1726 .chain(graph_tools().iter())
1727 .filter_map(|t| t.get("name").and_then(Js::as_str))
1728 .map(str::to_string)
1729 .collect();
1730 for name in ASSOCIATION_TOOLS {
1731 assert!(
1732 served.iter().any(|s| s == name),
1733 "{name} is listed but not served"
1734 );
1735 }
1736 assert!(
1737 CODE_GRAPH_TOOLS.contains(&"explore"),
1738 "and `explore` is the task tool the other surface lists"
1739 );
1740 }
1741
1742 #[test]
1745 fn tools_list_is_three_tools_on_a_code_graph_store() {
1746 let db = demo_db();
1747 db.write()
1748 .insert_node(
1749 "GitSync",
1750 crate::mcp_tasks::SYNC_KEY,
1751 vec![("id".into(), Value::Str(crate::mcp_tasks::SYNC_KEY.into()))],
1752 )
1753 .expect("marker");
1754 let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1755 let names: Vec<&str> = resp["result"]["tools"]
1756 .as_array()
1757 .expect("tools array")
1758 .iter()
1759 .map(|t| t["name"].as_str().expect("name"))
1760 .collect();
1761 assert_eq!(names, ["explore", "query", "stats"]);
1762 }
1763
1764 #[test]
1765 fn test_stats_returns_node_count() {
1766 let db = demo_db();
1767 let resp = tool_call(&db, 1, "stats", json!({}));
1768 assert!(!is_error(&resp));
1769 let result = tool_text(&resp);
1770 assert_eq!(result["nodes_live"], 2);
1771 }
1772
1773 #[test]
1777 fn create_rule_reports_a_build_it_could_not_finish() {
1778 let db = SharedDb::open(&tmp_dir()).expect("open");
1779 {
1780 let mut g = db.write();
1781 for i in 0..300usize {
1782 const D: usize = 32;
1783 let axis = (i / 10) % D;
1784 let mut xs = vec![0.0f64; D];
1785 xs[axis] = 1.0;
1786 xs[(axis + 1) % D] = (i % 10) as f64 * 0.001;
1787 g.insert_node(
1788 "V",
1789 &format!("v{i}"),
1790 vec![(
1791 "emb".into(),
1792 Value::List(xs.into_iter().map(Value::Float).collect()),
1793 )],
1794 )
1795 .expect("insert");
1796 }
1797 g.set_hnsw_build_batch(Some(64));
1798 }
1799 let args = json!({
1800 "name": "sim",
1801 "src_label": "V",
1802 "dst_label": "V",
1803 "predicate": {"VectorSimilar": {"field": "emb", "min": 0.9}},
1804 "edge_type": "SIM",
1805 "weight_prop": null,
1806 "max_edges": null,
1807 "approximate": true
1808 });
1809 let resp = tool_call(&db, 1, "create_rule", args);
1810 assert!(!is_error(&resp), "{resp}");
1811 let result = tool_text(&resp);
1812 assert_eq!(result["name"], json!("sim"));
1813 assert_eq!(result["building"], json!({"indexed": 64, "total": 300}));
1814 let note = result["note"].as_str().expect("a note explaining the wait");
1815 assert!(
1816 note.contains("derives no edges until it finishes") && note.contains("build-index"),
1817 "the note must say the edges are not there yet and how to finish: {note}"
1818 );
1819
1820 let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
1822 let rule = stats["rules"]
1823 .as_array()
1824 .expect("rules")
1825 .iter()
1826 .find(|r| r["name"] == "sim")
1827 .expect("the rule is installed while it builds");
1828 assert_eq!(rule["edges"], json!(0));
1829 assert_eq!(
1830 rule["building"],
1831 json!({"rule": "sim", "indexed": 64, "total": 300})
1832 );
1833
1834 while !db.write().pump_index_build().expect("pump").is_empty() {}
1836 let stats = tool_text(&tool_call(&db, 3, "stats", json!({})));
1837 let rule = stats["rules"]
1838 .as_array()
1839 .expect("rules")
1840 .iter()
1841 .find(|r| r["name"] == "sim")
1842 .expect("rule");
1843 assert!(rule.get("building").is_none(), "{rule}");
1844 assert!(rule["edges"].as_u64().expect("edges") > 0);
1845 }
1846
1847 #[test]
1848 fn test_query_runs_cypher() {
1849 let db = demo_db();
1850 let resp = tool_call(
1851 &db,
1852 1,
1853 "query",
1854 json!({ "cypher": "MATCH (n:Person) RETURN n.name ORDER BY n.name" }),
1855 );
1856 assert!(!is_error(&resp));
1857 let result = tool_text(&resp);
1858 assert_eq!(result["columns"], json!(["n.name"]));
1860 assert_eq!(result["rows"].as_array().map(|r| r.len()), Some(2));
1861 }
1862
1863 #[test]
1864 fn test_query_create_is_a_write() {
1865 let db = SharedDb::open(&tmp_dir()).expect("open");
1866 let resp = tool_call(
1867 &db,
1868 1,
1869 "query",
1870 json!({ "cypher": "CREATE (n:L {id: 'k'}) RETURN n" }),
1871 );
1872 assert!(
1873 !is_error(&resp),
1874 "CREATE via MCP query must succeed: {resp}"
1875 );
1876 let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
1877 assert_eq!(stats["nodes_live"], 1);
1878 }
1879
1880 #[test]
1881 fn test_ingest_json_inserts_nodes() {
1882 let db = demo_db();
1883 let resp = tool_call(
1884 &db,
1885 1,
1886 "ingest_json",
1887 json!({
1888 "label": "Person",
1889 "rows_json": r#"[{"id":"carol","name":"Carol"}]"#,
1890 "key_field": "id"
1891 }),
1892 );
1893 assert!(!is_error(&resp));
1894 let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
1896 assert_eq!(stats["nodes_live"], 3);
1897 }
1898
1899 #[test]
1900 fn test_node_info_returns_props() {
1901 let db = demo_db();
1902 let resp = tool_call(&db, 1, "node_info", json!({ "key": "alice" }));
1903 assert!(!is_error(&resp));
1904 let result = tool_text(&resp);
1905 assert_eq!(result["key"], "alice");
1906 assert_eq!(result["label"], "Person");
1907 assert_eq!(result["props"]["name"], "Alice");
1908 }
1909
1910 #[test]
1913 fn test_node_edges_returns_edges() {
1914 let db = demo_db();
1915 let resp = tool_call(
1916 &db,
1917 1,
1918 "node_edges",
1919 json!({ "key": "alice", "json": true }),
1920 );
1921 assert!(!is_error(&resp));
1922 let result = tool_text(&resp);
1923 assert_eq!(result["key"], "alice");
1924 let types = result["types"].as_array().expect("types");
1925 assert!(
1926 !types.is_empty(),
1927 "alice should have at least one edge type"
1928 );
1929 let similar = types
1930 .iter()
1931 .find(|t| t["edge_type"] == "SIMILAR")
1932 .expect("the rule's edge type");
1933 assert_eq!(similar["count"], json!(2));
1936 let edges = similar["edges"].as_array().expect("edges");
1937 let dirs: Vec<&str> = edges
1938 .iter()
1939 .map(|e| e["direction"].as_str().expect("direction"))
1940 .collect();
1941 assert!(dirs.contains(&"out") && dirs.contains(&"in"), "{similar}");
1942 for edge in edges {
1943 assert_eq!(edge["other"], json!("bob"));
1944 assert_eq!(edge["derived"], json!(true));
1945 assert_eq!(edge["rule"], json!("sim_emb"));
1946 assert_eq!(edge["score"], json!(1.0));
1947 assert!(
1948 edge["predicate"]
1949 .as_str()
1950 .unwrap_or("")
1951 .contains("vector_similar"),
1952 "the predicate travels with the edge: {edge}"
1953 );
1954 }
1955 }
1956
1957 #[test]
1960 fn test_neighborhood_traverses_one_hop() {
1961 let db = demo_db();
1962 let resp = tool_call(
1963 &db,
1964 1,
1965 "neighborhood",
1966 json!({ "key": "alice", "depth": 1, "json": true }),
1967 );
1968 assert!(!is_error(&resp));
1969 let result = tool_text(&resp);
1970 assert_eq!(result["key"], "alice");
1971 assert!(result["types"].as_array().is_some(), "{result}");
1972
1973 let deep = tool_call(
1974 &db,
1975 2,
1976 "neighborhood",
1977 json!({ "key": "alice", "depth": 2 }),
1978 );
1979 assert!(!is_error(&deep));
1980 let table = tool_text(&deep);
1981 assert_eq!(table["columns"], json!(["key", "label", "depth"]));
1982 assert!(table["rows"].as_array().is_some());
1983 }
1984
1985 #[test]
1986 fn test_explain_returns_rule_info() {
1987 let db = demo_db();
1988 let resp = tool_call(&db, 1, "explain", json!({ "a": "alice", "b": "bob" }));
1989 assert!(!is_error(&resp));
1990 let result = tool_text(&resp);
1991 let arr = result.as_array().expect("explain returns array");
1992 assert!(!arr.is_empty(), "expected at least one explanation");
1993 assert_eq!(arr[0]["rule"], "sim_emb");
1994 }
1995
1996 #[test]
1997 fn test_create_rule_backfills() {
1998 let db = SharedDb::open(&tmp_dir()).expect("open");
1999 {
2000 let mut g = db.write();
2001 let opts = IngestOptions {
2002 key_field: "id".into(),
2003 auto_fk: AutoFk::Off,
2004 };
2005 let rows: Vec<BTreeMap<String, Value>> = vec![
2006 [
2007 ("id", Value::Str("x".into())),
2008 ("tag", Value::Str("a".into())),
2009 ]
2010 .into_iter()
2011 .map(|(k, v)| (k.to_string(), v))
2012 .collect(),
2013 [
2014 ("id", Value::Str("y".into())),
2015 ("tag", Value::Str("a".into())),
2016 ]
2017 .into_iter()
2018 .map(|(k, v)| (k.to_string(), v))
2019 .collect(),
2020 ];
2021 g.ingest("Item", rows, &opts).expect("ingest");
2022 }
2023 let resp = tool_call(
2024 &db,
2025 1,
2026 "create_rule",
2027 json!({
2028 "name": "same_tag",
2029 "src_label": "Item",
2030 "dst_label": "Item",
2031 "predicate": { "FieldEqual": { "field": "tag" } },
2032 "edge_type": "SAME_TAG"
2033 }),
2034 );
2035 assert!(!is_error(&resp));
2036 let result = tool_text(&resp);
2037 assert_eq!(result["ok"], true);
2038 let edges_resp = tool_call(&db, 2, "node_edges", json!({ "key": "x", "json": true }));
2040 let edges_result = tool_text(&edges_resp);
2041 let types = edges_result["types"].as_array().expect("types");
2042 assert!(
2043 types.iter().any(|t| t["edge_type"] == "SAME_TAG"),
2044 "SAME_TAG edge not found after create_rule"
2045 );
2046 }
2047
2048 #[test]
2051 fn test_upsert_entity_creates_new_node() {
2052 let db = demo_db();
2053 let resp = tool_call(
2054 &db,
2055 1,
2056 "upsert_entity",
2057 json!({
2058 "key": "carol",
2059 "label": "Person",
2060 "props": { "name": "Carol", "age": 30 }
2061 }),
2062 );
2063 assert!(!is_error(&resp));
2064 let result = tool_text(&resp);
2065 assert_eq!(result["ok"], true);
2066 assert_eq!(result["created"], true);
2067 assert_eq!(result["key"], "carol");
2068 let info = tool_text(&tool_call(&db, 2, "node_info", json!({ "key": "carol" })));
2070 assert_eq!(info["props"]["name"], "Carol");
2071 }
2072
2073 #[test]
2074 fn test_upsert_entity_updates_existing_node() {
2075 let db = demo_db();
2076 let resp = tool_call(
2077 &db,
2078 1,
2079 "upsert_entity",
2080 json!({
2081 "key": "alice",
2082 "props": { "name": "Alice Updated" }
2083 }),
2084 );
2085 assert!(!is_error(&resp));
2086 let result = tool_text(&resp);
2087 assert_eq!(result["ok"], true);
2088 assert_eq!(result["created"], false);
2089 assert_eq!(result["updated_fields"], 1);
2090 let info = tool_text(&tool_call(&db, 2, "node_info", json!({ "key": "alice" })));
2092 assert_eq!(info["props"]["name"], "Alice Updated");
2093 }
2094
2095 #[test]
2096 fn test_upsert_entity_missing_label_on_create_is_error() {
2097 let db = demo_db();
2098 let resp = tool_call(
2099 &db,
2100 1,
2101 "upsert_entity",
2102 json!({ "key": "new-node", "props": { "x": 1 } }),
2103 );
2104 assert!(is_error(&resp), "should error without label for new node");
2105 }
2106
2107 #[test]
2108 fn test_find_similar_returns_similar_edges() {
2109 let db = demo_db();
2110 let resp = tool_call(
2111 &db,
2112 1,
2113 "find_similar",
2114 json!({ "key": "alice", "edge_type": "SIMILAR" }),
2115 );
2116 assert!(!is_error(&resp));
2117 let result = tool_text(&resp);
2118 assert_eq!(result["key"], "alice");
2119 assert_eq!(result["edge_type"], "SIMILAR");
2120 let similar = result["similar"].as_array().expect("similar array");
2121 assert!(!similar.is_empty(), "expected SIMILAR neighbors for alice");
2122 assert_eq!(similar[0]["neighbor_key"], "bob");
2123 }
2124
2125 #[test]
2126 fn test_find_similar_limit_respected() {
2127 let db = demo_db();
2128 let resp = tool_call(
2129 &db,
2130 1,
2131 "find_similar",
2132 json!({ "key": "alice", "edge_type": "SIMILAR", "limit": 0 }),
2133 );
2134 assert!(!is_error(&resp));
2135 let result = tool_text(&resp);
2136 let similar = result["similar"].as_array().expect("similar array");
2137 assert_eq!(similar.len(), 0);
2138 }
2139
2140 #[test]
2144 fn test_find_similar_vector_default_min_is_0_8() {
2145 let db = SharedDb::open(&tmp_dir()).expect("open");
2146 {
2147 let mut g = db.write();
2148 g.insert_node(
2150 "Item",
2151 "close",
2152 vec![(
2153 "emb".into(),
2154 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2155 )],
2156 )
2157 .unwrap();
2158 g.insert_node(
2160 "Item",
2161 "far",
2162 vec![(
2163 "emb".into(),
2164 Value::List(vec![Value::Float(0.0), Value::Float(1.0)]),
2165 )],
2166 )
2167 .unwrap();
2168 }
2169
2170 let resp = tool_call(
2172 &db,
2173 1,
2174 "find_similar",
2175 json!({
2176 "vector": [1.0, 0.0],
2177 "field": "emb",
2178 "label": "Item",
2179 "k": 10
2180 }),
2181 );
2182 assert!(!is_error(&resp), "vector search must not error");
2183 let result = tool_text(&resp);
2184 let results = result["results"].as_array().expect("results array");
2185
2186 let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2187 assert!(
2188 keys.contains(&"close"),
2189 "close node (sim=1.0) must be included"
2190 );
2191 assert!(
2192 !keys.contains(&"far"),
2193 "far node (sim=0.0) must be excluded by default min=0.8"
2194 );
2195 }
2196
2197 #[test]
2199 fn test_find_similar_vector_mask_excludes_hidden() {
2200 let db = SharedDb::open(&tmp_dir()).expect("open");
2201 {
2202 let mut g = db.write();
2203 g.insert_node(
2205 "Item",
2206 "visible",
2207 vec![(
2208 "emb".into(),
2209 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2210 )],
2211 )
2212 .unwrap();
2213 g.insert_node(
2215 "Item",
2216 "hidden",
2217 vec![(
2218 "emb".into(),
2219 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2220 )],
2221 )
2222 .unwrap();
2223 }
2224
2225 let resp = tool_call(
2226 &db,
2227 1,
2228 "find_similar",
2229 json!({
2230 "vector": [1.0, 0.0],
2231 "field": "emb",
2232 "label": "Item",
2233 "k": 10,
2234 "min": 0.0,
2235 "mask": ["visible"]
2236 }),
2237 );
2238 assert!(!is_error(&resp), "masked vector search must not error");
2239 let result = tool_text(&resp);
2240 let results = result["results"].as_array().expect("results array");
2241
2242 let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2243 assert!(
2244 keys.contains(&"visible"),
2245 "visible node must appear in masked results"
2246 );
2247 assert!(
2248 !keys.contains(&"hidden"),
2249 "hidden node must be excluded by mask"
2250 );
2251 }
2252
2253 #[test]
2255 fn test_find_similar_vector_mask_bad_type_is_error() {
2256 let db = SharedDb::open(&tmp_dir()).expect("open");
2257 let resp = tool_call(
2258 &db,
2259 1,
2260 "find_similar",
2261 json!({
2262 "vector": [1.0, 0.0],
2263 "field": "emb",
2264 "k": 5,
2265 "mask": [42]
2266 }),
2267 );
2268 assert!(
2269 is_error(&resp),
2270 "non-string mask element must produce a tool error"
2271 );
2272 }
2273
2274 #[test]
2276 fn test_find_similar_edge_mask_excludes_hidden_neighbor() {
2277 let db = SharedDb::open(&tmp_dir()).expect("open");
2278 {
2279 let mut g = db.write();
2280 g.insert_node("P", "alice", vec![]).unwrap();
2281 g.insert_node("P", "bob", vec![]).unwrap(); g.insert_node("P", "carol", vec![]).unwrap(); g.insert_edge("KNOWS", "alice", "bob").unwrap();
2284 g.insert_edge("KNOWS", "alice", "carol").unwrap();
2285 }
2286 let resp = tool_call(
2288 &db,
2289 1,
2290 "find_similar",
2291 json!({
2292 "key": "alice",
2293 "edge_type": "KNOWS",
2294 "mask": ["alice", "bob"]
2295 }),
2296 );
2297 assert!(!is_error(&resp), "masked edge search must not error");
2298 let result = tool_text(&resp);
2299 let similar = result["similar"].as_array().expect("similar array");
2300 let neighbors: Vec<&str> = similar
2301 .iter()
2302 .filter_map(|e| e["neighbor_key"].as_str())
2303 .collect();
2304 assert!(neighbors.contains(&"bob"), "bob (visible) must appear");
2305 assert!(
2306 !neighbors.contains(&"carol"),
2307 "carol (hidden) must be excluded"
2308 );
2309 }
2310
2311 #[test]
2314 fn test_find_similar_edge_mask_hidden_key_is_not_found() {
2315 let db = SharedDb::open(&tmp_dir()).expect("open");
2316 {
2317 let mut g = db.write();
2318 g.insert_node("P", "alice", vec![]).unwrap();
2319 g.insert_node("P", "bob", vec![]).unwrap();
2320 }
2321 let resp_masked = tool_call(
2323 &db,
2324 1,
2325 "find_similar",
2326 json!({ "key": "alice", "edge_type": "KNOWS", "mask": ["bob"] }),
2327 );
2328 let resp_ghost = tool_call(
2330 &db,
2331 2,
2332 "find_similar",
2333 json!({ "key": "ghost", "edge_type": "KNOWS" }),
2334 );
2335 assert!(
2336 is_error(&resp_masked),
2337 "hidden query key must produce a tool error"
2338 );
2339 assert!(
2340 is_error(&resp_ghost),
2341 "nonexistent key must produce a tool error"
2342 );
2343 assert_eq!(
2345 tool_err_text(&resp_masked).contains("alice"),
2346 tool_err_text(&resp_ghost).contains("ghost"),
2347 "error messages should follow same not-found template"
2348 );
2349 }
2350
2351 #[test]
2356 fn test_explain_association_same_as_explain() {
2357 let db = demo_db();
2358 let explain = tool_text(&tool_call(
2359 &db,
2360 1,
2361 "explain",
2362 json!({ "a": "alice", "b": "bob" }),
2363 ));
2364 let assoc = tool_text(&tool_call(
2365 &db,
2366 2,
2367 "explain_association",
2368 json!({ "a": "alice", "b": "bob", "json": true }),
2369 ));
2370 let explain: Vec<Js> = serde_json::from_value(explain).expect("explain array");
2371 let mut assoc: Vec<Js> = serde_json::from_value(assoc).expect("assoc array");
2372 for row in &mut assoc {
2373 let ev = row
2374 .as_object_mut()
2375 .expect("object")
2376 .remove("evidence")
2377 .expect("every derived edge carries its evidence");
2378 assert!(
2379 ev["similarity"].is_number(),
2380 "a vector_similar edge reports the cosine it scored: {ev}"
2381 );
2382 }
2383 assert_eq!(explain, assoc, "evidence is the only addition");
2384
2385 let prose = tool_call(
2386 &db,
2387 3,
2388 "explain_association",
2389 json!({ "a": "alice", "b": "bob" }),
2390 );
2391 let text = prose["result"]["content"][0]["text"]
2392 .as_str()
2393 .expect("text content");
2394 assert!(
2395 text.contains("mushroomdb explain — alice ↔ bob:"),
2396 "the default reply is the digest: {text}"
2397 );
2398 }
2399
2400 #[test]
2405 fn test_edge_history_returns_derived_lifecycle_with_rule() {
2406 let db = demo_db(); let resp = tool_call(&db, 1, "edge_history", json!({ "a": "alice", "b": "bob" }));
2408 assert!(!is_error(&resp), "edge_history must not error: {resp}");
2409 let result = tool_text(&resp);
2410
2411 let total = result["total_commits"].as_u64().expect("total_commits");
2413 assert!(total > 0, "total_commits must be > 0 after ingest + rule");
2414
2415 let events = result["events"].as_array().expect("events array");
2417 assert!(!events.is_empty(), "expected at least one edge event");
2418
2419 let derived_added = events
2421 .iter()
2422 .any(|ev| ev["event"].as_str() == Some("Added") && !ev["rule"].is_null());
2423 assert!(
2424 derived_added,
2425 "expected a derived Added event with rule attribution: {events:?}"
2426 );
2427 }
2428
2429 #[test]
2432 fn test_was_linked_at_valid_commit() {
2433 let db = SharedDb::open(&tmp_dir()).expect("open");
2434 {
2435 let mut g = db.write();
2436 let opts = IngestOptions {
2437 key_field: "id".into(),
2438 auto_fk: AutoFk::Off,
2439 };
2440 let rows: Vec<BTreeMap<String, Value>> = vec![
2441 [("id", Value::Str("x".into()))]
2442 .into_iter()
2443 .map(|(k, v)| (k.to_string(), v))
2444 .collect(),
2445 [("id", Value::Str("y".into()))]
2446 .into_iter()
2447 .map(|(k, v)| (k.to_string(), v))
2448 .collect(),
2449 ];
2450 g.ingest("N", rows, &opts).expect("ingest");
2451 g.insert_edge("LINK", "x", "y").expect("edge");
2452 }
2453 let g = db.read();
2455 let total = g.wal_total_commits().expect("wal_total_commits");
2456 drop(g);
2457
2458 let resp = tool_call(
2459 &db,
2460 1,
2461 "was_linked",
2462 json!({ "a": "x", "b": "y", "edge_type": "LINK", "at_commit": total - 1 }),
2463 );
2464 assert!(!is_error(&resp), "was_linked must not error: {resp}");
2465 let result = tool_text(&resp);
2466 assert_eq!(result["linked"], true);
2467 assert_eq!(result["a"], "x");
2468 assert_eq!(result["edge_type"], "LINK");
2469 }
2470
2471 #[test]
2474 fn test_was_linked_out_of_horizon_returns_tool_error() {
2475 let db = SharedDb::open(&tmp_dir()).expect("open");
2476 {
2477 let mut g = db.write();
2478 g.insert_node("N", "a", vec![]).expect("node a");
2479 g.insert_node("N", "b", vec![]).expect("node b");
2480 }
2481 let resp = tool_call(
2483 &db,
2484 1,
2485 "was_linked",
2486 json!({ "a": "a", "b": "b", "edge_type": "X", "at_commit": 999 }),
2487 );
2488 assert!(
2490 is_error(&resp),
2491 "out-of-range commit must be a tool error: {resp}"
2492 );
2493 let text = resp["result"]["content"][0]["text"].as_str().expect("text");
2494 assert!(
2495 text.contains("out of range") || text.contains("range"),
2496 "error must mention range: {text}"
2497 );
2498 }
2499
2500 #[test]
2503 fn test_node_history_via_mcp() {
2504 let db = demo_db(); let resp = tool_call(&db, 1, "node_history", json!({ "key": "alice" }));
2506 assert!(!is_error(&resp), "node_history must not error: {resp}");
2507 let result = tool_text(&resp);
2508
2509 assert_eq!(result["key"], "alice");
2510 let total = result["total_commits"].as_u64().expect("total_commits");
2511 assert!(total > 0, "total_commits must be > 0");
2512
2513 let history = result["history"].as_array().expect("history array");
2514 assert!(
2515 !history.is_empty(),
2516 "alice should have at least one history entry"
2517 );
2518
2519 let first_change = &history[0]["change"];
2521 assert_eq!(first_change["type"], "NodeInserted");
2522 assert_eq!(first_change["label"], "Person");
2523 }
2524}