1use crate::json::{
51 edge_history_result_json, namespace_arg, node_history_json, node_info_json, params_from_json,
52 parse_ingest_edges, result_set_json, rule_def_from_json, stamp_namespace, stamp_namespace_row,
53};
54use mushroomdb::{
55 json_to_rows, json_to_value, memory, AsOfScope, AutoFk, GraphError, IngestOptions, MaskMode,
56 NodeMask, PropPredicate, SharedDb, Value,
57};
58use serde_json::{json, Value as Js};
59use std::collections::BTreeMap;
60use std::io::{self, BufRead, Write};
61use std::path::{Path, PathBuf};
62
63pub 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(
85 db: SharedDb,
86 db_dir: Option<PathBuf>,
87 all_tools: bool,
88 mut reader: impl BufRead,
89 mut writer: impl Write,
90) -> io::Result<()> {
91 let mut buf = Vec::new();
92 loop {
93 buf.clear();
94 let n = reader.read_until(b'\n', &mut buf)?;
95 if n == 0 {
96 return Ok(());
97 }
98 match std::str::from_utf8(&buf) {
99 Ok(s) if s.trim().is_empty() => continue,
100 Ok(s) => handle_line(&db, db_dir.as_deref(), all_tools, s.trim(), &mut writer)?,
101 Err(_) => write_error(&mut writer, None, -32700, "Parse error")?,
102 }
103 }
104}
105
106fn handle_line(
107 db: &SharedDb,
108 db_dir: Option<&Path>,
109 all_tools: bool,
110 line: &str,
111 writer: &mut impl Write,
112) -> io::Result<()> {
113 let msg: Js = match serde_json::from_str(line) {
114 Ok(v) => v,
115 Err(_) => return write_error(writer, None, -32700, "Parse error"),
116 };
117 let Some(obj) = msg.as_object() else {
118 return write_error(writer, None, -32600, "Invalid Request");
119 };
120 let is_request = obj.contains_key("id");
121 let id = obj.get("id").cloned();
122 let method = match obj.get("method").and_then(Js::as_str) {
123 Some(m) => m,
124 None => {
125 if is_request {
126 write_error(writer, id, -32600, "Invalid Request")?;
127 }
128 return Ok(());
129 }
130 };
131 match method {
132 "initialize" => {
133 if is_request {
134 write_result(writer, id, initialize_result())?;
135 }
136 }
137 "notifications/initialized" => {
138 if is_request {
139 write_result(writer, id, json!({}))?;
140 }
141 }
142 "tools/list" => {
143 if is_request {
144 write_result(writer, id, tools_list(all_tools))?;
145 }
146 }
147 "tools/call" => {
148 if is_request {
149 match dispatch_call(db, db_dir, obj.get("params")) {
150 CallOutcome::Protocol { code, message } => {
151 write_error(writer, id, code, &message)?;
152 }
153 CallOutcome::ToolOk(payload) => {
154 write_result(writer, id, tool_ok(payload))?;
155 }
156 CallOutcome::TaskOk { text } => {
157 write_result(writer, id, task_ok(&text))?;
158 }
159 CallOutcome::ToolErr(message) => {
160 write_result(writer, id, tool_err(&message))?;
161 }
162 }
163 }
164 }
165 _ => {
166 if is_request {
167 write_error(writer, id, -32601, "Method not found")?;
168 }
169 }
170 }
171 Ok(())
172}
173
174pub(crate) enum CallOutcome {
175 Protocol {
176 code: i64,
177 message: String,
178 },
179 ToolOk(Js),
181 TaskOk {
184 text: String,
185 },
186 ToolErr(String),
187}
188
189fn dispatch_call(db: &SharedDb, db_dir: Option<&Path>, params: Option<&Js>) -> CallOutcome {
190 let Some(params) = params.and_then(Js::as_object) else {
191 return protocol_invalid();
192 };
193 let Some(name) = params.get("name").and_then(Js::as_str) else {
194 return protocol_invalid();
195 };
196 let empty = json!({});
197 let args = match params.get("arguments") {
198 None => &empty,
199 Some(a) if a.is_object() => a,
200 Some(_) => return protocol_invalid(),
201 };
202 if let Some(outcome) = crate::mcp_tasks::dispatch(db, db_dir, name, args) {
204 return outcome;
205 }
206 match name {
207 "query" => tool_query(db, args),
208 "ingest_json" => tool_ingest(db, args),
209 "create_rule" => tool_create_rule(db, args),
210 "explain" => tool_explain(db, args),
211 "stats" => tool_stats(db, args),
212 "node_info" => tool_node_info(db, args),
213 "upsert_entity" => tool_upsert_entity(db, args),
214 "find_similar" => tool_find_similar(db, args),
215 "pairwise_similar" => tool_pairwise_similar(db, args),
216 "hybrid_search" => tool_hybrid_search(db, args),
217 "node_history" => tool_node_history(db, args),
218 "edge_history" => tool_edge_history(db, args),
219 "was_linked" => tool_was_linked(db, args),
220 "rename_node" => tool_rename_node(db, args),
221 _ => protocol_invalid(),
222 }
223}
224
225fn protocol_invalid() -> CallOutcome {
226 CallOutcome::Protocol {
227 code: -32602,
228 message: "Invalid params".into(),
229 }
230}
231
232fn tool_query(db: &SharedDb, args: &Js) -> CallOutcome {
233 let Some(cypher) = args.get("cypher").and_then(Js::as_str) else {
234 return CallOutcome::ToolErr("missing cypher".into());
235 };
236 let params = match params_from_json(args.get("params")) {
237 Ok(p) => p,
238 Err(e) => return CallOutcome::ToolErr(e),
239 };
240
241 let role = match args.get("role") {
247 None | Some(Js::Null) => None,
248 Some(Js::String(s)) if !s.is_empty() => Some(s.as_str()),
249 Some(_) => return CallOutcome::ToolErr("role must be a non-empty string".into()),
250 };
251 let mask_keys = match args.get("mask") {
252 None => None,
253 Some(v) => match mask_key_list(v) {
254 Ok(keys) => Some(keys),
255 Err(e) => return CallOutcome::ToolErr(e),
256 },
257 };
258 if role.is_some() && mask_keys.is_some() {
259 return CallOutcome::ToolErr("pass role or mask, not both".into());
260 }
261
262 let namespace = match namespace_arg(args.get("namespace")) {
269 Ok(n) => n,
270 Err(e) => return CallOutcome::ToolErr(e),
271 };
272
273 let as_of = match args.get("as_of") {
277 None | Some(Js::Null) => None,
278 Some(v) => match v.as_u64() {
279 Some(n) => Some(n),
280 None => {
281 return CallOutcome::ToolErr(
282 "as_of must be a non-negative integer commit index".into(),
283 )
284 }
285 },
286 };
287
288 if let Some(commit) = as_of {
289 if args
292 .get("stub_hidden")
293 .and_then(|v| v.as_bool())
294 .unwrap_or(false)
295 {
296 return CallOutcome::ToolErr(
297 "as_of (time-travel) does not compose with stub_hidden".into(),
298 );
299 }
300 let scope = match (role, &mask_keys) {
301 (Some(role), _) => AsOfScope::Role(role),
302 (None, Some(keys)) => AsOfScope::Keys(keys),
303 (None, None) => match namespace.as_deref() {
304 Some(ns) => AsOfScope::Namespace(ns),
306 None => {
307 return match db.read().query_at(commit, cypher, ¶ms) {
308 Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
309 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
310 }
311 }
312 },
313 };
314 let g = db.read();
315 let out = match (namespace.as_deref(), role.is_some() || mask_keys.is_some()) {
316 (Some(ns), true) => g.query_at_scoped_in_namespace(commit, cypher, ¶ms, scope, ns),
318 _ => g.query_at_scoped(commit, cypher, ¶ms, scope),
319 };
320 return match out {
321 Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
322 Err(GraphError::KeyNotFound { key }) if key.starts_with("role:") => {
323 CallOutcome::ToolErr(format!("unknown role '{}'", &key["role:".len()..]))
324 }
325 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
326 };
327 }
328
329 if role.is_some() || mask_keys.is_some() || namespace.is_some() {
330 let stub_hidden = args
331 .get("stub_hidden")
332 .and_then(|v| v.as_bool())
333 .unwrap_or(false);
334 let g = db.read();
335 let mask = match (role, &mask_keys) {
336 (Some(role), _) => match g.mask_for_role(role) {
337 Ok(m) => m,
338 Err(GraphError::KeyNotFound { .. }) => {
341 return CallOutcome::ToolErr(format!("unknown role '{role}'"))
342 }
343 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
344 },
345 (None, Some(keys)) => NodeMask::from_keys(&*g, keys.iter().map(String::as_str)),
346 (None, None) => g.mask_for_namespace(
348 namespace
349 .as_deref()
350 .expect("one of the three is Some in this branch"),
351 ),
352 };
353 let mask = match (namespace.as_deref(), role.is_some() || mask_keys.is_some()) {
357 (Some(ns), true) => mask.intersect(&g.mask_for_namespace(ns)),
358 _ => mask,
359 };
360 let mask = if stub_hidden {
361 mask.with_mode(MaskMode::Stub)
362 } else {
363 mask
364 };
365 return match g.query_masked(cypher, ¶ms, &mask) {
366 Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
367 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
368 };
369 }
370
371 let is_write = match mushroomdb::is_write_query(cypher) {
372 Ok(b) => b,
373 Err(e) => return CallOutcome::ToolErr(e),
374 };
375 let rs = if is_write {
376 let mut g = db.write();
377 g.query_write(cypher, ¶ms)
378 } else {
379 let g = db.read();
380 g.query(cypher, ¶ms)
381 };
382 match rs {
383 Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
384 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
385 }
386}
387
388fn mask_key_list(mask: &Js) -> Result<Vec<String>, String> {
391 let arr = mask
392 .as_array()
393 .ok_or_else(|| "mask must be an array of strings".to_string())?;
394 arr.iter()
395 .map(|v| {
396 v.as_str()
397 .map(str::to_string)
398 .ok_or_else(|| "mask must be an array of strings".to_string())
399 })
400 .collect()
401}
402
403fn tool_ingest(db: &SharedDb, args: &Js) -> CallOutcome {
404 let Some(label) = args.get("label").and_then(Js::as_str) else {
405 return CallOutcome::ToolErr("missing label".into());
406 };
407 let Some(rows_json) = args.get("rows_json").and_then(Js::as_str) else {
408 return CallOutcome::ToolErr("missing rows_json".into());
409 };
410 let mut opts = IngestOptions::default();
411 if let Some(kf) = args.get("key_field") {
412 match kf.as_str() {
413 Some(s) => opts.key_field = s.to_string(),
414 None => return CallOutcome::ToolErr("key_field must be a string".into()),
415 }
416 }
417 if let Some(suf) = args.get("auto_fk_suffix") {
418 match suf.as_str() {
419 Some(s) => {
420 opts.auto_fk = AutoFk::Auto {
421 suffix: s.to_string(),
422 }
423 }
424 None => return CallOutcome::ToolErr("auto_fk_suffix must be a string".into()),
425 }
426 }
427 let edges = match args.get("edges") {
428 None | Some(Js::Null) => Vec::new(),
429 Some(raw) => match parse_ingest_edges(raw) {
430 Ok(e) => e,
431 Err(e) => return CallOutcome::ToolErr(e),
432 },
433 };
434 let parsed: Js = match serde_json::from_str(rows_json) {
435 Ok(v) => v,
436 Err(e) => {
437 return CallOutcome::ToolErr(graph_err_msg(GraphError::IngestError {
438 detail: e.to_string(),
439 }))
440 }
441 };
442 let mut converted = match json_to_rows(&parsed) {
443 Ok(c) => c,
444 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
445 };
446 let namespace = match namespace_arg(args.get("namespace")) {
448 Ok(n) => n,
449 Err(e) => return CallOutcome::ToolErr(e),
450 };
451 if let Err(e) = stamp_namespace(&mut converted.rows, namespace.as_deref()) {
452 return CallOutcome::ToolErr(e);
453 }
454 let taken = std::mem::take(&mut converted.rows);
455 let report = {
456 let mut g = db.write();
457 g.ingest_with_edges(label, taken, &opts, &edges)
458 };
459 match report.map(|r| converted.into_report(r)) {
460 Ok(r) => match serde_json::to_value(&r) {
461 Ok(v) => CallOutcome::ToolOk(v),
462 Err(e) => CallOutcome::ToolErr(e.to_string()),
463 },
464 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
465 }
466}
467
468fn tool_create_rule(db: &SharedDb, args: &Js) -> CallOutcome {
469 let def = match rule_def_from_json(args.clone()) {
470 Ok(d) => d,
471 Err(e) => return CallOutcome::ToolErr(e),
472 };
473 let name = def.name.clone();
474 let res = {
475 let mut g = db.write();
476 g.create_rule(def)
477 };
478 if let Err(e) = res {
479 return CallOutcome::ToolErr(graph_err_msg(e));
480 }
481 let building = db
486 .read()
487 .builds_in_progress()
488 .into_iter()
489 .find(|b| b.rule == name);
490 match building {
491 Some(b) => CallOutcome::ToolOk(json!({
492 "ok": true,
493 "name": name,
494 "building": {"indexed": b.indexed, "total": b.total},
495 "note": format!(
496 "the vector index for {name:?} is still being built ({}/{} vectors); \
497 this rule derives no edges until it finishes. Every write advances it, \
498 and `mushroomdb build-index <db-dir>` finishes it now. Poll `stats` — \
499 the rule's `building` field disappears when its edges are in.",
500 b.indexed, b.total
501 ),
502 })),
503 None => CallOutcome::ToolOk(json!({"ok": true, "name": name})),
504 }
505}
506
507fn tool_explain(db: &SharedDb, args: &Js) -> CallOutcome {
508 let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
509 return CallOutcome::ToolErr("missing a".into());
510 };
511 let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
512 return CallOutcome::ToolErr("missing b".into());
513 };
514 let out = {
515 let g = db.read();
516 g.explain(a, b)
517 };
518 match out {
519 Ok(v) => match serde_json::to_value(&v) {
520 Ok(j) => CallOutcome::ToolOk(j),
521 Err(e) => CallOutcome::ToolErr(e.to_string()),
522 },
523 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
524 }
525}
526
527fn tool_stats(db: &SharedDb, args: &Js) -> CallOutcome {
537 let role = match args.get("role") {
538 None | Some(Js::Null) => None,
539 Some(Js::String(s)) if !s.is_empty() => Some(s.clone()),
540 Some(_) => return CallOutcome::ToolErr("role must be a non-empty string".into()),
541 };
542 let namespace = match namespace_arg(args.get("namespace")) {
543 Ok(n) => n,
544 Err(e) => return CallOutcome::ToolErr(e),
545 };
546 let (snap, role_def) = {
547 let g = db.read();
548 let def = match &role {
549 Some(r) => {
550 if let Err(e) = g.mask_for_role(r) {
557 return match e {
558 GraphError::KeyNotFound { .. } => {
559 CallOutcome::ToolErr(format!("unknown role '{r}'"))
560 }
561 other => CallOutcome::ToolErr(graph_err_msg(other)),
562 };
563 }
564 g.roles().into_iter().find(|d| &d.name == r)
565 }
566 None => None,
567 };
568 (g.stats(), def)
569 };
570 let mut snap = snap;
571 let scoped = role.is_some() || namespace.is_some();
576 if scoped {
577 snap.namespaces.retain(|n| {
578 role_def.as_ref().is_none_or(|d| d.sees_namespace(&n.name))
579 && namespace.as_deref().is_none_or(|ns| ns == n.name)
580 });
581 }
582 match serde_json::to_value(&snap) {
583 Ok(mut v) => {
584 if !scoped {
588 if let Some(obj) = v.as_object_mut() {
589 obj.remove("namespaces");
590 }
591 }
592 CallOutcome::ToolOk(v)
593 }
594 Err(e) => CallOutcome::ToolErr(e.to_string()),
595 }
596}
597
598fn tool_node_info(db: &SharedDb, args: &Js) -> CallOutcome {
599 let Some(key) = args.get("key").and_then(Js::as_str) else {
600 return CallOutcome::ToolErr("missing key".into());
601 };
602 let info = {
603 let g = db.read();
604 g.node_info(key)
605 };
606 match info {
607 Some(info) => CallOutcome::ToolOk(node_info_json(&info)),
608 None => CallOutcome::ToolErr(graph_err_msg(GraphError::KeyNotFound {
609 key: key.to_string(),
610 })),
611 }
612}
613
614fn tool_upsert_entity(db: &SharedDb, args: &Js) -> CallOutcome {
659 let Some(key) = args.get("key").and_then(Js::as_str) else {
660 return CallOutcome::ToolErr("missing key".into());
661 };
662 let label_opt = args.get("label").and_then(Js::as_str);
663 let Some(props_obj) = args.get("props").and_then(Js::as_object) else {
664 return CallOutcome::ToolErr("missing props".into());
665 };
666 let namespace = match namespace_arg(args.get("namespace")) {
667 Ok(n) => n,
668 Err(e) => return CallOutcome::ToolErr(e),
669 };
670 let aliases: Vec<String> = match args.get("aliases") {
671 None | Some(Js::Null) => Vec::new(),
672 Some(Js::Array(items)) => match items
673 .iter()
674 .map(|v| v.as_str().map(str::to_string))
675 .collect::<Option<Vec<_>>>()
676 {
677 Some(a) => a,
678 None => return CallOutcome::ToolErr("aliases must be an array of strings".into()),
679 },
680 Some(_) => return CallOutcome::ToolErr("aliases must be an array of strings".into()),
681 };
682
683 let mut row: BTreeMap<String, Value> = BTreeMap::new();
687 for (field, json_val) in props_obj {
688 if field == "id" {
689 continue;
690 }
691 match json_to_value(json_val.clone()) {
692 Ok(Some(v)) => {
693 row.insert(field.clone(), v);
694 }
695 Ok(None) => {
696 return CallOutcome::ToolErr(format!("prop {field} is not a supported value type"))
697 }
698 Err(e) => return CallOutcome::ToolErr(format!("prop {field}: {e}")),
699 }
700 }
701 if let Some(ns) = namespace.as_deref() {
702 if let Err(e) = stamp_namespace_row(&mut row, ns) {
703 return CallOutcome::ToolErr(e);
704 }
705 }
706
707 let outcome = {
711 let mut g = db.write();
712 match memory::remember::upsert_entity(&mut *g, key, label_opt, row, &aliases) {
713 Ok(o) => o,
714 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
715 }
716 };
717 if outcome.created {
718 return CallOutcome::ToolOk(json!({
719 "ok": true,
720 "key": key,
721 "label": outcome.label,
722 "created": true
723 }));
724 }
725 let mut reply = json!({
726 "ok": true,
727 "key": key,
728 "label": outcome.label,
729 "created": false,
730 "updated_fields": outcome.updated_fields
731 });
732 let lost = &outcome.same_as_lost;
735 if !lost.is_empty() {
736 reply["same_as_lost"] = crate::mcp_tasks::same_as_lost_json(lost);
737 reply["same_as_lost_total"] = json!(lost.len());
738 reply["same_as_lost_note"] = json!(format!(
739 "this write retracted {} SAME_AS link(s): {}",
740 lost.len(),
741 crate::mcp_tasks::SAME_AS_LOST_REMEDY
742 ));
743 }
744 if let Some(unread) = &outcome.same_as_unread {
746 reply["same_as_unread"] = json!(unread);
747 reply["same_as_unread_note"] = json!(format!(
748 "this write committed, but its SAME_AS links could not be read afterwards, \
749 so same_as_lost is not known: {unread}"
750 ));
751 }
752 CallOutcome::ToolOk(reply)
753}
754
755fn tool_find_similar(db: &SharedDb, args: &Js) -> CallOutcome {
762 let mask_keys: Option<Vec<String>> = if let Some(mask_val) = args.get("mask") {
765 match mask_val.as_array() {
766 Some(arr) => {
767 let mut ks: Vec<String> = Vec::with_capacity(arr.len());
768 for v in arr {
769 match v.as_str() {
770 Some(s) => ks.push(s.to_string()),
771 None => {
772 return CallOutcome::ToolErr("mask must be an array of strings".into())
773 }
774 }
775 }
776 Some(ks)
777 }
778 None => return CallOutcome::ToolErr("mask must be an array of strings".into()),
779 }
780 } else {
781 None
782 };
783
784 let vector = match vector_arg(args) {
787 Ok(v) => v,
788 Err(e) => return CallOutcome::ToolErr(e),
789 };
790 if let Some(q) = vector {
791 if q.is_empty() {
792 return CallOutcome::ToolErr("vector must be a non-empty array of numbers".into());
793 }
794 let field = args
795 .get("field")
796 .and_then(Js::as_str)
797 .unwrap_or("embedding");
798 let label_str = args.get("label").and_then(Js::as_str).unwrap_or("");
799 let label = if label_str.is_empty() {
800 None
801 } else {
802 Some(label_str)
803 };
804 let k = match args.get("k") {
807 None | Some(Js::Null) => 10usize,
808 Some(v) => match v.as_u64() {
809 Some(n) => n as usize,
810 None => return CallOutcome::ToolErr("k must be a non-negative integer".into()),
811 },
812 };
813 let min = match args.get("min") {
814 None | Some(Js::Null) => mushroomdb::FIND_SIMILAR_DEFAULT_MIN,
815 Some(v) => match v.as_f64() {
816 Some(n) => n,
817 None => return CallOutcome::ToolErr("min must be a number".into()),
818 },
819 };
820 let where_pred = match parse_where_arg(args) {
821 Ok(p) => p,
822 Err(e) => return CallOutcome::ToolErr(e),
823 };
824 let exact = match args.get("exact") {
825 None => false,
826 Some(v) => match v.as_bool() {
827 Some(b) => b,
828 None => return CallOutcome::ToolErr("exact must be a boolean".into()),
829 },
830 };
831 let exact = exact || where_pred.is_some();
832
833 let hits = {
834 let g = db.read();
835 let node_mask = mask_keys
836 .as_ref()
837 .map(|keys| NodeMask::from_keys(&*g, keys.iter().map(String::as_str)));
838 match g.find_similar_vector_filtered(
839 field,
840 label,
841 &q,
842 k,
843 min,
844 node_mask.as_ref(),
845 where_pred.as_ref(),
846 exact,
847 ) {
848 Ok(h) => h,
849 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
850 }
851 };
852 let results: Vec<Js> = hits
853 .into_iter()
854 .map(|(key, score)| json!({ "key": key, "score": score }))
855 .collect();
856 return CallOutcome::ToolOk(json!({
857 "mode": "vector",
858 "field": field,
859 "label": label,
860 "k": k,
861 "min": min,
862 "results": results
863 }));
864 }
865
866 let Some(key) = args.get("key").and_then(Js::as_str) else {
868 return CallOutcome::ToolErr("missing key (or provide vector for vector search)".into());
869 };
870 let edge_type = args
871 .get("edge_type")
872 .and_then(Js::as_str)
873 .unwrap_or("SIMILAR");
874 let limit = match args.get("limit") {
875 None | Some(Js::Null) => 10usize,
876 Some(v) => match v.as_u64() {
877 Some(n) => n as usize,
878 None => return CallOutcome::ToolErr("limit must be a non-negative integer".into()),
879 },
880 };
881
882 if let Some(ref mask) = mask_keys {
885 let mask_set: std::collections::HashSet<&str> = mask.iter().map(String::as_str).collect();
886 if !mask_set.contains(key) {
887 return CallOutcome::ToolErr(graph_err_msg(GraphError::KeyNotFound {
888 key: key.into(),
889 }));
890 }
891 let out = {
892 let g = db.read();
893 g.node_edges(key)
894 };
895 return match out {
896 Ok(edges) => {
897 let similar: Vec<Js> = edges
898 .iter()
899 .filter(|e| e.edge_type == edge_type)
900 .filter(|e| {
901 let neighbor_key = if e.src_key == key {
903 &e.dst_key
904 } else {
905 &e.src_key
906 };
907 mask_set.contains(neighbor_key.as_str())
908 })
909 .take(limit)
910 .map(|e| {
911 let neighbor_key = if e.src_key == key {
912 &e.dst_key
913 } else {
914 &e.src_key
915 };
916 let direction = if e.src_key == key { "out" } else { "in" };
917 json!({
918 "neighbor_key": neighbor_key,
919 "direction": direction,
920 "edge_type": e.edge_type,
921 "derived": e.derived,
922 })
923 })
924 .collect();
925 CallOutcome::ToolOk(json!({
926 "key": key,
927 "edge_type": edge_type,
928 "similar": similar
929 }))
930 }
931 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
932 };
933 }
934
935 let out = {
936 let g = db.read();
937 g.node_edges(key)
938 };
939 match out {
940 Ok(edges) => {
941 let similar: Vec<Js> = edges
942 .iter()
943 .filter(|e| e.edge_type == edge_type)
944 .take(limit)
945 .map(|e| {
946 let neighbor_key = if e.src_key == key {
947 &e.dst_key
948 } else {
949 &e.src_key
950 };
951 let direction = if e.src_key == key { "out" } else { "in" };
952 json!({
953 "neighbor_key": neighbor_key,
954 "direction": direction,
955 "edge_type": e.edge_type,
956 "derived": e.derived,
957 })
958 })
959 .collect();
960 CallOutcome::ToolOk(json!({
961 "key": key,
962 "edge_type": edge_type,
963 "similar": similar
964 }))
965 }
966 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
967 }
968}
969
970fn vector_arg(args: &Js) -> Result<Option<Vec<f64>>, String> {
975 match args.get("vector") {
976 None | Some(Js::Null) => Ok(None),
977 Some(Js::Array(items)) => items
978 .iter()
979 .map(|v| {
980 v.as_f64()
981 .ok_or_else(|| "vector elements must all be numbers".to_string())
982 })
983 .collect::<Result<Vec<_>, _>>()
984 .map(Some),
985 Some(_) => Err("vector must be an array of numbers".to_string()),
986 }
987}
988
989fn count_arg(args: &Js, name: &str, default: usize) -> Result<usize, String> {
992 match args.get(name) {
993 None | Some(Js::Null) => Ok(default),
994 Some(v) => v
995 .as_u64()
996 .map(|n| n as usize)
997 .ok_or_else(|| format!("{name} must be a non-negative integer")),
998 }
999}
1000
1001fn number_arg(args: &Js, name: &str, default: f64) -> Result<f64, String> {
1003 match args.get(name) {
1004 None | Some(Js::Null) => Ok(default),
1005 Some(v) => v.as_f64().ok_or_else(|| format!("{name} must be a number")),
1006 }
1007}
1008
1009fn tool_pairwise_similar(db: &SharedDb, args: &Js) -> CallOutcome {
1011 let Some(keys_js) = args.get("keys").and_then(Js::as_array) else {
1012 return CallOutcome::ToolErr("missing required field: keys".into());
1013 };
1014 let mut keys: Vec<String> = Vec::with_capacity(keys_js.len());
1015 for v in keys_js {
1016 match v.as_str() {
1017 Some(s) => keys.push(s.to_string()),
1018 None => return CallOutcome::ToolErr("keys must be an array of strings".into()),
1019 }
1020 }
1021 let Some(field) = args.get("field").and_then(Js::as_str) else {
1022 return CallOutcome::ToolErr("missing required field: field".into());
1023 };
1024 let k = match count_arg(args, "k", 10) {
1025 Ok(k) => k,
1026 Err(e) => return CallOutcome::ToolErr(e),
1027 };
1028 let min = match number_arg(args, "min", 0.0) {
1029 Ok(min) => min,
1030 Err(e) => return CallOutcome::ToolErr(e),
1031 };
1032 let refs: Vec<&str> = keys.iter().map(String::as_str).collect();
1033 let out = {
1034 let g = db.read();
1035 g.pairwise_similar(&refs, field, k, min)
1036 };
1037 match out {
1038 Ok(pairs) => {
1039 let results: Vec<Js> = pairs
1040 .into_iter()
1041 .map(|(key, neighbors)| {
1042 json!({
1043 "key": key,
1044 "neighbors": neighbors
1045 .into_iter()
1046 .map(|(n, score)| json!({ "key": n, "score": score }))
1047 .collect::<Vec<_>>(),
1048 })
1049 })
1050 .collect();
1051 CallOutcome::ToolOk(json!({
1052 "field": field,
1053 "k": k,
1054 "min": min,
1055 "results": results
1056 }))
1057 }
1058 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1059 }
1060}
1061
1062fn tool_hybrid_search(db: &SharedDb, args: &Js) -> CallOutcome {
1063 let Some(query_text) = args.get("query_text").and_then(Js::as_str) else {
1064 return CallOutcome::ToolErr("missing required field: query_text".into());
1065 };
1066 let Some(text_field) = args.get("text_field").and_then(Js::as_str) else {
1067 return CallOutcome::ToolErr("missing required field: text_field".into());
1068 };
1069
1070 let vector_field = args
1071 .get("vector_field")
1072 .and_then(Js::as_str)
1073 .unwrap_or("embedding");
1074 let label = args.get("label").and_then(Js::as_str);
1075 let k = match count_arg(args, "k", 10) {
1076 Ok(k) => k,
1077 Err(e) => return CallOutcome::ToolErr(e),
1078 };
1079
1080 let query_vec: Vec<f64> = match vector_arg(args) {
1081 Ok(v) => v.unwrap_or_default(),
1082 Err(e) => return CallOutcome::ToolErr(e),
1083 };
1084
1085 let hits = {
1086 let g = db.read();
1087 g.search_hybrid(text_field, query_text, vector_field, &query_vec, label, k)
1088 };
1089
1090 let results: Vec<Js> = hits
1091 .into_iter()
1092 .map(|(key, score)| json!({ "key": key, "score": score }))
1093 .collect();
1094
1095 CallOutcome::ToolOk(json!({
1096 "query_text": query_text,
1097 "text_field": text_field,
1098 "vector_field": vector_field,
1099 "label": label,
1100 "k": k,
1101 "results": results
1102 }))
1103}
1104
1105fn tool_node_history(db: &SharedDb, args: &Js) -> CallOutcome {
1106 let Some(key) = args.get("key").and_then(Js::as_str) else {
1107 return CallOutcome::ToolErr("missing key".into());
1108 };
1109 let g = db.read();
1110 let result = match g.node_history(key) {
1111 Ok(e) => e,
1112 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
1113 };
1114 CallOutcome::ToolOk(node_history_json(key, &result))
1115}
1116
1117fn tool_edge_history(db: &SharedDb, args: &Js) -> CallOutcome {
1118 let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1119 return CallOutcome::ToolErr("missing a".into());
1120 };
1121 let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1122 return CallOutcome::ToolErr("missing b".into());
1123 };
1124 let result = {
1125 let g = db.read();
1126 g.edge_history(a, b)
1127 };
1128 match result {
1129 Ok(hr) => CallOutcome::ToolOk(edge_history_result_json(a, b, &hr)),
1130 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1131 }
1132}
1133
1134fn tool_was_linked(db: &SharedDb, args: &Js) -> CallOutcome {
1135 let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1136 return CallOutcome::ToolErr("missing a".into());
1137 };
1138 let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1139 return CallOutcome::ToolErr("missing b".into());
1140 };
1141 let Some(edge_type) = args
1142 .get("edge_type")
1143 .and_then(Js::as_str)
1144 .filter(|s| !s.is_empty())
1145 else {
1146 return CallOutcome::ToolErr("missing edge_type".into());
1147 };
1148 let at_commit = match args.get("at_commit") {
1152 Some(Js::String(date)) => match db.read().resolve_date(date) {
1153 Ok(c) => c,
1154 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
1155 },
1156 Some(v) => match v.as_u64() {
1157 Some(n) => n,
1158 None => {
1159 return CallOutcome::ToolErr(
1160 "at_commit must be a non-negative commit index or an RFC 3339 date".into(),
1161 )
1162 }
1163 },
1164 None => return CallOutcome::ToolErr("missing or invalid at_commit".into()),
1165 };
1166 let result = {
1167 let g = db.read();
1168 g.was_linked(a, b, edge_type, at_commit)
1169 };
1170 match result {
1171 Ok(linked) => CallOutcome::ToolOk(json!({
1172 "a": a,
1173 "b": b,
1174 "edge_type": edge_type,
1175 "at_commit": at_commit,
1176 "linked": linked,
1177 })),
1178 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1179 }
1180}
1181
1182fn tool_rename_node(db: &SharedDb, args: &Js) -> CallOutcome {
1183 let Some(old_key) = args.get("old_key").and_then(Js::as_str) else {
1184 return CallOutcome::ToolErr("missing old_key".into());
1185 };
1186 let Some(new_key) = args.get("new_key").and_then(Js::as_str) else {
1187 return CallOutcome::ToolErr("missing new_key".into());
1188 };
1189 let mut g = db.write();
1190 match g.rename_node(old_key, new_key) {
1191 Ok(()) => CallOutcome::ToolOk(json!({
1192 "ok": true,
1193 "old_key": old_key,
1194 "new_key": new_key,
1195 })),
1196 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1197 }
1198}
1199
1200fn parse_where_arg(args: &Js) -> std::result::Result<Option<PropPredicate>, String> {
1201 let Some(w) = args.get("where") else {
1202 return Ok(None);
1203 };
1204 if w.is_null() {
1205 return Ok(None);
1206 }
1207 let pred: PropPredicate =
1208 serde_json::from_value(w.clone()).map_err(|e| format!("where: {e}"))?;
1209 pred.validate_named("where")?;
1210 Ok(Some(pred))
1211}
1212
1213pub(crate) fn graph_err_msg(e: GraphError) -> String {
1214 match e {
1215 GraphError::QueryError { detail } | GraphError::IngestError { detail } => detail,
1216 GraphError::Unwritable { path, detail, .. } => {
1218 tracing::error!(path = %path, "{detail}");
1219 GraphError::UNWRITABLE_CLIENT_MESSAGE.to_string()
1220 }
1221 other => other.to_string(),
1222 }
1223}
1224
1225fn initialize_result() -> Js {
1226 json!({
1227 "protocolVersion": "2024-11-05",
1228 "capabilities": { "tools": {} },
1229 "serverInfo": { "name": "mushroomdb", "version": env!("CARGO_PKG_VERSION") }
1230 })
1231}
1232
1233const ADVANCED_PREFIX: &str = "Advanced: ";
1239
1240pub const ASSOCIATION_TOOLS: [&str; 23] = [
1276 "query",
1277 "explain_association",
1278 "neighborhood",
1279 "node_info",
1280 "node_edges",
1281 "was_linked",
1282 "edges_at",
1283 "what_if",
1284 "node_history",
1285 "edge_history",
1286 "find_similar",
1287 "pairwise_similar",
1288 "hybrid_search",
1289 "remember",
1290 "recall",
1291 "upsert_entity",
1292 "ingest_json",
1293 "create_rule",
1294 "stats",
1295 "schema",
1296 "analyze",
1297 "suggest_rules",
1298 "forget",
1299];
1300
1301fn tools_list(all: bool) -> Js {
1315 let mut served: Vec<Js> = crate::mcp_tasks::task_tools();
1316 for mut tool in graph_tools() {
1317 if let Some(d) = tool.get("description").and_then(Js::as_str) {
1318 let prefixed = format!("{ADVANCED_PREFIX}{d}");
1319 tool["description"] = Js::String(prefixed);
1320 }
1321 served.push(tool);
1322 }
1323 if all {
1324 return json!({ "tools": served });
1325 }
1326 let mut tools: Vec<Js> = Vec::with_capacity(ASSOCIATION_TOOLS.len());
1327 for name in ASSOCIATION_TOOLS {
1328 let Some(tool) = served
1329 .iter()
1330 .find(|t| t.get("name").and_then(Js::as_str) == Some(name))
1331 else {
1332 debug_assert!(false, "ASSOCIATION_TOOLS lists {name}, which is not served");
1333 continue;
1334 };
1335 tools.push(tool.clone());
1336 }
1337 json!({ "tools": tools })
1338}
1339
1340fn graph_tools() -> Vec<Js> {
1343 let Js::Array(tools) = json!([
1344 {
1345 "name": "query",
1346 "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 or the list is null, and an error when the property is not a list or map or the index is not an integer.",
1347 "inputSchema": {
1348 "type": "object",
1349 "properties": {
1350 "cypher": { "type": "string", "description": "Cypher query text." },
1351 "params": {
1352 "type": "object",
1353 "description": "Named JSON-scalar query parameters."
1354 },
1355 "mask": {
1356 "type": "array",
1357 "items": { "type": "string" },
1358 "description": "Optional node key allow-list. When present, only these nodes are visible; write statements are rejected."
1359 },
1360 "role": {
1361 "type": "string",
1362 "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."
1363 },
1364 "as_of": {
1365 "type": "integer",
1366 "minimum": 0,
1367 "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."
1368 },
1369 "namespace": {
1370 "type": "string",
1371 "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."
1372 }
1373 },
1374 "required": ["cypher"]
1375 }
1376 },
1377 {
1378 "name": "ingest_json",
1379 "description": "Fill the store from a batch — ingest a JSON array of objects as nodes of one label. Each object becomes a node; declare the rules that should link them with 'create_rule'.",
1380 "inputSchema": {
1381 "type": "object",
1382 "properties": {
1383 "label": { "type": "string" },
1384 "rows_json": {
1385 "type": "string",
1386 "description": "JSON text of an array of objects."
1387 },
1388 "key_field": { "type": "string" },
1389 "auto_fk_suffix": { "type": "string" },
1390 "edges": {
1391 "type": "array",
1392 "description": "Optional user edges [{edge_type, src, dst}]."
1393 },
1394 "namespace": {
1395 "type": "string",
1396 "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."
1397 }
1398 },
1399 "required": ["label", "rows_json"]
1400 }
1401 },
1402 {
1403 "name": "create_rule",
1404 "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.",
1405 "inputSchema": {
1406 "type": "object",
1407 "properties": {
1408 "name": { "type": "string" },
1409 "src_label": { "type": "string" },
1410 "dst_label": { "type": "string" },
1411 "predicate": { "type": "object" },
1412 "edge_type": { "type": "string" },
1413 "weight_prop": {
1414 "type": ["string", "null"],
1415 "description": "Edge property that stores the score (default: weight)."
1416 },
1417 "max_edges": { "type": ["integer", "null"] },
1418 "approximate": {
1419 "type": "boolean",
1420 "description": "Find candidates through the HNSW index instead of comparing every pair (default: false). Only for a VectorSimilar predicate; the edges are approximate, not exact."
1421 },
1422 "via_label": {
1423 "type": "string",
1424 "description": "Optional intermediate hop: the label of the via node. Give it together with via_edge. The predicate is then evaluated between the via node and the destination, and the derived edge still runs source to destination."
1425 },
1426 "via_edge": {
1427 "type": "string",
1428 "description": "Optional intermediate hop: the edge type followed from the source to reach a via node. Give it together with via_label."
1429 },
1430 "via_dir": {
1431 "type": "string",
1432 "enum": ["Out", "In"],
1433 "description": "Direction to follow via_edge from the source (default: Out, source to via)."
1434 },
1435 "namespace": {
1436 "type": "string",
1437 "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."
1438 }
1439 },
1440 "required": ["name", "src_label", "dst_label", "predicate", "edge_type"]
1441 }
1442 },
1443 {
1444 "name": "explain",
1445 "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.",
1446 "inputSchema": {
1447 "type": "object",
1448 "properties": {
1449 "a": { "type": "string", "minLength": 1 },
1450 "b": { "type": "string", "minLength": 1 }
1451 },
1452 "required": ["a", "b"]
1453 }
1454 },
1455 {
1456 "name": "stats",
1457 "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). Pass 'role' or 'namespace' to also get `namespaces`, the namespaces that argument may see with a live-node count each; without either argument the roster is omitted entirely.",
1458 "inputSchema": {
1459 "type": "object",
1460 "properties": {
1461 "role": {
1462 "type": "string",
1463 "description": "Report only the namespaces this role may see. The store-wide counts beside them are unchanged."
1464 },
1465 "namespace": {
1466 "type": "string",
1467 "description": "Report only this namespace. Intersects with 'role'."
1468 }
1469 }
1470 }
1471 },
1472 {
1473 "name": "node_info",
1474 "description": "What is K — its label and every property it holds.",
1475 "inputSchema": {
1476 "type": "object",
1477 "properties": {
1478 "key": { "type": "string" }
1479 },
1480 "required": ["key"]
1481 }
1482 },
1483 {
1484 "name": "upsert_entity",
1485 "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.",
1486 "inputSchema": {
1487 "type": "object",
1488 "properties": {
1489 "key": { "type": "string", "description": "Unique node key." },
1490 "label": { "type": "string", "description": "Node label (required when creating a new entity)." },
1491 "props": {
1492 "type": "object",
1493 "description": "Properties to set. Values must be scalars (string, number, bool) or arrays of scalars."
1494 },
1495 "namespace": {
1496 "type": "string",
1497 "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."
1498 },
1499 "aliases": {
1500 "type": "array",
1501 "items": { "type": "string" },
1502 "description": "Other names this entity goes by, kept as written in 'alias_keys': with the identity preset, one equal to a provisional stub's key (exact, case-sensitive) links that stub. The store derives 'aliases' from the key and the name only. Do not set either in props."
1503 }
1504 },
1505 "required": ["key", "props"]
1506 }
1507 },
1508 {
1509 "name": "find_similar",
1510 "description": "What is most like this — two modes: (1) Vector search — provide `vector` (and optionally `field`, `label`, `k`, `min`, `where`, `exact`) to find the k most similar nodes by cosine similarity using the HNSW index when available, brute-force otherwise. `where` is a property predicate (`{field, eq}` or `{field, in}`) and implies exact search. `exact` true skips HNSW. (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. Edge-traversal mode ignores `where` and `exact`. 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.",
1511 "inputSchema": {
1512 "type": "object",
1513 "properties": {
1514 "vector": {
1515 "type": "array",
1516 "items": { "type": "number" },
1517 "description": "Query embedding vector for vector-similarity search. When present, vector-search mode is used and `key` is ignored."
1518 },
1519 "field": { "type": "string", "description": "Property field holding the embedding vectors (default: embedding). Used in vector-search mode." },
1520 "label": { "type": "string", "description": "Restrict search to nodes with this label. Empty string means all labels. Used in vector-search mode." },
1521 "k": { "type": "integer", "description": "Maximum results to return in vector-search mode (default: 10)." },
1522 "min": { "type": "number", "description": "Minimum cosine similarity threshold in vector-search mode (default: 0.8 on every surface; before 0.7 HTTP and the Python binding defaulted to 0.0). Pass 0.0 to keep low-similarity hits." },
1523 "mask": {
1524 "type": "array",
1525 "items": { "type": "string" },
1526 "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."
1527 },
1528 "where": {
1529 "type": "object",
1530 "description": "Optional property predicate for vector-search mode, same shape as visible_where: {\"field\": \"...\", \"eq\": value} or {\"field\": \"...\", \"in\": [values]}. Implies exact search (skips HNSW). Invalid predicates are a tool error. Edge-traversal mode ignores this."
1531 },
1532 "exact": {
1533 "type": "boolean",
1534 "description": "When true, vector-search mode uses exact GEMM brute force and does not consult HNSW. Default false. Edge-traversal mode ignores this."
1535 },
1536 "key": { "type": "string", "description": "Source node key for edge-traversal mode." },
1537 "edge_type": { "type": "string", "description": "Edge type to filter by in edge-traversal mode (default: SIMILAR)." },
1538 "limit": { "type": "integer", "description": "Maximum neighbors to return in edge-traversal mode (default: 10)." }
1539 }
1540 }
1541 },
1542 {
1543 "name": "pairwise_similar",
1544 "description": "Which of these are most like each other — exact cosine top-k among a caller key set. Self excluded. Never uses HNSW. Unknown keys, missing embeddings, zero-norm and wrong-dimension vectors are skipped. Duplicate keys collapse to first-seen order. Empty keys returns nothing. n above PAIRWISE_MAX_N is a tool error.",
1545 "inputSchema": {
1546 "type": "object",
1547 "properties": {
1548 "keys": {
1549 "type": "array",
1550 "items": { "type": "string" },
1551 "description": "Node keys to score against each other."
1552 },
1553 "field": { "type": "string", "description": "Property field holding the embedding vectors." },
1554 "k": { "type": "integer", "description": "Maximum neighbors per key (default: 10)." },
1555 "min": { "type": "number", "description": "Minimum cosine similarity threshold (default: 0.0)." }
1556 },
1557 "required": ["keys", "field"]
1558 }
1559 },
1560 {
1561 "name": "hybrid_search",
1562 "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.",
1563 "inputSchema": {
1564 "type": "object",
1565 "properties": {
1566 "query_text": { "type": "string", "description": "Fulltext query string." },
1567 "text_field": { "type": "string", "description": "Property field to search with fulltext." },
1568 "vector": {
1569 "type": "array",
1570 "items": { "type": "number" },
1571 "description": "Query embedding vector. Omit for text-only ranking."
1572 },
1573 "vector_field": { "type": "string", "description": "Property field holding embedding vectors (default: embedding)." },
1574 "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." },
1575 "k": { "type": "integer", "description": "Maximum results to return (default: 10)." }
1576 },
1577 "required": ["query_text", "text_field"]
1578 }
1579 },
1580 {
1581 "name": "node_history",
1582 "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.",
1583 "inputSchema": {
1584 "type": "object",
1585 "properties": {
1586 "key": { "type": "string", "description": "Node key to look up." }
1587 },
1588 "required": ["key"]
1589 }
1590 },
1591 {
1592 "name": "edge_history",
1593 "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.",
1594 "inputSchema": {
1595 "type": "object",
1596 "properties": {
1597 "a": { "type": "string", "minLength": 1, "description": "First node key." },
1598 "b": { "type": "string", "minLength": 1, "description": "Second node key." }
1599 },
1600 "required": ["a", "b"]
1601 }
1602 },
1603 {
1604 "name": "was_linked",
1605 "description": "Were A and B linked on DATE (or at commit C) — whether an edge of `edge_type` existed between the two keys (either direction) at that point in the store's history. Pass the date directly; do not guess a commit index for it. Returns an error when the commit is outside the retained horizon (`horizon..total_commits`).",
1606 "inputSchema": {
1607 "type": "object",
1608 "properties": {
1609 "a": { "type": "string", "minLength": 1, "description": "First node key." },
1610 "b": { "type": "string", "minLength": 1, "description": "Second node key." },
1611 "edge_type": { "type": "string", "minLength": 1, "description": "Edge type to check." },
1612 "at_commit": {
1613 "description": "When to check. Either an RFC 3339 date string — \"2026-06-19\", \"2026-06-19T12:00:00Z\", offsets accepted — which resolves to the last commit at or before that instant, or a 0-based WAL commit index.",
1614 "anyOf": [
1615 { "type": "string", "minLength": 1 },
1616 { "type": "integer", "minimum": 0 }
1617 ]
1618 }
1619 },
1620 "required": ["a", "b", "edge_type", "at_commit"]
1621 }
1622 },
1623 {
1624 "name": "rename_node",
1625 "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.",
1626 "inputSchema": {
1627 "type": "object",
1628 "properties": {
1629 "old_key": { "type": "string", "minLength": 1, "description": "Current node key." },
1630 "new_key": { "type": "string", "minLength": 1, "description": "Desired new node key." }
1631 },
1632 "required": ["old_key", "new_key"]
1633 }
1634 }
1635 ]) else {
1636 unreachable!("the literal above is an array")
1637 };
1638 tools
1639}
1640
1641fn tool_ok(payload: Js) -> Js {
1642 json!({
1643 "content": [{ "type": "text", "text": payload.to_string() }]
1644 })
1645}
1646
1647fn task_ok(text: &str) -> Js {
1655 json!({
1656 "content": [{ "type": "text", "text": text }]
1657 })
1658}
1659
1660fn tool_err(message: &str) -> Js {
1661 json!({
1662 "content": [{ "type": "text", "text": message }],
1663 "isError": true
1664 })
1665}
1666
1667fn write_result(writer: &mut impl Write, id: Option<Js>, result: Js) -> io::Result<()> {
1668 write_json(
1669 writer,
1670 &json!({
1671 "jsonrpc": "2.0",
1672 "id": id.unwrap_or(Js::Null),
1673 "result": result
1674 }),
1675 )
1676}
1677
1678fn write_error(
1679 writer: &mut impl Write,
1680 id: Option<Js>,
1681 code: i64,
1682 message: &str,
1683) -> io::Result<()> {
1684 write_json(
1685 writer,
1686 &json!({
1687 "jsonrpc": "2.0",
1688 "id": id.unwrap_or(Js::Null),
1689 "error": { "code": code, "message": message }
1690 }),
1691 )
1692}
1693
1694fn write_json(writer: &mut impl Write, value: &Js) -> io::Result<()> {
1695 let s = serde_json::to_string(value).map_err(io::Error::other)?;
1696 writeln!(writer, "{s}")?;
1697 writer.flush()
1698}
1699
1700#[cfg(test)]
1705mod tests {
1706 use super::*;
1707
1708 #[test]
1709 fn an_unwritable_directory_reaches_a_client_without_paths_or_uids() {
1710 let msg = graph_err_msg(GraphError::Unwritable {
1711 path: "/secret/dir/LOCK".into(),
1712 detail: "uid 65532 owner 0 /secret/dir".into(),
1713 read_only_filesystem: false,
1714 });
1715 assert_eq!(msg, GraphError::UNWRITABLE_CLIENT_MESSAGE);
1716 assert!(!msg.contains("secret") && !msg.contains("65532"));
1717 }
1718 use mushroomdb::{AutoFk, IngestOptions, Predicate, RuleDef, Value};
1719
1720 fn tmp_dir() -> tempfile::TempDir {
1722 tempfile::TempDir::new().expect("temp dir")
1723 }
1724
1725 struct DemoDb {
1728 db: SharedDb,
1729 _dir: tempfile::TempDir,
1730 }
1731
1732 impl std::ops::Deref for DemoDb {
1733 type Target = SharedDb;
1734 fn deref(&self) -> &SharedDb {
1735 &self.db
1736 }
1737 }
1738
1739 fn demo_db() -> DemoDb {
1741 let dir = tempfile::TempDir::new().expect("temp dir");
1742 let db = SharedDb::open(dir.path()).expect("open");
1743 {
1744 let mut g = db.write();
1745 let opts = IngestOptions {
1746 key_field: "id".into(),
1747 auto_fk: AutoFk::Off,
1748 };
1749 let people: Vec<BTreeMap<String, Value>> = vec![
1751 [
1752 ("id", Value::Str("alice".into())),
1753 ("name", Value::Str("Alice".into())),
1754 (
1755 "emb",
1756 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1757 ),
1758 ]
1759 .into_iter()
1760 .map(|(k, v)| (k.to_string(), v))
1761 .collect(),
1762 [
1763 ("id", Value::Str("bob".into())),
1764 ("name", Value::Str("Bob".into())),
1765 (
1766 "emb",
1767 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1768 ),
1769 ]
1770 .into_iter()
1771 .map(|(k, v)| (k.to_string(), v))
1772 .collect(),
1773 ];
1774 g.ingest("Person", people, &opts).expect("ingest");
1775
1776 g.create_rule(RuleDef {
1778 name: "sim_emb".into(),
1779 src_label: "Person".into(),
1780 dst_label: "Person".into(),
1781 predicate: Predicate::VectorSimilar {
1782 field: "emb".into(),
1783 min: 0.9,
1784 },
1785 edge_type: "SIMILAR".into(),
1786 weight_prop: Some("score".into()),
1787 max_edges: None,
1788 approximate: false,
1789 via_label: None,
1790 via_edge: None,
1791 via_dir: None,
1792 namespace: None,
1793 })
1794 .expect("rule");
1795 }
1796 DemoDb { db, _dir: dir }
1797 }
1798
1799 fn roundtrip(db: &SharedDb, request: &str) -> Js {
1800 roundtrip_with(db, false, request)
1801 }
1802
1803 fn roundtrip_with(db: &SharedDb, all_tools: bool, request: &str) -> Js {
1804 let input = format!("{request}\n");
1805 let mut output = Vec::new();
1806 run_mcp_stdio_with(db.clone(), None, all_tools, input.as_bytes(), &mut output)
1807 .expect("mcp");
1808 let s = std::str::from_utf8(&output).expect("utf8");
1809 serde_json::from_str(s.trim()).expect("json response")
1810 }
1811
1812 fn tool_call(db: &SharedDb, id: u64, tool: &str, args: Js) -> Js {
1813 let req = json!({
1814 "jsonrpc": "2.0",
1815 "id": id,
1816 "method": "tools/call",
1817 "params": { "name": tool, "arguments": args }
1818 });
1819 roundtrip(db, &req.to_string())
1820 }
1821
1822 fn tool_text(resp: &Js) -> Js {
1824 let text = resp["result"]["content"][0]["text"]
1825 .as_str()
1826 .expect("content[0].text");
1827 serde_json::from_str(text).expect("tool text is json")
1828 }
1829
1830 fn is_error(resp: &Js) -> bool {
1831 resp["result"]["isError"].as_bool().unwrap_or(false)
1832 }
1833
1834 fn tool_err_text(resp: &Js) -> String {
1835 resp["result"]["content"][0]["text"]
1836 .as_str()
1837 .unwrap_or("")
1838 .to_string()
1839 }
1840
1841 #[test]
1844 fn test_tools_list_includes_all_expected() {
1845 let db = demo_db();
1846 let resp = roundtrip_with(
1847 &db,
1848 true,
1849 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
1850 );
1851 let tools = resp["result"]["tools"].as_array().expect("tools array");
1852 let names: Vec<&str> = tools
1853 .iter()
1854 .map(|t| t["name"].as_str().expect("name"))
1855 .collect();
1856 for expected in &[
1857 "explain_association",
1859 "node_edges",
1860 "neighborhood",
1861 "edges_at",
1862 "what_if",
1863 "recall",
1864 "remember",
1865 "schema",
1866 "analyze",
1867 "suggest_rules",
1868 "forget",
1869 "query",
1871 "ingest_json",
1872 "create_rule",
1873 "explain",
1874 "stats",
1875 "node_info",
1876 "upsert_entity",
1877 "find_similar",
1878 "pairwise_similar",
1879 "hybrid_search",
1880 "node_history",
1881 "edge_history",
1882 "was_linked",
1883 "rename_node",
1884 ] {
1885 assert!(names.contains(expected), "missing tool: {expected}");
1886 }
1887 assert_eq!(
1888 names.len(),
1889 25,
1890 "expected exactly 25 tools, got {}",
1891 names.len()
1892 );
1893 assert_eq!(
1894 &names[..11],
1895 [
1896 "explain_association",
1897 "node_edges",
1898 "neighborhood",
1899 "edges_at",
1900 "what_if",
1901 "recall",
1902 "remember",
1903 "schema",
1904 "analyze",
1905 "suggest_rules",
1906 "forget",
1907 ],
1908 "the task tools come first, in order"
1909 );
1910 assert_eq!(names[11], "query", "the graph tools follow them");
1911 }
1912
1913 #[test]
1916 fn tools_list_defaults_to_the_association_tools_on_a_memory_store() {
1917 let db = demo_db();
1918 let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1919 let names: Vec<&str> = resp["result"]["tools"]
1920 .as_array()
1921 .expect("tools array")
1922 .iter()
1923 .map(|t| t["name"].as_str().expect("name"))
1924 .collect();
1925 assert_eq!(names, ASSOCIATION_TOOLS.to_vec());
1926 }
1927
1928 #[test]
1932 fn create_rule_advertises_every_argument_a_suggestion_carries() {
1933 let def = RuleDef {
1934 name: "sim".into(),
1935 src_label: "Person".into(),
1936 dst_label: "Person".into(),
1937 predicate: Predicate::VectorSimilar {
1938 field: "emb".into(),
1939 min: 0.9,
1940 },
1941 edge_type: "SIMILAR".into(),
1942 weight_prop: Some("score".into()),
1943 max_edges: Some(8),
1944 approximate: true,
1945 via_label: None,
1946 via_edge: None,
1947 via_dir: None,
1948 namespace: None,
1949 };
1950 let args = mushroomdb::memory::suggest::create_rule_args(&def);
1951 assert_eq!(args["approximate"], json!(true), "{args}");
1952
1953 let tools = graph_tools();
1954 let schema = tools
1955 .iter()
1956 .find(|t| t["name"] == json!("create_rule"))
1957 .expect("create_rule is a graph tool");
1958 let advertised = schema["inputSchema"]["properties"]
1959 .as_object()
1960 .expect("properties");
1961 for arg in args.as_object().expect("an object").keys() {
1962 assert!(
1963 advertised.contains_key(arg),
1964 "create_rule's schema does not list {arg:?}, which a suggestion carries"
1965 );
1966 }
1967 assert_eq!(advertised["approximate"]["type"], json!("boolean"));
1968 }
1969
1970 #[test]
1973 fn association_listing_includes_pairwise_similar_after_find_similar() {
1974 let db = demo_db();
1975 let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1976 let names: Vec<&str> = resp["result"]["tools"]
1977 .as_array()
1978 .expect("tools array")
1979 .iter()
1980 .map(|t| t["name"].as_str().expect("name"))
1981 .collect();
1982 const EXPECTED: [&str; 23] = [
1983 "query",
1984 "explain_association",
1985 "neighborhood",
1986 "node_info",
1987 "node_edges",
1988 "was_linked",
1989 "edges_at",
1990 "what_if",
1991 "node_history",
1992 "edge_history",
1993 "find_similar",
1994 "pairwise_similar",
1995 "hybrid_search",
1996 "remember",
1997 "recall",
1998 "upsert_entity",
1999 "ingest_json",
2000 "create_rule",
2001 "stats",
2002 "schema",
2003 "analyze",
2004 "suggest_rules",
2005 "forget",
2006 ];
2007 assert_eq!(names, EXPECTED.to_vec());
2008 assert_eq!(ASSOCIATION_TOOLS.as_slice(), EXPECTED.as_slice());
2009 let find = names
2010 .iter()
2011 .position(|&n| n == "find_similar")
2012 .expect("find_similar listed");
2013 assert_eq!(names[find + 1], "pairwise_similar");
2014 }
2015
2016 #[test]
2020 fn the_association_surface_is_entity_tools_only() {
2021 for kept in ["remember", "recall", "explain_association"] {
2022 assert!(
2023 ASSOCIATION_TOOLS.contains(&kept),
2024 "{kept} answers on an entity graph and must be listed"
2025 );
2026 }
2027 let served: Vec<String> = crate::mcp_tasks::task_tools()
2028 .iter()
2029 .chain(graph_tools().iter())
2030 .filter_map(|t| t.get("name").and_then(Js::as_str))
2031 .map(str::to_string)
2032 .collect();
2033 for name in ASSOCIATION_TOOLS {
2034 assert!(
2035 served.iter().any(|s| s == name),
2036 "{name} is listed but not served"
2037 );
2038 }
2039 }
2040
2041 #[test]
2042 fn test_stats_returns_node_count() {
2043 let db = demo_db();
2044 let resp = tool_call(&db, 1, "stats", json!({}));
2045 assert!(!is_error(&resp));
2046 let result = tool_text(&resp);
2047 assert_eq!(result["nodes_live"], 2);
2048 }
2049
2050 #[test]
2055 fn mcp_stats_unscoped_omits_namespace_roster() {
2056 let _dir = tmp_dir();
2057 let db = SharedDb::open(_dir.path()).expect("open");
2058 {
2059 let mut g = db.write();
2060 g.insert_node(
2061 "Doc",
2062 "a",
2063 vec![("ns".into(), Value::Str("tenant-a".into()))],
2064 )
2065 .expect("insert a");
2066 g.insert_node(
2067 "Doc",
2068 "b",
2069 vec![("ns".into(), Value::Str("tenant-b".into()))],
2070 )
2071 .expect("insert b");
2072 }
2073
2074 let unscoped = tool_text(&tool_call(&db, 1, "stats", json!({})));
2075 assert!(
2076 unscoped.get("namespaces").is_none(),
2077 "unscoped stats must omit the roster entirely, not send an empty \
2078 array: {unscoped}"
2079 );
2080 assert_eq!(
2081 unscoped["nodes_live"], 2,
2082 "the store-wide counts beside the roster are unchanged"
2083 );
2084
2085 let scoped = tool_text(&tool_call(
2086 &db,
2087 2,
2088 "stats",
2089 json!({"namespace": "tenant-a"}),
2090 ));
2091 let names: Vec<&str> = scoped["namespaces"]
2092 .as_array()
2093 .expect("a scoped call still carries the roster it may see")
2094 .iter()
2095 .map(|n| n["name"].as_str().expect("name"))
2096 .collect();
2097 assert_eq!(
2098 names,
2099 ["tenant-a"],
2100 "a call that names a namespace sees that one and no other"
2101 );
2102 }
2103
2104 #[test]
2108 fn create_rule_reports_a_build_it_could_not_finish() {
2109 let _dir = tmp_dir();
2110 let db = SharedDb::open(_dir.path()).expect("open");
2111 {
2112 let mut g = db.write();
2113 for i in 0..300usize {
2114 const D: usize = 32;
2115 let axis = (i / 10) % D;
2116 let mut xs = vec![0.0f64; D];
2117 xs[axis] = 1.0;
2118 xs[(axis + 1) % D] = (i % 10) as f64 * 0.001;
2119 g.insert_node(
2120 "V",
2121 &format!("v{i}"),
2122 vec![(
2123 "emb".into(),
2124 Value::List(xs.into_iter().map(Value::Float).collect()),
2125 )],
2126 )
2127 .expect("insert");
2128 }
2129 g.set_hnsw_build_batch(Some(64));
2130 }
2131 let args = json!({
2132 "name": "sim",
2133 "src_label": "V",
2134 "dst_label": "V",
2135 "predicate": {"VectorSimilar": {"field": "emb", "min": 0.9}},
2136 "edge_type": "SIM",
2137 "weight_prop": null,
2138 "max_edges": null,
2139 "approximate": true
2140 });
2141 let resp = tool_call(&db, 1, "create_rule", args);
2142 assert!(!is_error(&resp), "{resp}");
2143 let result = tool_text(&resp);
2144 assert_eq!(result["name"], json!("sim"));
2145 assert_eq!(result["building"], json!({"indexed": 64, "total": 300}));
2146 let note = result["note"].as_str().expect("a note explaining the wait");
2147 assert!(
2148 note.contains("derives no edges until it finishes") && note.contains("build-index"),
2149 "the note must say the edges are not there yet and how to finish: {note}"
2150 );
2151
2152 let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2154 let rule = stats["rules"]
2155 .as_array()
2156 .expect("rules")
2157 .iter()
2158 .find(|r| r["name"] == "sim")
2159 .expect("the rule is installed while it builds");
2160 assert_eq!(rule["edges"], json!(0));
2161 assert_eq!(
2162 rule["building"],
2163 json!({"rule": "sim", "indexed": 64, "total": 300})
2164 );
2165
2166 while !db.write().pump_index_build().expect("pump").is_empty() {}
2168 let stats = tool_text(&tool_call(&db, 3, "stats", json!({})));
2169 let rule = stats["rules"]
2170 .as_array()
2171 .expect("rules")
2172 .iter()
2173 .find(|r| r["name"] == "sim")
2174 .expect("rule");
2175 assert!(rule.get("building").is_none(), "{rule}");
2176 assert!(rule["edges"].as_u64().expect("edges") > 0);
2177 }
2178
2179 #[test]
2180 fn test_query_runs_cypher() {
2181 let db = demo_db();
2182 let resp = tool_call(
2183 &db,
2184 1,
2185 "query",
2186 json!({ "cypher": "MATCH (n:Person) RETURN n.name ORDER BY n.name" }),
2187 );
2188 assert!(!is_error(&resp));
2189 let result = tool_text(&resp);
2190 assert_eq!(result["columns"], json!(["n.name"]));
2192 assert_eq!(result["rows"].as_array().map(|r| r.len()), Some(2));
2193 }
2194
2195 #[test]
2196 fn test_query_create_is_a_write() {
2197 let _dir = tmp_dir();
2198 let db = SharedDb::open(_dir.path()).expect("open");
2199 let resp = tool_call(
2200 &db,
2201 1,
2202 "query",
2203 json!({ "cypher": "CREATE (n:L {id: 'k'}) RETURN n" }),
2204 );
2205 assert!(
2206 !is_error(&resp),
2207 "CREATE via MCP query must succeed: {resp}"
2208 );
2209 let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2210 assert_eq!(stats["nodes_live"], 1);
2211 }
2212
2213 #[test]
2214 fn test_ingest_json_inserts_nodes() {
2215 let db = demo_db();
2216 let resp = tool_call(
2217 &db,
2218 1,
2219 "ingest_json",
2220 json!({
2221 "label": "Person",
2222 "rows_json": r#"[{"id":"carol","name":"Carol"}]"#,
2223 "key_field": "id"
2224 }),
2225 );
2226 assert!(!is_error(&resp));
2227 let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2229 assert_eq!(stats["nodes_live"], 3);
2230 }
2231
2232 #[test]
2233 fn test_node_info_returns_props() {
2234 let db = demo_db();
2235 let resp = tool_call(&db, 1, "node_info", json!({ "key": "alice" }));
2236 assert!(!is_error(&resp));
2237 let result = tool_text(&resp);
2238 assert_eq!(result["key"], "alice");
2239 assert_eq!(result["label"], "Person");
2240 assert_eq!(result["props"]["name"], "Alice");
2241 }
2242
2243 #[test]
2246 fn test_node_edges_returns_edges() {
2247 let db = demo_db();
2248 let resp = tool_call(
2249 &db,
2250 1,
2251 "node_edges",
2252 json!({ "key": "alice", "json": true }),
2253 );
2254 assert!(!is_error(&resp));
2255 let result = tool_text(&resp);
2256 assert_eq!(result["key"], "alice");
2257 let types = result["types"].as_array().expect("types");
2258 assert!(
2259 !types.is_empty(),
2260 "alice should have at least one edge type"
2261 );
2262 let similar = types
2263 .iter()
2264 .find(|t| t["edge_type"] == "SIMILAR")
2265 .expect("the rule's edge type");
2266 assert_eq!(similar["count"], json!(2));
2269 let edges = similar["edges"].as_array().expect("edges");
2270 let dirs: Vec<&str> = edges
2271 .iter()
2272 .map(|e| e["direction"].as_str().expect("direction"))
2273 .collect();
2274 assert!(dirs.contains(&"out") && dirs.contains(&"in"), "{similar}");
2275 for edge in edges {
2276 assert_eq!(edge["other"], json!("bob"));
2277 assert_eq!(edge["derived"], json!(true));
2278 assert_eq!(edge["rule"], json!("sim_emb"));
2279 assert_eq!(edge["score"], json!(1.0));
2280 assert!(
2281 edge["predicate"]
2282 .as_str()
2283 .unwrap_or("")
2284 .contains("vector_similar"),
2285 "the predicate travels with the edge: {edge}"
2286 );
2287 }
2288 }
2289
2290 #[test]
2293 fn test_neighborhood_traverses_one_hop() {
2294 let db = demo_db();
2295 let resp = tool_call(
2296 &db,
2297 1,
2298 "neighborhood",
2299 json!({ "key": "alice", "depth": 1, "json": true }),
2300 );
2301 assert!(!is_error(&resp));
2302 let result = tool_text(&resp);
2303 assert_eq!(result["key"], "alice");
2304 assert!(result["types"].as_array().is_some(), "{result}");
2305
2306 let deep = tool_call(
2307 &db,
2308 2,
2309 "neighborhood",
2310 json!({ "key": "alice", "depth": 2 }),
2311 );
2312 assert!(!is_error(&deep));
2313 let table = tool_text(&deep);
2314 assert_eq!(table["columns"], json!(["key", "label", "depth"]));
2315 assert!(table["rows"].as_array().is_some());
2316 }
2317
2318 #[test]
2319 fn test_explain_returns_rule_info() {
2320 let db = demo_db();
2321 let resp = tool_call(&db, 1, "explain", json!({ "a": "alice", "b": "bob" }));
2322 assert!(!is_error(&resp));
2323 let result = tool_text(&resp);
2324 let arr = result.as_array().expect("explain returns array");
2325 assert!(!arr.is_empty(), "expected at least one explanation");
2326 assert_eq!(arr[0]["rule"], "sim_emb");
2327 }
2328
2329 #[test]
2330 fn test_create_rule_backfills() {
2331 let _dir = tmp_dir();
2332 let db = SharedDb::open(_dir.path()).expect("open");
2333 {
2334 let mut g = db.write();
2335 let opts = IngestOptions {
2336 key_field: "id".into(),
2337 auto_fk: AutoFk::Off,
2338 };
2339 let rows: Vec<BTreeMap<String, Value>> = vec![
2340 [
2341 ("id", Value::Str("x".into())),
2342 ("tag", Value::Str("a".into())),
2343 ]
2344 .into_iter()
2345 .map(|(k, v)| (k.to_string(), v))
2346 .collect(),
2347 [
2348 ("id", Value::Str("y".into())),
2349 ("tag", Value::Str("a".into())),
2350 ]
2351 .into_iter()
2352 .map(|(k, v)| (k.to_string(), v))
2353 .collect(),
2354 ];
2355 g.ingest("Item", rows, &opts).expect("ingest");
2356 }
2357 let resp = tool_call(
2358 &db,
2359 1,
2360 "create_rule",
2361 json!({
2362 "name": "same_tag",
2363 "src_label": "Item",
2364 "dst_label": "Item",
2365 "predicate": { "FieldEqual": { "field": "tag" } },
2366 "edge_type": "SAME_TAG"
2367 }),
2368 );
2369 assert!(!is_error(&resp));
2370 let result = tool_text(&resp);
2371 assert_eq!(result["ok"], true);
2372 let edges_resp = tool_call(&db, 2, "node_edges", json!({ "key": "x", "json": true }));
2374 let edges_result = tool_text(&edges_resp);
2375 let types = edges_result["types"].as_array().expect("types");
2376 assert!(
2377 types.iter().any(|t| t["edge_type"] == "SAME_TAG"),
2378 "SAME_TAG edge not found after create_rule"
2379 );
2380 }
2381
2382 #[test]
2385 fn test_upsert_entity_creates_new_node() {
2386 let db = demo_db();
2387 let resp = tool_call(
2388 &db,
2389 1,
2390 "upsert_entity",
2391 json!({
2392 "key": "carol",
2393 "label": "Person",
2394 "props": { "name": "Carol", "age": 30 }
2395 }),
2396 );
2397 assert!(!is_error(&resp));
2398 let result = tool_text(&resp);
2399 assert_eq!(result["ok"], true);
2400 assert_eq!(result["created"], true);
2401 assert_eq!(result["key"], "carol");
2402 let info = tool_text(&tool_call(&db, 2, "node_info", json!({ "key": "carol" })));
2404 assert_eq!(info["props"]["name"], "Carol");
2405 }
2406
2407 #[test]
2408 fn upsert_entity_refuses_a_bad_tagged_value_naming_the_prop() {
2409 let db = demo_db();
2410 let before = db.read().node_count();
2411 for (payload, reason) in [
2412 (json!({"$date": "2023-02-29"}), "is not an ISO 8601 date"),
2413 (json!({"$binary": 7}), "payload must be a string"),
2414 ] {
2415 let resp = tool_call(
2416 &db,
2417 1,
2418 "upsert_entity",
2419 json!({"key": "carol", "label": "Person", "props": {"born": payload}}),
2420 );
2421 assert!(is_error(&resp));
2422 let err = tool_err_text(&resp);
2423 assert!(err.contains("prop born: $"), "{err}");
2424 assert!(err.contains(reason), "{err}");
2425 }
2426 assert_eq!(db.read().node_count(), before);
2427 }
2428
2429 #[test]
2430 fn test_upsert_entity_updates_existing_node() {
2431 let db = demo_db();
2432 let resp = tool_call(
2433 &db,
2434 1,
2435 "upsert_entity",
2436 json!({
2437 "key": "alice",
2438 "props": { "name": "Alice Updated" }
2439 }),
2440 );
2441 assert!(!is_error(&resp));
2442 let result = tool_text(&resp);
2443 assert_eq!(result["ok"], true);
2444 assert_eq!(result["created"], false);
2445 assert_eq!(result["updated_fields"], 1);
2446 let info = tool_text(&tool_call(&db, 2, "node_info", json!({ "key": "alice" })));
2448 assert_eq!(info["props"]["name"], "Alice Updated");
2449 }
2450
2451 #[test]
2452 fn test_upsert_entity_missing_label_on_create_is_error() {
2453 let db = demo_db();
2454 let resp = tool_call(
2455 &db,
2456 1,
2457 "upsert_entity",
2458 json!({ "key": "new-node", "props": { "x": 1 } }),
2459 );
2460 assert!(is_error(&resp), "should error without label for new node");
2461 }
2462
2463 #[test]
2464 fn test_pairwise_similar_excludes_self() {
2465 let db = demo_db();
2466 let resp = tool_call(
2467 &db,
2468 1,
2469 "pairwise_similar",
2470 json!({
2471 "keys": ["alice", "bob"],
2472 "field": "emb",
2473 "k": 10,
2474 "min": 0.0
2475 }),
2476 );
2477 assert!(
2478 !is_error(&resp),
2479 "pairwise_similar must not error: {resp:?}"
2480 );
2481 let result = tool_text(&resp);
2482 let results = result["results"].as_array().expect("results");
2483 assert_eq!(results.len(), 2);
2484 for row in results {
2485 let key = row["key"].as_str().expect("key");
2486 let neighbors = row["neighbors"].as_array().expect("neighbors");
2487 assert!(
2488 neighbors.iter().all(|n| n["key"].as_str() != Some(key)),
2489 "self must be excluded: {row}"
2490 );
2491 assert!(!neighbors.is_empty(), "alice/bob are identical: {row}");
2492 }
2493 }
2494
2495 #[test]
2496 fn test_find_similar_returns_similar_edges() {
2497 let db = demo_db();
2498 let resp = tool_call(
2499 &db,
2500 1,
2501 "find_similar",
2502 json!({ "key": "alice", "edge_type": "SIMILAR" }),
2503 );
2504 assert!(!is_error(&resp));
2505 let result = tool_text(&resp);
2506 assert_eq!(result["key"], "alice");
2507 assert_eq!(result["edge_type"], "SIMILAR");
2508 let similar = result["similar"].as_array().expect("similar array");
2509 assert!(!similar.is_empty(), "expected SIMILAR neighbors for alice");
2510 assert_eq!(similar[0]["neighbor_key"], "bob");
2511 }
2512
2513 #[test]
2514 fn test_find_similar_limit_respected() {
2515 let db = demo_db();
2516 let resp = tool_call(
2517 &db,
2518 1,
2519 "find_similar",
2520 json!({ "key": "alice", "edge_type": "SIMILAR", "limit": 0 }),
2521 );
2522 assert!(!is_error(&resp));
2523 let result = tool_text(&resp);
2524 let similar = result["similar"].as_array().expect("similar array");
2525 assert_eq!(similar.len(), 0);
2526 }
2527
2528 #[test]
2532 fn test_find_similar_vector_default_min_is_0_8() {
2533 let _dir = tmp_dir();
2534 let db = SharedDb::open(_dir.path()).expect("open");
2535 {
2536 let mut g = db.write();
2537 g.insert_node(
2539 "Item",
2540 "close",
2541 vec![(
2542 "emb".into(),
2543 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2544 )],
2545 )
2546 .unwrap();
2547 g.insert_node(
2549 "Item",
2550 "far",
2551 vec![(
2552 "emb".into(),
2553 Value::List(vec![Value::Float(0.0), Value::Float(1.0)]),
2554 )],
2555 )
2556 .unwrap();
2557 g.insert_node(
2560 "Item",
2561 "mid",
2562 vec![(
2563 "emb".into(),
2564 Value::List(vec![Value::Float(0.6), Value::Float(0.8)]),
2565 )],
2566 )
2567 .unwrap();
2568 }
2569
2570 let resp = tool_call(
2572 &db,
2573 1,
2574 "find_similar",
2575 json!({
2576 "vector": [1.0, 0.0],
2577 "field": "emb",
2578 "label": "Item",
2579 "k": 10
2580 }),
2581 );
2582 assert!(!is_error(&resp), "vector search must not error");
2583 let result = tool_text(&resp);
2584 let results = result["results"].as_array().expect("results array");
2585
2586 let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2587 assert_eq!(
2588 keys,
2589 vec!["close"],
2590 "only the hit at or above the default survives: mid (0.6) and far (0.0) do not"
2591 );
2592 assert_eq!(
2593 result["min"],
2594 json!(mushroomdb::FIND_SIMILAR_DEFAULT_MIN),
2595 "the reply echoes the floor it applied"
2596 );
2597
2598 let resp = tool_call(
2601 &db,
2602 2,
2603 "find_similar",
2604 json!({
2605 "vector": [1.0, 0.0],
2606 "field": "emb",
2607 "label": "Item",
2608 "k": 10,
2609 "min": 0.0
2610 }),
2611 );
2612 assert!(!is_error(&resp), "vector search must not error");
2613 let result = tool_text(&resp);
2614 let results = result["results"].as_array().expect("results array");
2615 let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2616 assert_eq!(
2617 keys,
2618 vec!["close", "mid", "far"],
2619 "an explicit min of 0.0 keeps the hits the default drops"
2620 );
2621 assert_eq!(
2622 result["min"],
2623 json!(0.0),
2624 "the reply echoes the named floor"
2625 );
2626 }
2627
2628 #[test]
2630 fn test_find_similar_vector_mask_excludes_hidden() {
2631 let _dir = tmp_dir();
2632 let db = SharedDb::open(_dir.path()).expect("open");
2633 {
2634 let mut g = db.write();
2635 g.insert_node(
2637 "Item",
2638 "visible",
2639 vec![(
2640 "emb".into(),
2641 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2642 )],
2643 )
2644 .unwrap();
2645 g.insert_node(
2647 "Item",
2648 "hidden",
2649 vec![(
2650 "emb".into(),
2651 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2652 )],
2653 )
2654 .unwrap();
2655 }
2656
2657 let resp = tool_call(
2658 &db,
2659 1,
2660 "find_similar",
2661 json!({
2662 "vector": [1.0, 0.0],
2663 "field": "emb",
2664 "label": "Item",
2665 "k": 10,
2666 "min": 0.0,
2667 "mask": ["visible"]
2668 }),
2669 );
2670 assert!(!is_error(&resp), "masked vector search must not error");
2671 let result = tool_text(&resp);
2672 let results = result["results"].as_array().expect("results array");
2673
2674 let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2675 assert!(
2676 keys.contains(&"visible"),
2677 "visible node must appear in masked results"
2678 );
2679 assert!(
2680 !keys.contains(&"hidden"),
2681 "hidden node must be excluded by mask"
2682 );
2683 }
2684
2685 #[test]
2687 fn test_find_similar_vector_mask_bad_type_is_error() {
2688 let _dir = tmp_dir();
2689 let db = SharedDb::open(_dir.path()).expect("open");
2690 let resp = tool_call(
2691 &db,
2692 1,
2693 "find_similar",
2694 json!({
2695 "vector": [1.0, 0.0],
2696 "field": "emb",
2697 "k": 5,
2698 "mask": [42]
2699 }),
2700 );
2701 assert!(
2702 is_error(&resp),
2703 "non-string mask element must produce a tool error"
2704 );
2705 }
2706
2707 #[test]
2712 fn find_similar_drops_non_numeric_vector_elements_is_error() {
2713 let db = demo_db();
2714 for (tool, args, want) in [
2715 (
2716 "find_similar",
2717 json!({ "vector": [1, "x", 0], "field": "emb" }),
2718 "vector elements must all be numbers",
2719 ),
2720 (
2721 "hybrid_search",
2722 json!({
2723 "query_text": "alice", "text_field": "name",
2724 "vector": [1.0, null], "vector_field": "emb"
2725 }),
2726 "vector elements must all be numbers",
2727 ),
2728 (
2729 "find_similar",
2730 json!({ "vector": "1,0", "field": "emb" }),
2731 "vector must be an array of numbers",
2732 ),
2733 (
2734 "hybrid_search",
2735 json!({ "query_text": "alice", "text_field": "name", "vector": 5 }),
2736 "vector must be an array of numbers",
2737 ),
2738 (
2739 "hybrid_search",
2740 json!({ "query_text": "alice", "text_field": "name", "k": "3" }),
2741 "k must be a non-negative integer",
2742 ),
2743 (
2744 "pairwise_similar",
2745 json!({ "keys": ["alice", "bob"], "field": "emb", "k": "3" }),
2746 "k must be a non-negative integer",
2747 ),
2748 (
2749 "pairwise_similar",
2750 json!({ "keys": ["alice", "bob"], "field": "emb", "min": "0.5" }),
2751 "min must be a number",
2752 ),
2753 ] {
2754 let resp = tool_call(&db, 1, tool, args.clone());
2755 assert!(
2756 is_error(&resp),
2757 "{tool} {args} must be a tool error, got {resp}"
2758 );
2759 assert_eq!(tool_err_text(&resp), want, "for {tool} {args}");
2760 }
2761 let resp = tool_call(
2763 &db,
2764 2,
2765 "pairwise_similar",
2766 json!({ "keys": ["alice", "bob"], "field": "emb", "k": null }),
2767 );
2768 assert!(!is_error(&resp), "null takes the default: {resp}");
2769 assert_eq!(tool_text(&resp)["k"], json!(10));
2770 }
2771
2772 #[test]
2775 fn create_rule_schema_advertises_the_via_fields() {
2776 let db = demo_db();
2777 let resp = roundtrip_with(
2778 &db,
2779 true,
2780 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
2781 );
2782 let tools = resp["result"]["tools"].as_array().expect("tools array");
2783 let rule = tools
2784 .iter()
2785 .find(|t| t["name"] == "create_rule")
2786 .expect("create_rule");
2787 let props = &rule["inputSchema"]["properties"];
2788 for field in ["via_label", "via_edge", "via_dir"] {
2789 assert!(props.get(field).is_some(), "{field} missing: {props}");
2790 }
2791 assert_eq!(props["via_dir"]["enum"], json!(["Out", "In"]));
2792 }
2793
2794 #[test]
2798 fn test_find_similar_non_numeric_min_k_or_limit_is_error() {
2799 let db = demo_db();
2800 for (args, want) in [
2801 (
2802 json!({ "vector": [1.0, 0.0], "field": "emb", "min": "0.5" }),
2803 "min must be a number",
2804 ),
2805 (
2806 json!({ "vector": [1.0, 0.0], "field": "emb", "k": "3" }),
2807 "k must be a non-negative integer",
2808 ),
2809 (
2810 json!({ "vector": [1.0, 0.0], "field": "emb", "k": -1 }),
2811 "k must be a non-negative integer",
2812 ),
2813 (
2814 json!({ "key": "alice", "limit": "1" }),
2815 "limit must be a non-negative integer",
2816 ),
2817 ] {
2818 let resp = tool_call(&db, 1, "find_similar", args.clone());
2819 assert!(is_error(&resp), "{args} must be a tool error, got {resp}");
2820 assert_eq!(tool_err_text(&resp), want, "for {args}");
2821 }
2822
2823 let resp = tool_call(
2825 &db,
2826 2,
2827 "find_similar",
2828 json!({ "vector": [1.0, 0.0], "field": "emb", "k": null, "min": null }),
2829 );
2830 assert!(!is_error(&resp), "null takes the default: {resp}");
2831 let result = tool_text(&resp);
2832 assert_eq!(result["k"], json!(10));
2833 assert_eq!(result["min"], json!(mushroomdb::FIND_SIMILAR_DEFAULT_MIN));
2834 let resp = tool_call(
2835 &db,
2836 3,
2837 "find_similar",
2838 json!({ "key": "alice", "limit": null }),
2839 );
2840 assert!(!is_error(&resp), "a null limit takes the default: {resp}");
2841 }
2842
2843 #[test]
2845 fn test_find_similar_vector_where_eq() {
2846 let _dir = tmp_dir();
2847 let db = SharedDb::open(_dir.path()).expect("open");
2848 {
2849 let mut g = db.write();
2850 g.insert_node(
2851 "Document",
2852 "in-scope",
2853 vec![
2854 (
2855 "emb".into(),
2856 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2857 ),
2858 ("resource_scope_id".into(), Value::Str("a".into())),
2859 ],
2860 )
2861 .unwrap();
2862 g.insert_node(
2863 "Document",
2864 "out-scope",
2865 vec![
2866 (
2867 "emb".into(),
2868 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2869 ),
2870 ("resource_scope_id".into(), Value::Str("b".into())),
2871 ],
2872 )
2873 .unwrap();
2874 }
2875 let resp = tool_call(
2876 &db,
2877 1,
2878 "find_similar",
2879 json!({
2880 "vector": [1.0, 0.0],
2881 "field": "emb",
2882 "label": "Document",
2883 "k": 10,
2884 "min": 0.0,
2885 "where": { "field": "resource_scope_id", "eq": "a" }
2886 }),
2887 );
2888 assert!(!is_error(&resp), "where eq must not error: {resp:?}");
2889 let result = tool_text(&resp);
2890 let keys: Vec<&str> = result["results"]
2891 .as_array()
2892 .expect("results")
2893 .iter()
2894 .filter_map(|r| r["key"].as_str())
2895 .collect();
2896 assert_eq!(keys, vec!["in-scope"]);
2897 }
2898
2899 #[test]
2900 fn test_find_similar_vector_where_invalid_is_error() {
2901 let _dir = tmp_dir();
2902 let db = SharedDb::open(_dir.path()).expect("open");
2903 let resp = tool_call(
2904 &db,
2905 1,
2906 "find_similar",
2907 json!({
2908 "vector": [1.0, 0.0],
2909 "field": "emb",
2910 "where": { "field": "resource_scope_id", "eq": "a", "in": ["b"] }
2911 }),
2912 );
2913 assert!(is_error(&resp), "invalid where must be a tool error");
2914 let msg = format!("{resp:?}");
2915 assert!(
2916 msg.contains("where"),
2917 "tool error must name where, got {msg}"
2918 );
2919 }
2920
2921 #[test]
2923 fn test_find_similar_edge_ignores_where_and_exact() {
2924 let db = demo_db();
2925 let resp = tool_call(
2926 &db,
2927 1,
2928 "find_similar",
2929 json!({
2930 "key": "alice",
2931 "edge_type": "SIMILAR",
2932 "where": { "field": "x", "eq": "y", "in": ["z"] },
2933 "exact": true
2934 }),
2935 );
2936 assert!(
2937 !is_error(&resp),
2938 "edge mode must ignore invalid where: {resp:?}"
2939 );
2940 }
2941
2942 #[test]
2944 fn test_find_similar_edge_mask_excludes_hidden_neighbor() {
2945 let _dir = tmp_dir();
2946 let db = SharedDb::open(_dir.path()).expect("open");
2947 {
2948 let mut g = db.write();
2949 g.insert_node("P", "alice", vec![]).unwrap();
2950 g.insert_node("P", "bob", vec![]).unwrap(); g.insert_node("P", "carol", vec![]).unwrap(); g.insert_edge("KNOWS", "alice", "bob").unwrap();
2953 g.insert_edge("KNOWS", "alice", "carol").unwrap();
2954 }
2955 let resp = tool_call(
2957 &db,
2958 1,
2959 "find_similar",
2960 json!({
2961 "key": "alice",
2962 "edge_type": "KNOWS",
2963 "mask": ["alice", "bob"]
2964 }),
2965 );
2966 assert!(!is_error(&resp), "masked edge search must not error");
2967 let result = tool_text(&resp);
2968 let similar = result["similar"].as_array().expect("similar array");
2969 let neighbors: Vec<&str> = similar
2970 .iter()
2971 .filter_map(|e| e["neighbor_key"].as_str())
2972 .collect();
2973 assert!(neighbors.contains(&"bob"), "bob (visible) must appear");
2974 assert!(
2975 !neighbors.contains(&"carol"),
2976 "carol (hidden) must be excluded"
2977 );
2978 }
2979
2980 #[test]
2983 fn test_find_similar_edge_mask_hidden_key_is_not_found() {
2984 let _dir = tmp_dir();
2985 let db = SharedDb::open(_dir.path()).expect("open");
2986 {
2987 let mut g = db.write();
2988 g.insert_node("P", "alice", vec![]).unwrap();
2989 g.insert_node("P", "bob", vec![]).unwrap();
2990 }
2991 let resp_masked = tool_call(
2993 &db,
2994 1,
2995 "find_similar",
2996 json!({ "key": "alice", "edge_type": "KNOWS", "mask": ["bob"] }),
2997 );
2998 let resp_ghost = tool_call(
3000 &db,
3001 2,
3002 "find_similar",
3003 json!({ "key": "ghost", "edge_type": "KNOWS" }),
3004 );
3005 assert!(
3006 is_error(&resp_masked),
3007 "hidden query key must produce a tool error"
3008 );
3009 assert!(
3010 is_error(&resp_ghost),
3011 "nonexistent key must produce a tool error"
3012 );
3013 assert_eq!(
3015 tool_err_text(&resp_masked).contains("alice"),
3016 tool_err_text(&resp_ghost).contains("ghost"),
3017 "error messages should follow same not-found template"
3018 );
3019 }
3020
3021 #[test]
3026 fn test_explain_association_same_as_explain() {
3027 let db = demo_db();
3028 let explain = tool_text(&tool_call(
3029 &db,
3030 1,
3031 "explain",
3032 json!({ "a": "alice", "b": "bob" }),
3033 ));
3034 let assoc = tool_text(&tool_call(
3035 &db,
3036 2,
3037 "explain_association",
3038 json!({ "a": "alice", "b": "bob", "json": true }),
3039 ));
3040 let explain: Vec<Js> = serde_json::from_value(explain).expect("explain array");
3041 let mut assoc: Vec<Js> = serde_json::from_value(assoc).expect("assoc array");
3042 for row in &mut assoc {
3043 let ev = row
3044 .as_object_mut()
3045 .expect("object")
3046 .remove("evidence")
3047 .expect("every derived edge carries its evidence");
3048 assert!(
3049 ev["similarity"].is_number(),
3050 "a vector_similar edge reports the cosine it scored: {ev}"
3051 );
3052 }
3053 assert_eq!(explain, assoc, "evidence is the only addition");
3054
3055 let prose = tool_call(
3056 &db,
3057 3,
3058 "explain_association",
3059 json!({ "a": "alice", "b": "bob" }),
3060 );
3061 let text = prose["result"]["content"][0]["text"]
3062 .as_str()
3063 .expect("text content");
3064 assert!(
3065 text.contains("mushroomdb explain — alice ↔ bob:"),
3066 "the default reply is the digest: {text}"
3067 );
3068 }
3069
3070 #[test]
3075 fn test_edge_history_returns_derived_lifecycle_with_rule() {
3076 let db = demo_db(); let resp = tool_call(&db, 1, "edge_history", json!({ "a": "alice", "b": "bob" }));
3078 assert!(!is_error(&resp), "edge_history must not error: {resp}");
3079 let result = tool_text(&resp);
3080
3081 let total = result["total_commits"].as_u64().expect("total_commits");
3083 assert!(total > 0, "total_commits must be > 0 after ingest + rule");
3084
3085 let events = result["events"].as_array().expect("events array");
3087 assert!(!events.is_empty(), "expected at least one edge event");
3088
3089 let derived_added = events
3091 .iter()
3092 .any(|ev| ev["event"].as_str() == Some("Added") && !ev["rule"].is_null());
3093 assert!(
3094 derived_added,
3095 "expected a derived Added event with rule attribution: {events:?}"
3096 );
3097 }
3098
3099 #[test]
3102 fn test_was_linked_at_valid_commit() {
3103 let _dir = tmp_dir();
3104 let db = SharedDb::open(_dir.path()).expect("open");
3105 {
3106 let mut g = db.write();
3107 let opts = IngestOptions {
3108 key_field: "id".into(),
3109 auto_fk: AutoFk::Off,
3110 };
3111 let rows: Vec<BTreeMap<String, Value>> = vec![
3112 [("id", Value::Str("x".into()))]
3113 .into_iter()
3114 .map(|(k, v)| (k.to_string(), v))
3115 .collect(),
3116 [("id", Value::Str("y".into()))]
3117 .into_iter()
3118 .map(|(k, v)| (k.to_string(), v))
3119 .collect(),
3120 ];
3121 g.ingest("N", rows, &opts).expect("ingest");
3122 g.insert_edge("LINK", "x", "y").expect("edge");
3123 }
3124 let g = db.read();
3126 let total = g.wal_total_commits().expect("wal_total_commits");
3127 drop(g);
3128
3129 let resp = tool_call(
3130 &db,
3131 1,
3132 "was_linked",
3133 json!({ "a": "x", "b": "y", "edge_type": "LINK", "at_commit": total - 1 }),
3134 );
3135 assert!(!is_error(&resp), "was_linked must not error: {resp}");
3136 let result = tool_text(&resp);
3137 assert_eq!(result["linked"], true);
3138 assert_eq!(result["a"], "x");
3139 assert_eq!(result["edge_type"], "LINK");
3140 }
3141
3142 #[test]
3145 fn test_was_linked_out_of_horizon_returns_tool_error() {
3146 let _dir = tmp_dir();
3147 let db = SharedDb::open(_dir.path()).expect("open");
3148 {
3149 let mut g = db.write();
3150 g.insert_node("N", "a", vec![]).expect("node a");
3151 g.insert_node("N", "b", vec![]).expect("node b");
3152 }
3153 let resp = tool_call(
3155 &db,
3156 1,
3157 "was_linked",
3158 json!({ "a": "a", "b": "b", "edge_type": "X", "at_commit": 999 }),
3159 );
3160 assert!(
3162 is_error(&resp),
3163 "out-of-range commit must be a tool error: {resp}"
3164 );
3165 let text = resp["result"]["content"][0]["text"].as_str().expect("text");
3166 assert!(
3167 text.contains("out of range") || text.contains("range"),
3168 "error must mention range: {text}"
3169 );
3170 }
3171
3172 #[test]
3175 fn test_node_history_via_mcp() {
3176 let db = demo_db(); let resp = tool_call(&db, 1, "node_history", json!({ "key": "alice" }));
3178 assert!(!is_error(&resp), "node_history must not error: {resp}");
3179 let result = tool_text(&resp);
3180
3181 assert_eq!(result["key"], "alice");
3182 let total = result["total_commits"].as_u64().expect("total_commits");
3183 assert!(total > 0, "total_commits must be > 0");
3184
3185 let history = result["history"].as_array().expect("history array");
3186 assert!(
3187 !history.is_empty(),
3188 "alice should have at least one history entry"
3189 );
3190
3191 let first_change = &history[0]["change"];
3193 assert_eq!(first_change["type"], "NodeInserted");
3194 assert_eq!(first_change["label"], "Person");
3195 }
3196}