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 Some(v) => {
693 row.insert(field.clone(), v);
694 }
695 None => {
696 return CallOutcome::ToolErr(format!("prop {field} is not a supported value type"))
697 }
698 }
699 }
700 if let Some(ns) = namespace.as_deref() {
701 if let Err(e) = stamp_namespace_row(&mut row, ns) {
702 return CallOutcome::ToolErr(e);
703 }
704 }
705
706 let outcome = {
710 let mut g = db.write();
711 match memory::remember::upsert_entity(&mut *g, key, label_opt, row, &aliases) {
712 Ok(o) => o,
713 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
714 }
715 };
716 if outcome.created {
717 return CallOutcome::ToolOk(json!({
718 "ok": true,
719 "key": key,
720 "label": outcome.label,
721 "created": true
722 }));
723 }
724 let mut reply = json!({
725 "ok": true,
726 "key": key,
727 "label": outcome.label,
728 "created": false,
729 "updated_fields": outcome.updated_fields
730 });
731 let lost = &outcome.same_as_lost;
734 if !lost.is_empty() {
735 reply["same_as_lost"] = crate::mcp_tasks::same_as_lost_json(lost);
736 reply["same_as_lost_total"] = json!(lost.len());
737 reply["same_as_lost_note"] = json!(format!(
738 "this write retracted {} SAME_AS link(s): {}",
739 lost.len(),
740 crate::mcp_tasks::SAME_AS_LOST_REMEDY
741 ));
742 }
743 CallOutcome::ToolOk(reply)
744}
745
746fn tool_find_similar(db: &SharedDb, args: &Js) -> CallOutcome {
753 let mask_keys: Option<Vec<String>> = if let Some(mask_val) = args.get("mask") {
756 match mask_val.as_array() {
757 Some(arr) => {
758 let mut ks: Vec<String> = Vec::with_capacity(arr.len());
759 for v in arr {
760 match v.as_str() {
761 Some(s) => ks.push(s.to_string()),
762 None => {
763 return CallOutcome::ToolErr("mask must be an array of strings".into())
764 }
765 }
766 }
767 Some(ks)
768 }
769 None => return CallOutcome::ToolErr("mask must be an array of strings".into()),
770 }
771 } else {
772 None
773 };
774
775 if let Some(vec_js) = args.get("vector").and_then(Js::as_array) {
778 let q: Vec<f64> = vec_js.iter().filter_map(|v| v.as_f64()).collect();
779 if q.is_empty() {
780 return CallOutcome::ToolErr("vector must be a non-empty array of numbers".into());
781 }
782 let field = args
783 .get("field")
784 .and_then(Js::as_str)
785 .unwrap_or("embedding");
786 let label_str = args.get("label").and_then(Js::as_str).unwrap_or("");
787 let label = if label_str.is_empty() {
788 None
789 } else {
790 Some(label_str)
791 };
792 let k = match args.get("k") {
795 None | Some(Js::Null) => 10usize,
796 Some(v) => match v.as_u64() {
797 Some(n) => n as usize,
798 None => return CallOutcome::ToolErr("k must be a non-negative integer".into()),
799 },
800 };
801 let min = match args.get("min") {
802 None | Some(Js::Null) => mushroomdb::FIND_SIMILAR_DEFAULT_MIN,
803 Some(v) => match v.as_f64() {
804 Some(n) => n,
805 None => return CallOutcome::ToolErr("min must be a number".into()),
806 },
807 };
808 let where_pred = match parse_where_arg(args) {
809 Ok(p) => p,
810 Err(e) => return CallOutcome::ToolErr(e),
811 };
812 let exact = match args.get("exact") {
813 None => false,
814 Some(v) => match v.as_bool() {
815 Some(b) => b,
816 None => return CallOutcome::ToolErr("exact must be a boolean".into()),
817 },
818 };
819 let exact = exact || where_pred.is_some();
820
821 let hits = {
822 let g = db.read();
823 let node_mask = mask_keys
824 .as_ref()
825 .map(|keys| NodeMask::from_keys(&*g, keys.iter().map(String::as_str)));
826 match g.find_similar_vector_filtered(
827 field,
828 label,
829 &q,
830 k,
831 min,
832 node_mask.as_ref(),
833 where_pred.as_ref(),
834 exact,
835 ) {
836 Ok(h) => h,
837 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
838 }
839 };
840 let results: Vec<Js> = hits
841 .into_iter()
842 .map(|(key, score)| json!({ "key": key, "score": score }))
843 .collect();
844 return CallOutcome::ToolOk(json!({
845 "mode": "vector",
846 "field": field,
847 "label": label,
848 "k": k,
849 "min": min,
850 "results": results
851 }));
852 }
853
854 let Some(key) = args.get("key").and_then(Js::as_str) else {
856 return CallOutcome::ToolErr("missing key (or provide vector for vector search)".into());
857 };
858 let edge_type = args
859 .get("edge_type")
860 .and_then(Js::as_str)
861 .unwrap_or("SIMILAR");
862 let limit = match args.get("limit") {
863 None | Some(Js::Null) => 10usize,
864 Some(v) => match v.as_u64() {
865 Some(n) => n as usize,
866 None => return CallOutcome::ToolErr("limit must be a non-negative integer".into()),
867 },
868 };
869
870 if let Some(ref mask) = mask_keys {
873 let mask_set: std::collections::HashSet<&str> = mask.iter().map(String::as_str).collect();
874 if !mask_set.contains(key) {
875 return CallOutcome::ToolErr(graph_err_msg(GraphError::KeyNotFound {
876 key: key.into(),
877 }));
878 }
879 let out = {
880 let g = db.read();
881 g.node_edges(key)
882 };
883 return match out {
884 Ok(edges) => {
885 let similar: Vec<Js> = edges
886 .iter()
887 .filter(|e| e.edge_type == edge_type)
888 .filter(|e| {
889 let neighbor_key = if e.src_key == key {
891 &e.dst_key
892 } else {
893 &e.src_key
894 };
895 mask_set.contains(neighbor_key.as_str())
896 })
897 .take(limit)
898 .map(|e| {
899 let neighbor_key = if e.src_key == key {
900 &e.dst_key
901 } else {
902 &e.src_key
903 };
904 let direction = if e.src_key == key { "out" } else { "in" };
905 json!({
906 "neighbor_key": neighbor_key,
907 "direction": direction,
908 "edge_type": e.edge_type,
909 "derived": e.derived,
910 })
911 })
912 .collect();
913 CallOutcome::ToolOk(json!({
914 "key": key,
915 "edge_type": edge_type,
916 "similar": similar
917 }))
918 }
919 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
920 };
921 }
922
923 let out = {
924 let g = db.read();
925 g.node_edges(key)
926 };
927 match out {
928 Ok(edges) => {
929 let similar: Vec<Js> = edges
930 .iter()
931 .filter(|e| e.edge_type == edge_type)
932 .take(limit)
933 .map(|e| {
934 let neighbor_key = if e.src_key == key {
935 &e.dst_key
936 } else {
937 &e.src_key
938 };
939 let direction = if e.src_key == key { "out" } else { "in" };
940 json!({
941 "neighbor_key": neighbor_key,
942 "direction": direction,
943 "edge_type": e.edge_type,
944 "derived": e.derived,
945 })
946 })
947 .collect();
948 CallOutcome::ToolOk(json!({
949 "key": key,
950 "edge_type": edge_type,
951 "similar": similar
952 }))
953 }
954 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
955 }
956}
957
958fn tool_pairwise_similar(db: &SharedDb, args: &Js) -> CallOutcome {
960 let Some(keys_js) = args.get("keys").and_then(Js::as_array) else {
961 return CallOutcome::ToolErr("missing required field: keys".into());
962 };
963 let mut keys: Vec<String> = Vec::with_capacity(keys_js.len());
964 for v in keys_js {
965 match v.as_str() {
966 Some(s) => keys.push(s.to_string()),
967 None => return CallOutcome::ToolErr("keys must be an array of strings".into()),
968 }
969 }
970 let Some(field) = args.get("field").and_then(Js::as_str) else {
971 return CallOutcome::ToolErr("missing required field: field".into());
972 };
973 let k = args
974 .get("k")
975 .and_then(Js::as_u64)
976 .map(|n| n as usize)
977 .unwrap_or(10);
978 let min = args.get("min").and_then(Js::as_f64).unwrap_or(0.0);
979 let refs: Vec<&str> = keys.iter().map(String::as_str).collect();
980 let out = {
981 let g = db.read();
982 g.pairwise_similar(&refs, field, k, min)
983 };
984 match out {
985 Ok(pairs) => {
986 let results: Vec<Js> = pairs
987 .into_iter()
988 .map(|(key, neighbors)| {
989 json!({
990 "key": key,
991 "neighbors": neighbors
992 .into_iter()
993 .map(|(n, score)| json!({ "key": n, "score": score }))
994 .collect::<Vec<_>>(),
995 })
996 })
997 .collect();
998 CallOutcome::ToolOk(json!({
999 "field": field,
1000 "k": k,
1001 "min": min,
1002 "results": results
1003 }))
1004 }
1005 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1006 }
1007}
1008
1009fn tool_hybrid_search(db: &SharedDb, args: &Js) -> CallOutcome {
1010 let Some(query_text) = args.get("query_text").and_then(Js::as_str) else {
1011 return CallOutcome::ToolErr("missing required field: query_text".into());
1012 };
1013 let Some(text_field) = args.get("text_field").and_then(Js::as_str) else {
1014 return CallOutcome::ToolErr("missing required field: text_field".into());
1015 };
1016
1017 let vector_field = args
1018 .get("vector_field")
1019 .and_then(Js::as_str)
1020 .unwrap_or("embedding");
1021 let label = args.get("label").and_then(Js::as_str);
1022 let k = args
1023 .get("k")
1024 .and_then(Js::as_u64)
1025 .map(|n| n as usize)
1026 .unwrap_or(10);
1027
1028 let query_vec: Vec<f64> = args
1029 .get("vector")
1030 .and_then(Js::as_array)
1031 .map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect())
1032 .unwrap_or_default();
1033
1034 let hits = {
1035 let g = db.read();
1036 g.search_hybrid(text_field, query_text, vector_field, &query_vec, label, k)
1037 };
1038
1039 let results: Vec<Js> = hits
1040 .into_iter()
1041 .map(|(key, score)| json!({ "key": key, "score": score }))
1042 .collect();
1043
1044 CallOutcome::ToolOk(json!({
1045 "query_text": query_text,
1046 "text_field": text_field,
1047 "vector_field": vector_field,
1048 "label": label,
1049 "k": k,
1050 "results": results
1051 }))
1052}
1053
1054fn tool_node_history(db: &SharedDb, args: &Js) -> CallOutcome {
1055 let Some(key) = args.get("key").and_then(Js::as_str) else {
1056 return CallOutcome::ToolErr("missing key".into());
1057 };
1058 let g = db.read();
1059 let result = match g.node_history(key) {
1060 Ok(e) => e,
1061 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
1062 };
1063 CallOutcome::ToolOk(node_history_json(key, &result))
1064}
1065
1066fn tool_edge_history(db: &SharedDb, args: &Js) -> CallOutcome {
1067 let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1068 return CallOutcome::ToolErr("missing a".into());
1069 };
1070 let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1071 return CallOutcome::ToolErr("missing b".into());
1072 };
1073 let result = {
1074 let g = db.read();
1075 g.edge_history(a, b)
1076 };
1077 match result {
1078 Ok(hr) => CallOutcome::ToolOk(edge_history_result_json(a, b, &hr)),
1079 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1080 }
1081}
1082
1083fn tool_was_linked(db: &SharedDb, args: &Js) -> CallOutcome {
1084 let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1085 return CallOutcome::ToolErr("missing a".into());
1086 };
1087 let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1088 return CallOutcome::ToolErr("missing b".into());
1089 };
1090 let Some(edge_type) = args
1091 .get("edge_type")
1092 .and_then(Js::as_str)
1093 .filter(|s| !s.is_empty())
1094 else {
1095 return CallOutcome::ToolErr("missing edge_type".into());
1096 };
1097 let at_commit = match args.get("at_commit") {
1101 Some(Js::String(date)) => match db.read().resolve_date(date) {
1102 Ok(c) => c,
1103 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
1104 },
1105 Some(v) => match v.as_u64() {
1106 Some(n) => n,
1107 None => {
1108 return CallOutcome::ToolErr(
1109 "at_commit must be a non-negative commit index or an RFC 3339 date".into(),
1110 )
1111 }
1112 },
1113 None => return CallOutcome::ToolErr("missing or invalid at_commit".into()),
1114 };
1115 let result = {
1116 let g = db.read();
1117 g.was_linked(a, b, edge_type, at_commit)
1118 };
1119 match result {
1120 Ok(linked) => CallOutcome::ToolOk(json!({
1121 "a": a,
1122 "b": b,
1123 "edge_type": edge_type,
1124 "at_commit": at_commit,
1125 "linked": linked,
1126 })),
1127 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1128 }
1129}
1130
1131fn tool_rename_node(db: &SharedDb, args: &Js) -> CallOutcome {
1132 let Some(old_key) = args.get("old_key").and_then(Js::as_str) else {
1133 return CallOutcome::ToolErr("missing old_key".into());
1134 };
1135 let Some(new_key) = args.get("new_key").and_then(Js::as_str) else {
1136 return CallOutcome::ToolErr("missing new_key".into());
1137 };
1138 let mut g = db.write();
1139 match g.rename_node(old_key, new_key) {
1140 Ok(()) => CallOutcome::ToolOk(json!({
1141 "ok": true,
1142 "old_key": old_key,
1143 "new_key": new_key,
1144 })),
1145 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1146 }
1147}
1148
1149fn parse_where_arg(args: &Js) -> std::result::Result<Option<PropPredicate>, String> {
1150 let Some(w) = args.get("where") else {
1151 return Ok(None);
1152 };
1153 if w.is_null() {
1154 return Ok(None);
1155 }
1156 let pred: PropPredicate =
1157 serde_json::from_value(w.clone()).map_err(|e| format!("where: {e}"))?;
1158 pred.validate_named("where")?;
1159 Ok(Some(pred))
1160}
1161
1162pub(crate) fn graph_err_msg(e: GraphError) -> String {
1163 match e {
1164 GraphError::QueryError { detail } | GraphError::IngestError { detail } => detail,
1165 other => other.to_string(),
1166 }
1167}
1168
1169fn initialize_result() -> Js {
1170 json!({
1171 "protocolVersion": "2024-11-05",
1172 "capabilities": { "tools": {} },
1173 "serverInfo": { "name": "mushroomdb", "version": env!("CARGO_PKG_VERSION") }
1174 })
1175}
1176
1177const ADVANCED_PREFIX: &str = "Advanced: ";
1183
1184pub const ASSOCIATION_TOOLS: [&str; 23] = [
1220 "query",
1221 "explain_association",
1222 "neighborhood",
1223 "node_info",
1224 "node_edges",
1225 "was_linked",
1226 "edges_at",
1227 "what_if",
1228 "node_history",
1229 "edge_history",
1230 "find_similar",
1231 "pairwise_similar",
1232 "hybrid_search",
1233 "remember",
1234 "recall",
1235 "upsert_entity",
1236 "ingest_json",
1237 "create_rule",
1238 "stats",
1239 "schema",
1240 "analyze",
1241 "suggest_rules",
1242 "forget",
1243];
1244
1245fn tools_list(all: bool) -> Js {
1259 let mut served: Vec<Js> = crate::mcp_tasks::task_tools();
1260 for mut tool in graph_tools() {
1261 if let Some(d) = tool.get("description").and_then(Js::as_str) {
1262 let prefixed = format!("{ADVANCED_PREFIX}{d}");
1263 tool["description"] = Js::String(prefixed);
1264 }
1265 served.push(tool);
1266 }
1267 if all {
1268 return json!({ "tools": served });
1269 }
1270 let mut tools: Vec<Js> = Vec::with_capacity(ASSOCIATION_TOOLS.len());
1271 for name in ASSOCIATION_TOOLS {
1272 let Some(tool) = served
1273 .iter()
1274 .find(|t| t.get("name").and_then(Js::as_str) == Some(name))
1275 else {
1276 debug_assert!(false, "ASSOCIATION_TOOLS lists {name}, which is not served");
1277 continue;
1278 };
1279 tools.push(tool.clone());
1280 }
1281 json!({ "tools": tools })
1282}
1283
1284fn graph_tools() -> Vec<Js> {
1287 let Js::Array(tools) = json!([
1288 {
1289 "name": "query",
1290 "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.",
1291 "inputSchema": {
1292 "type": "object",
1293 "properties": {
1294 "cypher": { "type": "string", "description": "Cypher query text." },
1295 "params": {
1296 "type": "object",
1297 "description": "Named JSON-scalar query parameters."
1298 },
1299 "mask": {
1300 "type": "array",
1301 "items": { "type": "string" },
1302 "description": "Optional node key allow-list. When present, only these nodes are visible; write statements are rejected."
1303 },
1304 "role": {
1305 "type": "string",
1306 "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."
1307 },
1308 "as_of": {
1309 "type": "integer",
1310 "minimum": 0,
1311 "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."
1312 },
1313 "namespace": {
1314 "type": "string",
1315 "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."
1316 }
1317 },
1318 "required": ["cypher"]
1319 }
1320 },
1321 {
1322 "name": "ingest_json",
1323 "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'.",
1324 "inputSchema": {
1325 "type": "object",
1326 "properties": {
1327 "label": { "type": "string" },
1328 "rows_json": {
1329 "type": "string",
1330 "description": "JSON text of an array of objects."
1331 },
1332 "key_field": { "type": "string" },
1333 "auto_fk_suffix": { "type": "string" },
1334 "edges": {
1335 "type": "array",
1336 "description": "Optional user edges [{edge_type, src, dst}]."
1337 },
1338 "namespace": {
1339 "type": "string",
1340 "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."
1341 }
1342 },
1343 "required": ["label", "rows_json"]
1344 }
1345 },
1346 {
1347 "name": "create_rule",
1348 "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.",
1349 "inputSchema": {
1350 "type": "object",
1351 "properties": {
1352 "name": { "type": "string" },
1353 "src_label": { "type": "string" },
1354 "dst_label": { "type": "string" },
1355 "predicate": { "type": "object" },
1356 "edge_type": { "type": "string" },
1357 "weight_prop": {
1358 "type": ["string", "null"],
1359 "description": "Edge property that stores the score (default: weight)."
1360 },
1361 "max_edges": { "type": ["integer", "null"] },
1362 "approximate": {
1363 "type": "boolean",
1364 "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."
1365 },
1366 "namespace": {
1367 "type": "string",
1368 "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."
1369 }
1370 },
1371 "required": ["name", "src_label", "dst_label", "predicate", "edge_type"]
1372 }
1373 },
1374 {
1375 "name": "explain",
1376 "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.",
1377 "inputSchema": {
1378 "type": "object",
1379 "properties": {
1380 "a": { "type": "string", "minLength": 1 },
1381 "b": { "type": "string", "minLength": 1 }
1382 },
1383 "required": ["a", "b"]
1384 }
1385 },
1386 {
1387 "name": "stats",
1388 "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.",
1389 "inputSchema": {
1390 "type": "object",
1391 "properties": {
1392 "role": {
1393 "type": "string",
1394 "description": "Report only the namespaces this role may see. The store-wide counts beside them are unchanged."
1395 },
1396 "namespace": {
1397 "type": "string",
1398 "description": "Report only this namespace. Intersects with 'role'."
1399 }
1400 }
1401 }
1402 },
1403 {
1404 "name": "node_info",
1405 "description": "What is K — its label and every property it holds.",
1406 "inputSchema": {
1407 "type": "object",
1408 "properties": {
1409 "key": { "type": "string" }
1410 },
1411 "required": ["key"]
1412 }
1413 },
1414 {
1415 "name": "upsert_entity",
1416 "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.",
1417 "inputSchema": {
1418 "type": "object",
1419 "properties": {
1420 "key": { "type": "string", "description": "Unique node key." },
1421 "label": { "type": "string", "description": "Node label (required when creating a new entity)." },
1422 "props": {
1423 "type": "object",
1424 "description": "Properties to set. Values must be scalars (string, number, bool) or arrays of scalars."
1425 },
1426 "namespace": {
1427 "type": "string",
1428 "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."
1429 },
1430 "aliases": {
1431 "type": "array",
1432 "items": { "type": "string" },
1433 "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."
1434 }
1435 },
1436 "required": ["key", "props"]
1437 }
1438 },
1439 {
1440 "name": "find_similar",
1441 "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.",
1442 "inputSchema": {
1443 "type": "object",
1444 "properties": {
1445 "vector": {
1446 "type": "array",
1447 "items": { "type": "number" },
1448 "description": "Query embedding vector for vector-similarity search. When present, vector-search mode is used and `key` is ignored."
1449 },
1450 "field": { "type": "string", "description": "Property field holding the embedding vectors (default: embedding). Used in vector-search mode." },
1451 "label": { "type": "string", "description": "Restrict search to nodes with this label. Empty string means all labels. Used in vector-search mode." },
1452 "k": { "type": "integer", "description": "Maximum results to return in vector-search mode (default: 10)." },
1453 "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." },
1454 "mask": {
1455 "type": "array",
1456 "items": { "type": "string" },
1457 "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."
1458 },
1459 "where": {
1460 "type": "object",
1461 "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."
1462 },
1463 "exact": {
1464 "type": "boolean",
1465 "description": "When true, vector-search mode uses exact GEMM brute force and does not consult HNSW. Default false. Edge-traversal mode ignores this."
1466 },
1467 "key": { "type": "string", "description": "Source node key for edge-traversal mode." },
1468 "edge_type": { "type": "string", "description": "Edge type to filter by in edge-traversal mode (default: SIMILAR)." },
1469 "limit": { "type": "integer", "description": "Maximum neighbors to return in edge-traversal mode (default: 10)." }
1470 }
1471 }
1472 },
1473 {
1474 "name": "pairwise_similar",
1475 "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.",
1476 "inputSchema": {
1477 "type": "object",
1478 "properties": {
1479 "keys": {
1480 "type": "array",
1481 "items": { "type": "string" },
1482 "description": "Node keys to score against each other."
1483 },
1484 "field": { "type": "string", "description": "Property field holding the embedding vectors." },
1485 "k": { "type": "integer", "description": "Maximum neighbors per key (default: 10)." },
1486 "min": { "type": "number", "description": "Minimum cosine similarity threshold (default: 0.0)." }
1487 },
1488 "required": ["keys", "field"]
1489 }
1490 },
1491 {
1492 "name": "hybrid_search",
1493 "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.",
1494 "inputSchema": {
1495 "type": "object",
1496 "properties": {
1497 "query_text": { "type": "string", "description": "Fulltext query string." },
1498 "text_field": { "type": "string", "description": "Property field to search with fulltext." },
1499 "vector": {
1500 "type": "array",
1501 "items": { "type": "number" },
1502 "description": "Query embedding vector. Omit for text-only ranking."
1503 },
1504 "vector_field": { "type": "string", "description": "Property field holding embedding vectors (default: embedding)." },
1505 "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." },
1506 "k": { "type": "integer", "description": "Maximum results to return (default: 10)." }
1507 },
1508 "required": ["query_text", "text_field"]
1509 }
1510 },
1511 {
1512 "name": "node_history",
1513 "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.",
1514 "inputSchema": {
1515 "type": "object",
1516 "properties": {
1517 "key": { "type": "string", "description": "Node key to look up." }
1518 },
1519 "required": ["key"]
1520 }
1521 },
1522 {
1523 "name": "edge_history",
1524 "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.",
1525 "inputSchema": {
1526 "type": "object",
1527 "properties": {
1528 "a": { "type": "string", "minLength": 1, "description": "First node key." },
1529 "b": { "type": "string", "minLength": 1, "description": "Second node key." }
1530 },
1531 "required": ["a", "b"]
1532 }
1533 },
1534 {
1535 "name": "was_linked",
1536 "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`).",
1537 "inputSchema": {
1538 "type": "object",
1539 "properties": {
1540 "a": { "type": "string", "minLength": 1, "description": "First node key." },
1541 "b": { "type": "string", "minLength": 1, "description": "Second node key." },
1542 "edge_type": { "type": "string", "minLength": 1, "description": "Edge type to check." },
1543 "at_commit": {
1544 "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.",
1545 "anyOf": [
1546 { "type": "string", "minLength": 1 },
1547 { "type": "integer", "minimum": 0 }
1548 ]
1549 }
1550 },
1551 "required": ["a", "b", "edge_type", "at_commit"]
1552 }
1553 },
1554 {
1555 "name": "rename_node",
1556 "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.",
1557 "inputSchema": {
1558 "type": "object",
1559 "properties": {
1560 "old_key": { "type": "string", "minLength": 1, "description": "Current node key." },
1561 "new_key": { "type": "string", "minLength": 1, "description": "Desired new node key." }
1562 },
1563 "required": ["old_key", "new_key"]
1564 }
1565 }
1566 ]) else {
1567 unreachable!("the literal above is an array")
1568 };
1569 tools
1570}
1571
1572fn tool_ok(payload: Js) -> Js {
1573 json!({
1574 "content": [{ "type": "text", "text": payload.to_string() }]
1575 })
1576}
1577
1578fn task_ok(text: &str) -> Js {
1586 json!({
1587 "content": [{ "type": "text", "text": text }]
1588 })
1589}
1590
1591fn tool_err(message: &str) -> Js {
1592 json!({
1593 "content": [{ "type": "text", "text": message }],
1594 "isError": true
1595 })
1596}
1597
1598fn write_result(writer: &mut impl Write, id: Option<Js>, result: Js) -> io::Result<()> {
1599 write_json(
1600 writer,
1601 &json!({
1602 "jsonrpc": "2.0",
1603 "id": id.unwrap_or(Js::Null),
1604 "result": result
1605 }),
1606 )
1607}
1608
1609fn write_error(
1610 writer: &mut impl Write,
1611 id: Option<Js>,
1612 code: i64,
1613 message: &str,
1614) -> io::Result<()> {
1615 write_json(
1616 writer,
1617 &json!({
1618 "jsonrpc": "2.0",
1619 "id": id.unwrap_or(Js::Null),
1620 "error": { "code": code, "message": message }
1621 }),
1622 )
1623}
1624
1625fn write_json(writer: &mut impl Write, value: &Js) -> io::Result<()> {
1626 let s = serde_json::to_string(value).map_err(io::Error::other)?;
1627 writeln!(writer, "{s}")?;
1628 writer.flush()
1629}
1630
1631#[cfg(test)]
1636mod tests {
1637 use super::*;
1638 use mushroomdb::{AutoFk, IngestOptions, Predicate, RuleDef, Value};
1639 use std::path::PathBuf;
1640 use std::sync::atomic::{AtomicU64, Ordering};
1641
1642 fn tmp_dir() -> PathBuf {
1643 static SEQ: AtomicU64 = AtomicU64::new(0);
1644 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1645 let d = std::env::temp_dir().join(format!("mcp-test-{}-{}", std::process::id(), n));
1646 let _ = std::fs::remove_dir_all(&d);
1650 d
1651 }
1652
1653 fn demo_db() -> SharedDb {
1655 let db = SharedDb::open(&tmp_dir()).expect("open");
1656 {
1657 let mut g = db.write();
1658 let opts = IngestOptions {
1659 key_field: "id".into(),
1660 auto_fk: AutoFk::Off,
1661 };
1662 let people: Vec<BTreeMap<String, Value>> = vec![
1664 [
1665 ("id", Value::Str("alice".into())),
1666 ("name", Value::Str("Alice".into())),
1667 (
1668 "emb",
1669 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1670 ),
1671 ]
1672 .into_iter()
1673 .map(|(k, v)| (k.to_string(), v))
1674 .collect(),
1675 [
1676 ("id", Value::Str("bob".into())),
1677 ("name", Value::Str("Bob".into())),
1678 (
1679 "emb",
1680 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1681 ),
1682 ]
1683 .into_iter()
1684 .map(|(k, v)| (k.to_string(), v))
1685 .collect(),
1686 ];
1687 g.ingest("Person", people, &opts).expect("ingest");
1688
1689 g.create_rule(RuleDef {
1691 name: "sim_emb".into(),
1692 src_label: "Person".into(),
1693 dst_label: "Person".into(),
1694 predicate: Predicate::VectorSimilar {
1695 field: "emb".into(),
1696 min: 0.9,
1697 },
1698 edge_type: "SIMILAR".into(),
1699 weight_prop: Some("score".into()),
1700 max_edges: None,
1701 approximate: false,
1702 via_label: None,
1703 via_edge: None,
1704 via_dir: None,
1705 namespace: None,
1706 })
1707 .expect("rule");
1708 }
1709 db
1710 }
1711
1712 fn roundtrip(db: &SharedDb, request: &str) -> Js {
1713 roundtrip_with(db, false, request)
1714 }
1715
1716 fn roundtrip_with(db: &SharedDb, all_tools: bool, request: &str) -> Js {
1717 let input = format!("{request}\n");
1718 let mut output = Vec::new();
1719 run_mcp_stdio_with(db.clone(), None, all_tools, input.as_bytes(), &mut output)
1720 .expect("mcp");
1721 let s = std::str::from_utf8(&output).expect("utf8");
1722 serde_json::from_str(s.trim()).expect("json response")
1723 }
1724
1725 fn tool_call(db: &SharedDb, id: u64, tool: &str, args: Js) -> Js {
1726 let req = json!({
1727 "jsonrpc": "2.0",
1728 "id": id,
1729 "method": "tools/call",
1730 "params": { "name": tool, "arguments": args }
1731 });
1732 roundtrip(db, &req.to_string())
1733 }
1734
1735 fn tool_text(resp: &Js) -> Js {
1737 let text = resp["result"]["content"][0]["text"]
1738 .as_str()
1739 .expect("content[0].text");
1740 serde_json::from_str(text).expect("tool text is json")
1741 }
1742
1743 fn is_error(resp: &Js) -> bool {
1744 resp["result"]["isError"].as_bool().unwrap_or(false)
1745 }
1746
1747 fn tool_err_text(resp: &Js) -> String {
1748 resp["result"]["content"][0]["text"]
1749 .as_str()
1750 .unwrap_or("")
1751 .to_string()
1752 }
1753
1754 #[test]
1757 fn test_tools_list_includes_all_expected() {
1758 let db = demo_db();
1759 let resp = roundtrip_with(
1760 &db,
1761 true,
1762 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
1763 );
1764 let tools = resp["result"]["tools"].as_array().expect("tools array");
1765 let names: Vec<&str> = tools
1766 .iter()
1767 .map(|t| t["name"].as_str().expect("name"))
1768 .collect();
1769 for expected in &[
1770 "explain_association",
1772 "node_edges",
1773 "neighborhood",
1774 "edges_at",
1775 "what_if",
1776 "recall",
1777 "remember",
1778 "schema",
1779 "analyze",
1780 "suggest_rules",
1781 "forget",
1782 "query",
1784 "ingest_json",
1785 "create_rule",
1786 "explain",
1787 "stats",
1788 "node_info",
1789 "upsert_entity",
1790 "find_similar",
1791 "pairwise_similar",
1792 "hybrid_search",
1793 "node_history",
1794 "edge_history",
1795 "was_linked",
1796 "rename_node",
1797 ] {
1798 assert!(names.contains(expected), "missing tool: {expected}");
1799 }
1800 assert_eq!(
1801 names.len(),
1802 25,
1803 "expected exactly 25 tools, got {}",
1804 names.len()
1805 );
1806 assert_eq!(
1807 &names[..11],
1808 [
1809 "explain_association",
1810 "node_edges",
1811 "neighborhood",
1812 "edges_at",
1813 "what_if",
1814 "recall",
1815 "remember",
1816 "schema",
1817 "analyze",
1818 "suggest_rules",
1819 "forget",
1820 ],
1821 "the task tools come first, in order"
1822 );
1823 assert_eq!(names[11], "query", "the graph tools follow them");
1824 }
1825
1826 #[test]
1829 fn tools_list_defaults_to_the_association_tools_on_a_memory_store() {
1830 let db = demo_db();
1831 let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1832 let names: Vec<&str> = resp["result"]["tools"]
1833 .as_array()
1834 .expect("tools array")
1835 .iter()
1836 .map(|t| t["name"].as_str().expect("name"))
1837 .collect();
1838 assert_eq!(names, ASSOCIATION_TOOLS.to_vec());
1839 }
1840
1841 #[test]
1845 fn create_rule_advertises_every_argument_a_suggestion_carries() {
1846 let def = RuleDef {
1847 name: "sim".into(),
1848 src_label: "Person".into(),
1849 dst_label: "Person".into(),
1850 predicate: Predicate::VectorSimilar {
1851 field: "emb".into(),
1852 min: 0.9,
1853 },
1854 edge_type: "SIMILAR".into(),
1855 weight_prop: Some("score".into()),
1856 max_edges: Some(8),
1857 approximate: true,
1858 via_label: None,
1859 via_edge: None,
1860 via_dir: None,
1861 namespace: None,
1862 };
1863 let args = mushroomdb::memory::suggest::create_rule_args(&def);
1864 assert_eq!(args["approximate"], json!(true), "{args}");
1865
1866 let tools = graph_tools();
1867 let schema = tools
1868 .iter()
1869 .find(|t| t["name"] == json!("create_rule"))
1870 .expect("create_rule is a graph tool");
1871 let advertised = schema["inputSchema"]["properties"]
1872 .as_object()
1873 .expect("properties");
1874 for arg in args.as_object().expect("an object").keys() {
1875 assert!(
1876 advertised.contains_key(arg),
1877 "create_rule's schema does not list {arg:?}, which a suggestion carries"
1878 );
1879 }
1880 assert_eq!(advertised["approximate"]["type"], json!("boolean"));
1881 }
1882
1883 #[test]
1886 fn association_listing_includes_pairwise_similar_after_find_similar() {
1887 let db = demo_db();
1888 let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1889 let names: Vec<&str> = resp["result"]["tools"]
1890 .as_array()
1891 .expect("tools array")
1892 .iter()
1893 .map(|t| t["name"].as_str().expect("name"))
1894 .collect();
1895 const EXPECTED: [&str; 23] = [
1896 "query",
1897 "explain_association",
1898 "neighborhood",
1899 "node_info",
1900 "node_edges",
1901 "was_linked",
1902 "edges_at",
1903 "what_if",
1904 "node_history",
1905 "edge_history",
1906 "find_similar",
1907 "pairwise_similar",
1908 "hybrid_search",
1909 "remember",
1910 "recall",
1911 "upsert_entity",
1912 "ingest_json",
1913 "create_rule",
1914 "stats",
1915 "schema",
1916 "analyze",
1917 "suggest_rules",
1918 "forget",
1919 ];
1920 assert_eq!(names, EXPECTED.to_vec());
1921 assert_eq!(ASSOCIATION_TOOLS.as_slice(), EXPECTED.as_slice());
1922 let find = names
1923 .iter()
1924 .position(|&n| n == "find_similar")
1925 .expect("find_similar listed");
1926 assert_eq!(names[find + 1], "pairwise_similar");
1927 }
1928
1929 #[test]
1933 fn the_association_surface_is_entity_tools_only() {
1934 for kept in ["remember", "recall", "explain_association"] {
1935 assert!(
1936 ASSOCIATION_TOOLS.contains(&kept),
1937 "{kept} answers on an entity graph and must be listed"
1938 );
1939 }
1940 let served: Vec<String> = crate::mcp_tasks::task_tools()
1941 .iter()
1942 .chain(graph_tools().iter())
1943 .filter_map(|t| t.get("name").and_then(Js::as_str))
1944 .map(str::to_string)
1945 .collect();
1946 for name in ASSOCIATION_TOOLS {
1947 assert!(
1948 served.iter().any(|s| s == name),
1949 "{name} is listed but not served"
1950 );
1951 }
1952 }
1953
1954 #[test]
1955 fn test_stats_returns_node_count() {
1956 let db = demo_db();
1957 let resp = tool_call(&db, 1, "stats", json!({}));
1958 assert!(!is_error(&resp));
1959 let result = tool_text(&resp);
1960 assert_eq!(result["nodes_live"], 2);
1961 }
1962
1963 #[test]
1968 fn mcp_stats_unscoped_omits_namespace_roster() {
1969 let db = SharedDb::open(&tmp_dir()).expect("open");
1970 {
1971 let mut g = db.write();
1972 g.insert_node(
1973 "Doc",
1974 "a",
1975 vec![("ns".into(), Value::Str("tenant-a".into()))],
1976 )
1977 .expect("insert a");
1978 g.insert_node(
1979 "Doc",
1980 "b",
1981 vec![("ns".into(), Value::Str("tenant-b".into()))],
1982 )
1983 .expect("insert b");
1984 }
1985
1986 let unscoped = tool_text(&tool_call(&db, 1, "stats", json!({})));
1987 assert!(
1988 unscoped.get("namespaces").is_none(),
1989 "unscoped stats must omit the roster entirely, not send an empty \
1990 array: {unscoped}"
1991 );
1992 assert_eq!(
1993 unscoped["nodes_live"], 2,
1994 "the store-wide counts beside the roster are unchanged"
1995 );
1996
1997 let scoped = tool_text(&tool_call(
1998 &db,
1999 2,
2000 "stats",
2001 json!({"namespace": "tenant-a"}),
2002 ));
2003 let names: Vec<&str> = scoped["namespaces"]
2004 .as_array()
2005 .expect("a scoped call still carries the roster it may see")
2006 .iter()
2007 .map(|n| n["name"].as_str().expect("name"))
2008 .collect();
2009 assert_eq!(
2010 names,
2011 ["tenant-a"],
2012 "a call that names a namespace sees that one and no other"
2013 );
2014 }
2015
2016 #[test]
2020 fn create_rule_reports_a_build_it_could_not_finish() {
2021 let db = SharedDb::open(&tmp_dir()).expect("open");
2022 {
2023 let mut g = db.write();
2024 for i in 0..300usize {
2025 const D: usize = 32;
2026 let axis = (i / 10) % D;
2027 let mut xs = vec![0.0f64; D];
2028 xs[axis] = 1.0;
2029 xs[(axis + 1) % D] = (i % 10) as f64 * 0.001;
2030 g.insert_node(
2031 "V",
2032 &format!("v{i}"),
2033 vec![(
2034 "emb".into(),
2035 Value::List(xs.into_iter().map(Value::Float).collect()),
2036 )],
2037 )
2038 .expect("insert");
2039 }
2040 g.set_hnsw_build_batch(Some(64));
2041 }
2042 let args = json!({
2043 "name": "sim",
2044 "src_label": "V",
2045 "dst_label": "V",
2046 "predicate": {"VectorSimilar": {"field": "emb", "min": 0.9}},
2047 "edge_type": "SIM",
2048 "weight_prop": null,
2049 "max_edges": null,
2050 "approximate": true
2051 });
2052 let resp = tool_call(&db, 1, "create_rule", args);
2053 assert!(!is_error(&resp), "{resp}");
2054 let result = tool_text(&resp);
2055 assert_eq!(result["name"], json!("sim"));
2056 assert_eq!(result["building"], json!({"indexed": 64, "total": 300}));
2057 let note = result["note"].as_str().expect("a note explaining the wait");
2058 assert!(
2059 note.contains("derives no edges until it finishes") && note.contains("build-index"),
2060 "the note must say the edges are not there yet and how to finish: {note}"
2061 );
2062
2063 let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2065 let rule = stats["rules"]
2066 .as_array()
2067 .expect("rules")
2068 .iter()
2069 .find(|r| r["name"] == "sim")
2070 .expect("the rule is installed while it builds");
2071 assert_eq!(rule["edges"], json!(0));
2072 assert_eq!(
2073 rule["building"],
2074 json!({"rule": "sim", "indexed": 64, "total": 300})
2075 );
2076
2077 while !db.write().pump_index_build().expect("pump").is_empty() {}
2079 let stats = tool_text(&tool_call(&db, 3, "stats", json!({})));
2080 let rule = stats["rules"]
2081 .as_array()
2082 .expect("rules")
2083 .iter()
2084 .find(|r| r["name"] == "sim")
2085 .expect("rule");
2086 assert!(rule.get("building").is_none(), "{rule}");
2087 assert!(rule["edges"].as_u64().expect("edges") > 0);
2088 }
2089
2090 #[test]
2091 fn test_query_runs_cypher() {
2092 let db = demo_db();
2093 let resp = tool_call(
2094 &db,
2095 1,
2096 "query",
2097 json!({ "cypher": "MATCH (n:Person) RETURN n.name ORDER BY n.name" }),
2098 );
2099 assert!(!is_error(&resp));
2100 let result = tool_text(&resp);
2101 assert_eq!(result["columns"], json!(["n.name"]));
2103 assert_eq!(result["rows"].as_array().map(|r| r.len()), Some(2));
2104 }
2105
2106 #[test]
2107 fn test_query_create_is_a_write() {
2108 let db = SharedDb::open(&tmp_dir()).expect("open");
2109 let resp = tool_call(
2110 &db,
2111 1,
2112 "query",
2113 json!({ "cypher": "CREATE (n:L {id: 'k'}) RETURN n" }),
2114 );
2115 assert!(
2116 !is_error(&resp),
2117 "CREATE via MCP query must succeed: {resp}"
2118 );
2119 let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2120 assert_eq!(stats["nodes_live"], 1);
2121 }
2122
2123 #[test]
2124 fn test_ingest_json_inserts_nodes() {
2125 let db = demo_db();
2126 let resp = tool_call(
2127 &db,
2128 1,
2129 "ingest_json",
2130 json!({
2131 "label": "Person",
2132 "rows_json": r#"[{"id":"carol","name":"Carol"}]"#,
2133 "key_field": "id"
2134 }),
2135 );
2136 assert!(!is_error(&resp));
2137 let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2139 assert_eq!(stats["nodes_live"], 3);
2140 }
2141
2142 #[test]
2143 fn test_node_info_returns_props() {
2144 let db = demo_db();
2145 let resp = tool_call(&db, 1, "node_info", json!({ "key": "alice" }));
2146 assert!(!is_error(&resp));
2147 let result = tool_text(&resp);
2148 assert_eq!(result["key"], "alice");
2149 assert_eq!(result["label"], "Person");
2150 assert_eq!(result["props"]["name"], "Alice");
2151 }
2152
2153 #[test]
2156 fn test_node_edges_returns_edges() {
2157 let db = demo_db();
2158 let resp = tool_call(
2159 &db,
2160 1,
2161 "node_edges",
2162 json!({ "key": "alice", "json": true }),
2163 );
2164 assert!(!is_error(&resp));
2165 let result = tool_text(&resp);
2166 assert_eq!(result["key"], "alice");
2167 let types = result["types"].as_array().expect("types");
2168 assert!(
2169 !types.is_empty(),
2170 "alice should have at least one edge type"
2171 );
2172 let similar = types
2173 .iter()
2174 .find(|t| t["edge_type"] == "SIMILAR")
2175 .expect("the rule's edge type");
2176 assert_eq!(similar["count"], json!(2));
2179 let edges = similar["edges"].as_array().expect("edges");
2180 let dirs: Vec<&str> = edges
2181 .iter()
2182 .map(|e| e["direction"].as_str().expect("direction"))
2183 .collect();
2184 assert!(dirs.contains(&"out") && dirs.contains(&"in"), "{similar}");
2185 for edge in edges {
2186 assert_eq!(edge["other"], json!("bob"));
2187 assert_eq!(edge["derived"], json!(true));
2188 assert_eq!(edge["rule"], json!("sim_emb"));
2189 assert_eq!(edge["score"], json!(1.0));
2190 assert!(
2191 edge["predicate"]
2192 .as_str()
2193 .unwrap_or("")
2194 .contains("vector_similar"),
2195 "the predicate travels with the edge: {edge}"
2196 );
2197 }
2198 }
2199
2200 #[test]
2203 fn test_neighborhood_traverses_one_hop() {
2204 let db = demo_db();
2205 let resp = tool_call(
2206 &db,
2207 1,
2208 "neighborhood",
2209 json!({ "key": "alice", "depth": 1, "json": true }),
2210 );
2211 assert!(!is_error(&resp));
2212 let result = tool_text(&resp);
2213 assert_eq!(result["key"], "alice");
2214 assert!(result["types"].as_array().is_some(), "{result}");
2215
2216 let deep = tool_call(
2217 &db,
2218 2,
2219 "neighborhood",
2220 json!({ "key": "alice", "depth": 2 }),
2221 );
2222 assert!(!is_error(&deep));
2223 let table = tool_text(&deep);
2224 assert_eq!(table["columns"], json!(["key", "label", "depth"]));
2225 assert!(table["rows"].as_array().is_some());
2226 }
2227
2228 #[test]
2229 fn test_explain_returns_rule_info() {
2230 let db = demo_db();
2231 let resp = tool_call(&db, 1, "explain", json!({ "a": "alice", "b": "bob" }));
2232 assert!(!is_error(&resp));
2233 let result = tool_text(&resp);
2234 let arr = result.as_array().expect("explain returns array");
2235 assert!(!arr.is_empty(), "expected at least one explanation");
2236 assert_eq!(arr[0]["rule"], "sim_emb");
2237 }
2238
2239 #[test]
2240 fn test_create_rule_backfills() {
2241 let db = SharedDb::open(&tmp_dir()).expect("open");
2242 {
2243 let mut g = db.write();
2244 let opts = IngestOptions {
2245 key_field: "id".into(),
2246 auto_fk: AutoFk::Off,
2247 };
2248 let rows: Vec<BTreeMap<String, Value>> = vec![
2249 [
2250 ("id", Value::Str("x".into())),
2251 ("tag", Value::Str("a".into())),
2252 ]
2253 .into_iter()
2254 .map(|(k, v)| (k.to_string(), v))
2255 .collect(),
2256 [
2257 ("id", Value::Str("y".into())),
2258 ("tag", Value::Str("a".into())),
2259 ]
2260 .into_iter()
2261 .map(|(k, v)| (k.to_string(), v))
2262 .collect(),
2263 ];
2264 g.ingest("Item", rows, &opts).expect("ingest");
2265 }
2266 let resp = tool_call(
2267 &db,
2268 1,
2269 "create_rule",
2270 json!({
2271 "name": "same_tag",
2272 "src_label": "Item",
2273 "dst_label": "Item",
2274 "predicate": { "FieldEqual": { "field": "tag" } },
2275 "edge_type": "SAME_TAG"
2276 }),
2277 );
2278 assert!(!is_error(&resp));
2279 let result = tool_text(&resp);
2280 assert_eq!(result["ok"], true);
2281 let edges_resp = tool_call(&db, 2, "node_edges", json!({ "key": "x", "json": true }));
2283 let edges_result = tool_text(&edges_resp);
2284 let types = edges_result["types"].as_array().expect("types");
2285 assert!(
2286 types.iter().any(|t| t["edge_type"] == "SAME_TAG"),
2287 "SAME_TAG edge not found after create_rule"
2288 );
2289 }
2290
2291 #[test]
2294 fn test_upsert_entity_creates_new_node() {
2295 let db = demo_db();
2296 let resp = tool_call(
2297 &db,
2298 1,
2299 "upsert_entity",
2300 json!({
2301 "key": "carol",
2302 "label": "Person",
2303 "props": { "name": "Carol", "age": 30 }
2304 }),
2305 );
2306 assert!(!is_error(&resp));
2307 let result = tool_text(&resp);
2308 assert_eq!(result["ok"], true);
2309 assert_eq!(result["created"], true);
2310 assert_eq!(result["key"], "carol");
2311 let info = tool_text(&tool_call(&db, 2, "node_info", json!({ "key": "carol" })));
2313 assert_eq!(info["props"]["name"], "Carol");
2314 }
2315
2316 #[test]
2317 fn test_upsert_entity_updates_existing_node() {
2318 let db = demo_db();
2319 let resp = tool_call(
2320 &db,
2321 1,
2322 "upsert_entity",
2323 json!({
2324 "key": "alice",
2325 "props": { "name": "Alice Updated" }
2326 }),
2327 );
2328 assert!(!is_error(&resp));
2329 let result = tool_text(&resp);
2330 assert_eq!(result["ok"], true);
2331 assert_eq!(result["created"], false);
2332 assert_eq!(result["updated_fields"], 1);
2333 let info = tool_text(&tool_call(&db, 2, "node_info", json!({ "key": "alice" })));
2335 assert_eq!(info["props"]["name"], "Alice Updated");
2336 }
2337
2338 #[test]
2339 fn test_upsert_entity_missing_label_on_create_is_error() {
2340 let db = demo_db();
2341 let resp = tool_call(
2342 &db,
2343 1,
2344 "upsert_entity",
2345 json!({ "key": "new-node", "props": { "x": 1 } }),
2346 );
2347 assert!(is_error(&resp), "should error without label for new node");
2348 }
2349
2350 #[test]
2351 fn test_pairwise_similar_excludes_self() {
2352 let db = demo_db();
2353 let resp = tool_call(
2354 &db,
2355 1,
2356 "pairwise_similar",
2357 json!({
2358 "keys": ["alice", "bob"],
2359 "field": "emb",
2360 "k": 10,
2361 "min": 0.0
2362 }),
2363 );
2364 assert!(
2365 !is_error(&resp),
2366 "pairwise_similar must not error: {resp:?}"
2367 );
2368 let result = tool_text(&resp);
2369 let results = result["results"].as_array().expect("results");
2370 assert_eq!(results.len(), 2);
2371 for row in results {
2372 let key = row["key"].as_str().expect("key");
2373 let neighbors = row["neighbors"].as_array().expect("neighbors");
2374 assert!(
2375 neighbors.iter().all(|n| n["key"].as_str() != Some(key)),
2376 "self must be excluded: {row}"
2377 );
2378 assert!(!neighbors.is_empty(), "alice/bob are identical: {row}");
2379 }
2380 }
2381
2382 #[test]
2383 fn test_find_similar_returns_similar_edges() {
2384 let db = demo_db();
2385 let resp = tool_call(
2386 &db,
2387 1,
2388 "find_similar",
2389 json!({ "key": "alice", "edge_type": "SIMILAR" }),
2390 );
2391 assert!(!is_error(&resp));
2392 let result = tool_text(&resp);
2393 assert_eq!(result["key"], "alice");
2394 assert_eq!(result["edge_type"], "SIMILAR");
2395 let similar = result["similar"].as_array().expect("similar array");
2396 assert!(!similar.is_empty(), "expected SIMILAR neighbors for alice");
2397 assert_eq!(similar[0]["neighbor_key"], "bob");
2398 }
2399
2400 #[test]
2401 fn test_find_similar_limit_respected() {
2402 let db = demo_db();
2403 let resp = tool_call(
2404 &db,
2405 1,
2406 "find_similar",
2407 json!({ "key": "alice", "edge_type": "SIMILAR", "limit": 0 }),
2408 );
2409 assert!(!is_error(&resp));
2410 let result = tool_text(&resp);
2411 let similar = result["similar"].as_array().expect("similar array");
2412 assert_eq!(similar.len(), 0);
2413 }
2414
2415 #[test]
2419 fn test_find_similar_vector_default_min_is_0_8() {
2420 let db = SharedDb::open(&tmp_dir()).expect("open");
2421 {
2422 let mut g = db.write();
2423 g.insert_node(
2425 "Item",
2426 "close",
2427 vec![(
2428 "emb".into(),
2429 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2430 )],
2431 )
2432 .unwrap();
2433 g.insert_node(
2435 "Item",
2436 "far",
2437 vec![(
2438 "emb".into(),
2439 Value::List(vec![Value::Float(0.0), Value::Float(1.0)]),
2440 )],
2441 )
2442 .unwrap();
2443 g.insert_node(
2446 "Item",
2447 "mid",
2448 vec![(
2449 "emb".into(),
2450 Value::List(vec![Value::Float(0.6), Value::Float(0.8)]),
2451 )],
2452 )
2453 .unwrap();
2454 }
2455
2456 let resp = tool_call(
2458 &db,
2459 1,
2460 "find_similar",
2461 json!({
2462 "vector": [1.0, 0.0],
2463 "field": "emb",
2464 "label": "Item",
2465 "k": 10
2466 }),
2467 );
2468 assert!(!is_error(&resp), "vector search must not error");
2469 let result = tool_text(&resp);
2470 let results = result["results"].as_array().expect("results array");
2471
2472 let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2473 assert_eq!(
2474 keys,
2475 vec!["close"],
2476 "only the hit at or above the default survives: mid (0.6) and far (0.0) do not"
2477 );
2478 assert_eq!(
2479 result["min"],
2480 json!(mushroomdb::FIND_SIMILAR_DEFAULT_MIN),
2481 "the reply echoes the floor it applied"
2482 );
2483
2484 let resp = tool_call(
2487 &db,
2488 2,
2489 "find_similar",
2490 json!({
2491 "vector": [1.0, 0.0],
2492 "field": "emb",
2493 "label": "Item",
2494 "k": 10,
2495 "min": 0.0
2496 }),
2497 );
2498 assert!(!is_error(&resp), "vector search must not error");
2499 let result = tool_text(&resp);
2500 let results = result["results"].as_array().expect("results array");
2501 let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2502 assert_eq!(
2503 keys,
2504 vec!["close", "mid", "far"],
2505 "an explicit min of 0.0 keeps the hits the default drops"
2506 );
2507 assert_eq!(
2508 result["min"],
2509 json!(0.0),
2510 "the reply echoes the named floor"
2511 );
2512 }
2513
2514 #[test]
2516 fn test_find_similar_vector_mask_excludes_hidden() {
2517 let db = SharedDb::open(&tmp_dir()).expect("open");
2518 {
2519 let mut g = db.write();
2520 g.insert_node(
2522 "Item",
2523 "visible",
2524 vec![(
2525 "emb".into(),
2526 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2527 )],
2528 )
2529 .unwrap();
2530 g.insert_node(
2532 "Item",
2533 "hidden",
2534 vec![(
2535 "emb".into(),
2536 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2537 )],
2538 )
2539 .unwrap();
2540 }
2541
2542 let resp = tool_call(
2543 &db,
2544 1,
2545 "find_similar",
2546 json!({
2547 "vector": [1.0, 0.0],
2548 "field": "emb",
2549 "label": "Item",
2550 "k": 10,
2551 "min": 0.0,
2552 "mask": ["visible"]
2553 }),
2554 );
2555 assert!(!is_error(&resp), "masked vector search must not error");
2556 let result = tool_text(&resp);
2557 let results = result["results"].as_array().expect("results array");
2558
2559 let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2560 assert!(
2561 keys.contains(&"visible"),
2562 "visible node must appear in masked results"
2563 );
2564 assert!(
2565 !keys.contains(&"hidden"),
2566 "hidden node must be excluded by mask"
2567 );
2568 }
2569
2570 #[test]
2572 fn test_find_similar_vector_mask_bad_type_is_error() {
2573 let db = SharedDb::open(&tmp_dir()).expect("open");
2574 let resp = tool_call(
2575 &db,
2576 1,
2577 "find_similar",
2578 json!({
2579 "vector": [1.0, 0.0],
2580 "field": "emb",
2581 "k": 5,
2582 "mask": [42]
2583 }),
2584 );
2585 assert!(
2586 is_error(&resp),
2587 "non-string mask element must produce a tool error"
2588 );
2589 }
2590
2591 #[test]
2595 fn test_find_similar_non_numeric_min_k_or_limit_is_error() {
2596 let db = demo_db();
2597 for (args, want) in [
2598 (
2599 json!({ "vector": [1.0, 0.0], "field": "emb", "min": "0.5" }),
2600 "min must be a number",
2601 ),
2602 (
2603 json!({ "vector": [1.0, 0.0], "field": "emb", "k": "3" }),
2604 "k must be a non-negative integer",
2605 ),
2606 (
2607 json!({ "vector": [1.0, 0.0], "field": "emb", "k": -1 }),
2608 "k must be a non-negative integer",
2609 ),
2610 (
2611 json!({ "key": "alice", "limit": "1" }),
2612 "limit must be a non-negative integer",
2613 ),
2614 ] {
2615 let resp = tool_call(&db, 1, "find_similar", args.clone());
2616 assert!(is_error(&resp), "{args} must be a tool error, got {resp}");
2617 assert_eq!(tool_err_text(&resp), want, "for {args}");
2618 }
2619
2620 let resp = tool_call(
2622 &db,
2623 2,
2624 "find_similar",
2625 json!({ "vector": [1.0, 0.0], "field": "emb", "k": null, "min": null }),
2626 );
2627 assert!(!is_error(&resp), "null takes the default: {resp}");
2628 let result = tool_text(&resp);
2629 assert_eq!(result["k"], json!(10));
2630 assert_eq!(result["min"], json!(mushroomdb::FIND_SIMILAR_DEFAULT_MIN));
2631 let resp = tool_call(
2632 &db,
2633 3,
2634 "find_similar",
2635 json!({ "key": "alice", "limit": null }),
2636 );
2637 assert!(!is_error(&resp), "a null limit takes the default: {resp}");
2638 }
2639
2640 #[test]
2642 fn test_find_similar_vector_where_eq() {
2643 let db = SharedDb::open(&tmp_dir()).expect("open");
2644 {
2645 let mut g = db.write();
2646 g.insert_node(
2647 "Document",
2648 "in-scope",
2649 vec![
2650 (
2651 "emb".into(),
2652 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2653 ),
2654 ("resource_scope_id".into(), Value::Str("a".into())),
2655 ],
2656 )
2657 .unwrap();
2658 g.insert_node(
2659 "Document",
2660 "out-scope",
2661 vec![
2662 (
2663 "emb".into(),
2664 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2665 ),
2666 ("resource_scope_id".into(), Value::Str("b".into())),
2667 ],
2668 )
2669 .unwrap();
2670 }
2671 let resp = tool_call(
2672 &db,
2673 1,
2674 "find_similar",
2675 json!({
2676 "vector": [1.0, 0.0],
2677 "field": "emb",
2678 "label": "Document",
2679 "k": 10,
2680 "min": 0.0,
2681 "where": { "field": "resource_scope_id", "eq": "a" }
2682 }),
2683 );
2684 assert!(!is_error(&resp), "where eq must not error: {resp:?}");
2685 let result = tool_text(&resp);
2686 let keys: Vec<&str> = result["results"]
2687 .as_array()
2688 .expect("results")
2689 .iter()
2690 .filter_map(|r| r["key"].as_str())
2691 .collect();
2692 assert_eq!(keys, vec!["in-scope"]);
2693 }
2694
2695 #[test]
2696 fn test_find_similar_vector_where_invalid_is_error() {
2697 let db = SharedDb::open(&tmp_dir()).expect("open");
2698 let resp = tool_call(
2699 &db,
2700 1,
2701 "find_similar",
2702 json!({
2703 "vector": [1.0, 0.0],
2704 "field": "emb",
2705 "where": { "field": "resource_scope_id", "eq": "a", "in": ["b"] }
2706 }),
2707 );
2708 assert!(is_error(&resp), "invalid where must be a tool error");
2709 let msg = format!("{resp:?}");
2710 assert!(
2711 msg.contains("where"),
2712 "tool error must name where, got {msg}"
2713 );
2714 }
2715
2716 #[test]
2718 fn test_find_similar_edge_ignores_where_and_exact() {
2719 let db = demo_db();
2720 let resp = tool_call(
2721 &db,
2722 1,
2723 "find_similar",
2724 json!({
2725 "key": "alice",
2726 "edge_type": "SIMILAR",
2727 "where": { "field": "x", "eq": "y", "in": ["z"] },
2728 "exact": true
2729 }),
2730 );
2731 assert!(
2732 !is_error(&resp),
2733 "edge mode must ignore invalid where: {resp:?}"
2734 );
2735 }
2736
2737 #[test]
2739 fn test_find_similar_edge_mask_excludes_hidden_neighbor() {
2740 let db = SharedDb::open(&tmp_dir()).expect("open");
2741 {
2742 let mut g = db.write();
2743 g.insert_node("P", "alice", vec![]).unwrap();
2744 g.insert_node("P", "bob", vec![]).unwrap(); g.insert_node("P", "carol", vec![]).unwrap(); g.insert_edge("KNOWS", "alice", "bob").unwrap();
2747 g.insert_edge("KNOWS", "alice", "carol").unwrap();
2748 }
2749 let resp = tool_call(
2751 &db,
2752 1,
2753 "find_similar",
2754 json!({
2755 "key": "alice",
2756 "edge_type": "KNOWS",
2757 "mask": ["alice", "bob"]
2758 }),
2759 );
2760 assert!(!is_error(&resp), "masked edge search must not error");
2761 let result = tool_text(&resp);
2762 let similar = result["similar"].as_array().expect("similar array");
2763 let neighbors: Vec<&str> = similar
2764 .iter()
2765 .filter_map(|e| e["neighbor_key"].as_str())
2766 .collect();
2767 assert!(neighbors.contains(&"bob"), "bob (visible) must appear");
2768 assert!(
2769 !neighbors.contains(&"carol"),
2770 "carol (hidden) must be excluded"
2771 );
2772 }
2773
2774 #[test]
2777 fn test_find_similar_edge_mask_hidden_key_is_not_found() {
2778 let db = SharedDb::open(&tmp_dir()).expect("open");
2779 {
2780 let mut g = db.write();
2781 g.insert_node("P", "alice", vec![]).unwrap();
2782 g.insert_node("P", "bob", vec![]).unwrap();
2783 }
2784 let resp_masked = tool_call(
2786 &db,
2787 1,
2788 "find_similar",
2789 json!({ "key": "alice", "edge_type": "KNOWS", "mask": ["bob"] }),
2790 );
2791 let resp_ghost = tool_call(
2793 &db,
2794 2,
2795 "find_similar",
2796 json!({ "key": "ghost", "edge_type": "KNOWS" }),
2797 );
2798 assert!(
2799 is_error(&resp_masked),
2800 "hidden query key must produce a tool error"
2801 );
2802 assert!(
2803 is_error(&resp_ghost),
2804 "nonexistent key must produce a tool error"
2805 );
2806 assert_eq!(
2808 tool_err_text(&resp_masked).contains("alice"),
2809 tool_err_text(&resp_ghost).contains("ghost"),
2810 "error messages should follow same not-found template"
2811 );
2812 }
2813
2814 #[test]
2819 fn test_explain_association_same_as_explain() {
2820 let db = demo_db();
2821 let explain = tool_text(&tool_call(
2822 &db,
2823 1,
2824 "explain",
2825 json!({ "a": "alice", "b": "bob" }),
2826 ));
2827 let assoc = tool_text(&tool_call(
2828 &db,
2829 2,
2830 "explain_association",
2831 json!({ "a": "alice", "b": "bob", "json": true }),
2832 ));
2833 let explain: Vec<Js> = serde_json::from_value(explain).expect("explain array");
2834 let mut assoc: Vec<Js> = serde_json::from_value(assoc).expect("assoc array");
2835 for row in &mut assoc {
2836 let ev = row
2837 .as_object_mut()
2838 .expect("object")
2839 .remove("evidence")
2840 .expect("every derived edge carries its evidence");
2841 assert!(
2842 ev["similarity"].is_number(),
2843 "a vector_similar edge reports the cosine it scored: {ev}"
2844 );
2845 }
2846 assert_eq!(explain, assoc, "evidence is the only addition");
2847
2848 let prose = tool_call(
2849 &db,
2850 3,
2851 "explain_association",
2852 json!({ "a": "alice", "b": "bob" }),
2853 );
2854 let text = prose["result"]["content"][0]["text"]
2855 .as_str()
2856 .expect("text content");
2857 assert!(
2858 text.contains("mushroomdb explain — alice ↔ bob:"),
2859 "the default reply is the digest: {text}"
2860 );
2861 }
2862
2863 #[test]
2868 fn test_edge_history_returns_derived_lifecycle_with_rule() {
2869 let db = demo_db(); let resp = tool_call(&db, 1, "edge_history", json!({ "a": "alice", "b": "bob" }));
2871 assert!(!is_error(&resp), "edge_history must not error: {resp}");
2872 let result = tool_text(&resp);
2873
2874 let total = result["total_commits"].as_u64().expect("total_commits");
2876 assert!(total > 0, "total_commits must be > 0 after ingest + rule");
2877
2878 let events = result["events"].as_array().expect("events array");
2880 assert!(!events.is_empty(), "expected at least one edge event");
2881
2882 let derived_added = events
2884 .iter()
2885 .any(|ev| ev["event"].as_str() == Some("Added") && !ev["rule"].is_null());
2886 assert!(
2887 derived_added,
2888 "expected a derived Added event with rule attribution: {events:?}"
2889 );
2890 }
2891
2892 #[test]
2895 fn test_was_linked_at_valid_commit() {
2896 let db = SharedDb::open(&tmp_dir()).expect("open");
2897 {
2898 let mut g = db.write();
2899 let opts = IngestOptions {
2900 key_field: "id".into(),
2901 auto_fk: AutoFk::Off,
2902 };
2903 let rows: Vec<BTreeMap<String, Value>> = vec![
2904 [("id", Value::Str("x".into()))]
2905 .into_iter()
2906 .map(|(k, v)| (k.to_string(), v))
2907 .collect(),
2908 [("id", Value::Str("y".into()))]
2909 .into_iter()
2910 .map(|(k, v)| (k.to_string(), v))
2911 .collect(),
2912 ];
2913 g.ingest("N", rows, &opts).expect("ingest");
2914 g.insert_edge("LINK", "x", "y").expect("edge");
2915 }
2916 let g = db.read();
2918 let total = g.wal_total_commits().expect("wal_total_commits");
2919 drop(g);
2920
2921 let resp = tool_call(
2922 &db,
2923 1,
2924 "was_linked",
2925 json!({ "a": "x", "b": "y", "edge_type": "LINK", "at_commit": total - 1 }),
2926 );
2927 assert!(!is_error(&resp), "was_linked must not error: {resp}");
2928 let result = tool_text(&resp);
2929 assert_eq!(result["linked"], true);
2930 assert_eq!(result["a"], "x");
2931 assert_eq!(result["edge_type"], "LINK");
2932 }
2933
2934 #[test]
2937 fn test_was_linked_out_of_horizon_returns_tool_error() {
2938 let db = SharedDb::open(&tmp_dir()).expect("open");
2939 {
2940 let mut g = db.write();
2941 g.insert_node("N", "a", vec![]).expect("node a");
2942 g.insert_node("N", "b", vec![]).expect("node b");
2943 }
2944 let resp = tool_call(
2946 &db,
2947 1,
2948 "was_linked",
2949 json!({ "a": "a", "b": "b", "edge_type": "X", "at_commit": 999 }),
2950 );
2951 assert!(
2953 is_error(&resp),
2954 "out-of-range commit must be a tool error: {resp}"
2955 );
2956 let text = resp["result"]["content"][0]["text"].as_str().expect("text");
2957 assert!(
2958 text.contains("out of range") || text.contains("range"),
2959 "error must mention range: {text}"
2960 );
2961 }
2962
2963 #[test]
2966 fn test_node_history_via_mcp() {
2967 let db = demo_db(); let resp = tool_call(&db, 1, "node_history", json!({ "key": "alice" }));
2969 assert!(!is_error(&resp), "node_history must not error: {resp}");
2970 let result = tool_text(&resp);
2971
2972 assert_eq!(result["key"], "alice");
2973 let total = result["total_commits"].as_u64().expect("total_commits");
2974 assert!(total > 0, "total_commits must be > 0");
2975
2976 let history = result["history"].as_array().expect("history array");
2977 assert!(
2978 !history.is_empty(),
2979 "alice should have at least one history entry"
2980 );
2981
2982 let first_change = &history[0]["change"];
2984 assert_eq!(first_change["type"], "NodeInserted");
2985 assert_eq!(first_change["label"], "Person");
2986 }
2987}