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