1use crate::json::{
52 edge_history_result_json, namespace_arg, node_history_json, node_info_json, params_from_json,
53 parse_ingest_edges, result_set_json, rule_def_from_json, stamp_namespace, stamp_namespace_row,
54};
55use core_api::{
56 json_to_rows, json_to_value, AsOfScope, AutoFk, GraphError, IngestOptions, MaskMode, NodeMask,
57 PropPredicate, SharedDb, Value, NS_PROP,
58};
59use serde_json::{json, Value as Js};
60use std::collections::BTreeMap;
61use std::io::{self, BufRead, Write};
62use std::path::{Path, PathBuf};
63
64pub fn run_mcp_stdio(
71 db: SharedDb,
72 db_dir: Option<PathBuf>,
73 reader: impl BufRead,
74 writer: impl Write,
75) -> io::Result<()> {
76 run_mcp_stdio_with(db, db_dir, false, reader, writer)
77}
78
79pub fn run_mcp_stdio_with(
91 db: SharedDb,
92 db_dir: Option<PathBuf>,
93 all_tools: bool,
94 mut reader: impl BufRead,
95 mut writer: impl Write,
96) -> io::Result<()> {
97 let surface = surface_of(&db);
98 let mut buf = Vec::new();
99 loop {
100 buf.clear();
101 let n = reader.read_until(b'\n', &mut buf)?;
102 if n == 0 {
103 return Ok(());
104 }
105 match std::str::from_utf8(&buf) {
106 Ok(s) if s.trim().is_empty() => continue,
107 Ok(s) => handle_line(
108 &db,
109 db_dir.as_deref(),
110 all_tools,
111 surface,
112 s.trim(),
113 &mut writer,
114 )?,
115 Err(_) => write_error(&mut writer, None, -32700, "Parse error")?,
116 }
117 }
118}
119
120fn handle_line(
121 db: &SharedDb,
122 db_dir: Option<&Path>,
123 all_tools: bool,
124 surface: Surface,
125 line: &str,
126 writer: &mut impl Write,
127) -> io::Result<()> {
128 let msg: Js = match serde_json::from_str(line) {
129 Ok(v) => v,
130 Err(_) => return write_error(writer, None, -32700, "Parse error"),
131 };
132 let Some(obj) = msg.as_object() else {
133 return write_error(writer, None, -32600, "Invalid Request");
134 };
135 let is_request = obj.contains_key("id");
136 let id = obj.get("id").cloned();
137 let method = match obj.get("method").and_then(Js::as_str) {
138 Some(m) => m,
139 None => {
140 if is_request {
141 write_error(writer, id, -32600, "Invalid Request")?;
142 }
143 return Ok(());
144 }
145 };
146 match method {
147 "initialize" => {
148 if is_request {
149 write_result(writer, id, initialize_result())?;
150 }
151 }
152 "notifications/initialized" => {
153 if is_request {
154 write_result(writer, id, json!({}))?;
155 }
156 }
157 "tools/list" => {
158 if is_request {
159 write_result(writer, id, tools_list(all_tools, surface))?;
160 }
161 }
162 "tools/call" => {
163 if is_request {
164 match dispatch_call(db, db_dir, obj.get("params")) {
165 CallOutcome::Protocol { code, message } => {
166 write_error(writer, id, code, &message)?;
167 }
168 CallOutcome::ToolOk(payload) => {
169 write_result(writer, id, tool_ok(payload))?;
170 }
171 CallOutcome::TaskOk { text } => {
172 write_result(writer, id, task_ok(&text))?;
173 }
174 CallOutcome::ToolErr(message) => {
175 write_result(writer, id, tool_err(&message))?;
176 }
177 }
178 }
179 }
180 _ => {
181 if is_request {
182 write_error(writer, id, -32601, "Method not found")?;
183 }
184 }
185 }
186 Ok(())
187}
188
189pub(crate) enum CallOutcome {
190 Protocol {
191 code: i64,
192 message: String,
193 },
194 ToolOk(Js),
196 TaskOk {
199 text: String,
200 },
201 ToolErr(String),
202}
203
204fn dispatch_call(db: &SharedDb, db_dir: Option<&Path>, params: Option<&Js>) -> CallOutcome {
205 let Some(params) = params.and_then(Js::as_object) else {
206 return protocol_invalid();
207 };
208 let Some(name) = params.get("name").and_then(Js::as_str) else {
209 return protocol_invalid();
210 };
211 let empty = json!({});
212 let args = match params.get("arguments") {
213 None => &empty,
214 Some(a) if a.is_object() => a,
215 Some(_) => return protocol_invalid(),
216 };
217 if let Some(outcome) = crate::mcp_tasks::dispatch(db, db_dir, name, args) {
219 return outcome;
220 }
221 match name {
222 "query" => tool_query(db, args),
223 "ingest_json" => tool_ingest(db, args),
224 "create_rule" => tool_create_rule(db, args),
225 "explain" => tool_explain(db, args),
226 "stats" => tool_stats(db, args),
227 "node_info" => tool_node_info(db, args),
228 "upsert_entity" => tool_upsert_entity(db, args),
229 "find_similar" => tool_find_similar(db, args),
230 "pairwise_similar" => tool_pairwise_similar(db, args),
231 "hybrid_search" => tool_hybrid_search(db, args),
232 "node_history" => tool_node_history(db, args),
233 "edge_history" => tool_edge_history(db, args),
234 "was_linked" => tool_was_linked(db, args),
235 "rename_node" => tool_rename_node(db, args),
236 _ => protocol_invalid(),
237 }
238}
239
240fn protocol_invalid() -> CallOutcome {
241 CallOutcome::Protocol {
242 code: -32602,
243 message: "Invalid params".into(),
244 }
245}
246
247fn tool_query(db: &SharedDb, args: &Js) -> CallOutcome {
248 let Some(cypher) = args.get("cypher").and_then(Js::as_str) else {
249 return CallOutcome::ToolErr("missing cypher".into());
250 };
251 let params = match params_from_json(args.get("params")) {
252 Ok(p) => p,
253 Err(e) => return CallOutcome::ToolErr(e),
254 };
255
256 let role = match args.get("role") {
262 None | Some(Js::Null) => None,
263 Some(Js::String(s)) if !s.is_empty() => Some(s.as_str()),
264 Some(_) => return CallOutcome::ToolErr("role must be a non-empty string".into()),
265 };
266 let mask_keys = match args.get("mask") {
267 None => None,
268 Some(v) => match mask_key_list(v) {
269 Ok(keys) => Some(keys),
270 Err(e) => return CallOutcome::ToolErr(e),
271 },
272 };
273 if role.is_some() && mask_keys.is_some() {
274 return CallOutcome::ToolErr("pass role or mask, not both".into());
275 }
276
277 let namespace = match namespace_arg(args.get("namespace")) {
284 Ok(n) => n,
285 Err(e) => return CallOutcome::ToolErr(e),
286 };
287
288 let as_of = match args.get("as_of") {
292 None | Some(Js::Null) => None,
293 Some(v) => match v.as_u64() {
294 Some(n) => Some(n),
295 None => {
296 return CallOutcome::ToolErr(
297 "as_of must be a non-negative integer commit index".into(),
298 )
299 }
300 },
301 };
302
303 if let Some(commit) = as_of {
304 if args
307 .get("stub_hidden")
308 .and_then(|v| v.as_bool())
309 .unwrap_or(false)
310 {
311 return CallOutcome::ToolErr(
312 "as_of (time-travel) does not compose with stub_hidden".into(),
313 );
314 }
315 let scope = match (role, &mask_keys) {
316 (Some(role), _) => AsOfScope::Role(role),
317 (None, Some(keys)) => AsOfScope::Keys(keys),
318 (None, None) => match namespace.as_deref() {
319 Some(ns) => AsOfScope::Namespace(ns),
321 None => {
322 return match db.read().query_at(commit, cypher, ¶ms) {
323 Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
324 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
325 }
326 }
327 },
328 };
329 let g = db.read();
330 let out = match (namespace.as_deref(), role.is_some() || mask_keys.is_some()) {
331 (Some(ns), true) => g.query_at_scoped_in_namespace(commit, cypher, ¶ms, scope, ns),
333 _ => g.query_at_scoped(commit, cypher, ¶ms, scope),
334 };
335 return match out {
336 Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
337 Err(GraphError::KeyNotFound { key }) if key.starts_with("role:") => {
338 CallOutcome::ToolErr(format!("unknown role '{}'", &key["role:".len()..]))
339 }
340 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
341 };
342 }
343
344 if role.is_some() || mask_keys.is_some() || namespace.is_some() {
345 let stub_hidden = args
346 .get("stub_hidden")
347 .and_then(|v| v.as_bool())
348 .unwrap_or(false);
349 let g = db.read();
350 let mask = match (role, &mask_keys) {
351 (Some(role), _) => match g.mask_for_role(role) {
352 Ok(m) => m,
353 Err(GraphError::KeyNotFound { .. }) => {
356 return CallOutcome::ToolErr(format!("unknown role '{role}'"))
357 }
358 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
359 },
360 (None, Some(keys)) => NodeMask::from_keys(&*g, keys.iter().map(String::as_str)),
361 (None, None) => g.mask_for_namespace(
363 namespace
364 .as_deref()
365 .expect("one of the three is Some in this branch"),
366 ),
367 };
368 let mask = match (namespace.as_deref(), role.is_some() || mask_keys.is_some()) {
372 (Some(ns), true) => mask.intersect(&g.mask_for_namespace(ns)),
373 _ => mask,
374 };
375 let mask = if stub_hidden {
376 mask.with_mode(MaskMode::Stub)
377 } else {
378 mask
379 };
380 return match g.query_masked(cypher, ¶ms, &mask) {
381 Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
382 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
383 };
384 }
385
386 let is_write = match core_api::is_write_query(cypher) {
387 Ok(b) => b,
388 Err(e) => return CallOutcome::ToolErr(e),
389 };
390 let rs = if is_write {
391 let mut g = db.write();
392 g.query_write(cypher, ¶ms)
393 } else {
394 let g = db.read();
395 g.query(cypher, ¶ms)
396 };
397 match rs {
398 Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
399 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
400 }
401}
402
403fn mask_key_list(mask: &Js) -> Result<Vec<String>, String> {
406 let arr = mask
407 .as_array()
408 .ok_or_else(|| "mask must be an array of strings".to_string())?;
409 arr.iter()
410 .map(|v| {
411 v.as_str()
412 .map(str::to_string)
413 .ok_or_else(|| "mask must be an array of strings".to_string())
414 })
415 .collect()
416}
417
418fn tool_ingest(db: &SharedDb, args: &Js) -> CallOutcome {
419 let Some(label) = args.get("label").and_then(Js::as_str) else {
420 return CallOutcome::ToolErr("missing label".into());
421 };
422 let Some(rows_json) = args.get("rows_json").and_then(Js::as_str) else {
423 return CallOutcome::ToolErr("missing rows_json".into());
424 };
425 let mut opts = IngestOptions::default();
426 if let Some(kf) = args.get("key_field") {
427 match kf.as_str() {
428 Some(s) => opts.key_field = s.to_string(),
429 None => return CallOutcome::ToolErr("key_field must be a string".into()),
430 }
431 }
432 if let Some(suf) = args.get("auto_fk_suffix") {
433 match suf.as_str() {
434 Some(s) => {
435 opts.auto_fk = AutoFk::Auto {
436 suffix: s.to_string(),
437 }
438 }
439 None => return CallOutcome::ToolErr("auto_fk_suffix must be a string".into()),
440 }
441 }
442 let edges = match args.get("edges") {
443 None | Some(Js::Null) => Vec::new(),
444 Some(raw) => match parse_ingest_edges(raw) {
445 Ok(e) => e,
446 Err(e) => return CallOutcome::ToolErr(e),
447 },
448 };
449 let parsed: Js = match serde_json::from_str(rows_json) {
450 Ok(v) => v,
451 Err(e) => {
452 return CallOutcome::ToolErr(graph_err_msg(GraphError::IngestError {
453 detail: e.to_string(),
454 }))
455 }
456 };
457 let mut converted = match json_to_rows(&parsed) {
458 Ok(c) => c,
459 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
460 };
461 let namespace = match namespace_arg(args.get("namespace")) {
463 Ok(n) => n,
464 Err(e) => return CallOutcome::ToolErr(e),
465 };
466 if let Err(e) = stamp_namespace(&mut converted.rows, namespace.as_deref()) {
467 return CallOutcome::ToolErr(e);
468 }
469 let taken = std::mem::take(&mut converted.rows);
470 let report = {
471 let mut g = db.write();
472 g.ingest_with_edges(label, taken, &opts, &edges)
473 };
474 match report.map(|r| converted.into_report(r)) {
475 Ok(r) => match serde_json::to_value(&r) {
476 Ok(v) => CallOutcome::ToolOk(v),
477 Err(e) => CallOutcome::ToolErr(e.to_string()),
478 },
479 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
480 }
481}
482
483fn tool_create_rule(db: &SharedDb, args: &Js) -> CallOutcome {
484 let def = match rule_def_from_json(args.clone()) {
485 Ok(d) => d,
486 Err(e) => return CallOutcome::ToolErr(e),
487 };
488 let name = def.name.clone();
489 let res = {
490 let mut g = db.write();
491 g.create_rule(def)
492 };
493 if let Err(e) = res {
494 return CallOutcome::ToolErr(graph_err_msg(e));
495 }
496 let building = db
501 .read()
502 .builds_in_progress()
503 .into_iter()
504 .find(|b| b.rule == name);
505 match building {
506 Some(b) => CallOutcome::ToolOk(json!({
507 "ok": true,
508 "name": name,
509 "building": {"indexed": b.indexed, "total": b.total},
510 "note": format!(
511 "the vector index for {name:?} is still being built ({}/{} vectors); \
512 this rule derives no edges until it finishes. Every write advances it, \
513 and `mushroomdb build-index <db-dir>` finishes it now. Poll `stats` — \
514 the rule's `building` field disappears when its edges are in.",
515 b.indexed, b.total
516 ),
517 })),
518 None => CallOutcome::ToolOk(json!({"ok": true, "name": name})),
519 }
520}
521
522fn tool_explain(db: &SharedDb, args: &Js) -> CallOutcome {
523 let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
524 return CallOutcome::ToolErr("missing a".into());
525 };
526 let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
527 return CallOutcome::ToolErr("missing b".into());
528 };
529 let out = {
530 let g = db.read();
531 g.explain(a, b)
532 };
533 match out {
534 Ok(v) => match serde_json::to_value(&v) {
535 Ok(j) => CallOutcome::ToolOk(j),
536 Err(e) => CallOutcome::ToolErr(e.to_string()),
537 },
538 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
539 }
540}
541
542fn tool_stats(db: &SharedDb, args: &Js) -> CallOutcome {
552 let role = match args.get("role") {
553 None | Some(Js::Null) => None,
554 Some(Js::String(s)) if !s.is_empty() => Some(s.clone()),
555 Some(_) => return CallOutcome::ToolErr("role must be a non-empty string".into()),
556 };
557 let namespace = match namespace_arg(args.get("namespace")) {
558 Ok(n) => n,
559 Err(e) => return CallOutcome::ToolErr(e),
560 };
561 let (snap, role_def) = {
562 let g = db.read();
563 let def = match &role {
564 Some(r) => {
565 if let Err(e) = g.mask_for_role(r) {
572 return match e {
573 GraphError::KeyNotFound { .. } => {
574 CallOutcome::ToolErr(format!("unknown role '{r}'"))
575 }
576 other => CallOutcome::ToolErr(graph_err_msg(other)),
577 };
578 }
579 g.roles().into_iter().find(|d| &d.name == r)
580 }
581 None => None,
582 };
583 (g.stats(), def)
584 };
585 let mut snap = snap;
586 let scoped = role.is_some() || namespace.is_some();
591 if scoped {
592 snap.namespaces.retain(|n| {
593 role_def.as_ref().is_none_or(|d| d.sees_namespace(&n.name))
594 && namespace.as_deref().is_none_or(|ns| ns == n.name)
595 });
596 }
597 match serde_json::to_value(&snap) {
598 Ok(mut v) => {
599 if !scoped {
603 if let Some(obj) = v.as_object_mut() {
604 obj.remove("namespaces");
605 }
606 }
607 CallOutcome::ToolOk(v)
608 }
609 Err(e) => CallOutcome::ToolErr(e.to_string()),
610 }
611}
612
613fn tool_node_info(db: &SharedDb, args: &Js) -> CallOutcome {
614 let Some(key) = args.get("key").and_then(Js::as_str) else {
615 return CallOutcome::ToolErr("missing key".into());
616 };
617 let info = {
618 let g = db.read();
619 g.node_info(key)
620 };
621 match info {
622 Some(info) => CallOutcome::ToolOk(node_info_json(&info)),
623 None => CallOutcome::ToolErr(graph_err_msg(GraphError::KeyNotFound {
624 key: key.to_string(),
625 })),
626 }
627}
628
629fn tool_upsert_entity(db: &SharedDb, args: &Js) -> CallOutcome {
654 let Some(key) = args.get("key").and_then(Js::as_str) else {
655 return CallOutcome::ToolErr("missing key".into());
656 };
657 let label_opt = args.get("label").and_then(Js::as_str);
658 let Some(props_obj) = args.get("props").and_then(Js::as_object) else {
659 return CallOutcome::ToolErr("missing props".into());
660 };
661 let namespace = match namespace_arg(args.get("namespace")) {
662 Ok(n) => n,
663 Err(e) => return CallOutcome::ToolErr(e),
664 };
665
666 let mut row: BTreeMap<String, Value> = BTreeMap::new();
670 for (field, json_val) in props_obj {
671 if field == "id" {
672 continue;
673 }
674 match json_to_value(json_val.clone()) {
675 Some(v) => {
676 row.insert(field.clone(), v);
677 }
678 None => {
679 return CallOutcome::ToolErr(format!("prop {field} is not a supported value type"))
680 }
681 }
682 }
683 if let Some(ns) = namespace.as_deref() {
684 if let Err(e) = stamp_namespace_row(&mut row, ns) {
685 return CallOutcome::ToolErr(e);
686 }
687 }
688
689 let exists = {
690 let g = db.read();
691 g.has_node(key)
692 };
693
694 if exists {
695 let mut g = db.write();
696 let mut to_set: Vec<(String, Value)> = Vec::new();
697 for (field, v) in row {
698 if field == NS_PROP && Some(&v) == g.namespace_of(key).map(Value::Str).as_ref() {
704 continue;
705 }
706 to_set.push((field, v));
707 }
708 let count = to_set.len();
709 if let Err(e) = g.set_props(key, to_set) {
710 return CallOutcome::ToolErr(graph_err_msg(e));
711 }
712 CallOutcome::ToolOk(json!({
713 "ok": true,
714 "key": key,
715 "created": false,
716 "updated_fields": count
717 }))
718 } else {
719 let Some(label) = label_opt else {
720 return CallOutcome::ToolErr("label required when creating a new entity".into());
721 };
722 row.insert("id".to_string(), Value::Str(key.to_string()));
723 let opts = IngestOptions {
724 key_field: "id".to_string(),
725 auto_fk: AutoFk::Off,
726 };
727 let mut g = db.write();
728 match g.ingest(label, vec![row], &opts) {
729 Ok(_) => CallOutcome::ToolOk(json!({ "ok": true, "key": key, "created": true })),
730 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
731 }
732 }
733}
734
735fn tool_find_similar(db: &SharedDb, args: &Js) -> CallOutcome {
742 let mask_keys: Option<Vec<String>> = if let Some(mask_val) = args.get("mask") {
745 match mask_val.as_array() {
746 Some(arr) => {
747 let mut ks: Vec<String> = Vec::with_capacity(arr.len());
748 for v in arr {
749 match v.as_str() {
750 Some(s) => ks.push(s.to_string()),
751 None => {
752 return CallOutcome::ToolErr("mask must be an array of strings".into())
753 }
754 }
755 }
756 Some(ks)
757 }
758 None => return CallOutcome::ToolErr("mask must be an array of strings".into()),
759 }
760 } else {
761 None
762 };
763
764 if let Some(vec_js) = args.get("vector").and_then(Js::as_array) {
767 let q: Vec<f64> = vec_js.iter().filter_map(|v| v.as_f64()).collect();
768 if q.is_empty() {
769 return CallOutcome::ToolErr("vector must be a non-empty array of numbers".into());
770 }
771 let field = args
772 .get("field")
773 .and_then(Js::as_str)
774 .unwrap_or("embedding");
775 let label_str = args.get("label").and_then(Js::as_str).unwrap_or("");
776 let label = if label_str.is_empty() {
777 None
778 } else {
779 Some(label_str)
780 };
781 let k = args
782 .get("k")
783 .and_then(Js::as_u64)
784 .map(|n| n as usize)
785 .unwrap_or(10);
786 let min = args.get("min").and_then(Js::as_f64).unwrap_or(0.8);
787 let where_pred = match parse_where_arg(args) {
788 Ok(p) => p,
789 Err(e) => return CallOutcome::ToolErr(e),
790 };
791 let exact = match args.get("exact") {
792 None => false,
793 Some(v) => match v.as_bool() {
794 Some(b) => b,
795 None => return CallOutcome::ToolErr("exact must be a boolean".into()),
796 },
797 };
798 let exact = exact || where_pred.is_some();
799
800 let hits = {
801 let g = db.read();
802 let node_mask = mask_keys
803 .as_ref()
804 .map(|keys| NodeMask::from_keys(&*g, keys.iter().map(String::as_str)));
805 match g.find_similar_vector_filtered(
806 field,
807 label,
808 &q,
809 k,
810 min,
811 node_mask.as_ref(),
812 where_pred.as_ref(),
813 exact,
814 ) {
815 Ok(h) => h,
816 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
817 }
818 };
819 let results: Vec<Js> = hits
820 .into_iter()
821 .map(|(key, score)| json!({ "key": key, "score": score }))
822 .collect();
823 return CallOutcome::ToolOk(json!({
824 "mode": "vector",
825 "field": field,
826 "label": label,
827 "k": k,
828 "min": min,
829 "results": results
830 }));
831 }
832
833 let Some(key) = args.get("key").and_then(Js::as_str) else {
835 return CallOutcome::ToolErr("missing key (or provide vector for vector search)".into());
836 };
837 let edge_type = args
838 .get("edge_type")
839 .and_then(Js::as_str)
840 .unwrap_or("SIMILAR");
841 let limit = args
842 .get("limit")
843 .and_then(Js::as_u64)
844 .map(|n| n as usize)
845 .unwrap_or(10);
846
847 if let Some(ref mask) = mask_keys {
850 let mask_set: std::collections::HashSet<&str> = mask.iter().map(String::as_str).collect();
851 if !mask_set.contains(key) {
852 return CallOutcome::ToolErr(graph_err_msg(GraphError::KeyNotFound {
853 key: key.into(),
854 }));
855 }
856 let out = {
857 let g = db.read();
858 g.node_edges(key)
859 };
860 return match out {
861 Ok(edges) => {
862 let similar: Vec<Js> = edges
863 .iter()
864 .filter(|e| e.edge_type == edge_type)
865 .filter(|e| {
866 let neighbor_key = if e.src_key == key {
868 &e.dst_key
869 } else {
870 &e.src_key
871 };
872 mask_set.contains(neighbor_key.as_str())
873 })
874 .take(limit)
875 .map(|e| {
876 let neighbor_key = if e.src_key == key {
877 &e.dst_key
878 } else {
879 &e.src_key
880 };
881 let direction = if e.src_key == key { "out" } else { "in" };
882 json!({
883 "neighbor_key": neighbor_key,
884 "direction": direction,
885 "edge_type": e.edge_type,
886 "derived": e.derived,
887 })
888 })
889 .collect();
890 CallOutcome::ToolOk(json!({
891 "key": key,
892 "edge_type": edge_type,
893 "similar": similar
894 }))
895 }
896 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
897 };
898 }
899
900 let out = {
901 let g = db.read();
902 g.node_edges(key)
903 };
904 match out {
905 Ok(edges) => {
906 let similar: Vec<Js> = edges
907 .iter()
908 .filter(|e| e.edge_type == edge_type)
909 .take(limit)
910 .map(|e| {
911 let neighbor_key = if e.src_key == key {
912 &e.dst_key
913 } else {
914 &e.src_key
915 };
916 let direction = if e.src_key == key { "out" } else { "in" };
917 json!({
918 "neighbor_key": neighbor_key,
919 "direction": direction,
920 "edge_type": e.edge_type,
921 "derived": e.derived,
922 })
923 })
924 .collect();
925 CallOutcome::ToolOk(json!({
926 "key": key,
927 "edge_type": edge_type,
928 "similar": similar
929 }))
930 }
931 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
932 }
933}
934
935fn tool_pairwise_similar(db: &SharedDb, args: &Js) -> CallOutcome {
937 let Some(keys_js) = args.get("keys").and_then(Js::as_array) else {
938 return CallOutcome::ToolErr("missing required field: keys".into());
939 };
940 let mut keys: Vec<String> = Vec::with_capacity(keys_js.len());
941 for v in keys_js {
942 match v.as_str() {
943 Some(s) => keys.push(s.to_string()),
944 None => return CallOutcome::ToolErr("keys must be an array of strings".into()),
945 }
946 }
947 let Some(field) = args.get("field").and_then(Js::as_str) else {
948 return CallOutcome::ToolErr("missing required field: field".into());
949 };
950 let k = args
951 .get("k")
952 .and_then(Js::as_u64)
953 .map(|n| n as usize)
954 .unwrap_or(10);
955 let min = args.get("min").and_then(Js::as_f64).unwrap_or(0.0);
956 let refs: Vec<&str> = keys.iter().map(String::as_str).collect();
957 let out = {
958 let g = db.read();
959 g.pairwise_similar(&refs, field, k, min)
960 };
961 match out {
962 Ok(pairs) => {
963 let results: Vec<Js> = pairs
964 .into_iter()
965 .map(|(key, neighbors)| {
966 json!({
967 "key": key,
968 "neighbors": neighbors
969 .into_iter()
970 .map(|(n, score)| json!({ "key": n, "score": score }))
971 .collect::<Vec<_>>(),
972 })
973 })
974 .collect();
975 CallOutcome::ToolOk(json!({
976 "field": field,
977 "k": k,
978 "min": min,
979 "results": results
980 }))
981 }
982 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
983 }
984}
985
986fn tool_hybrid_search(db: &SharedDb, args: &Js) -> CallOutcome {
987 let Some(query_text) = args.get("query_text").and_then(Js::as_str) else {
988 return CallOutcome::ToolErr("missing required field: query_text".into());
989 };
990 let Some(text_field) = args.get("text_field").and_then(Js::as_str) else {
991 return CallOutcome::ToolErr("missing required field: text_field".into());
992 };
993
994 let vector_field = args
995 .get("vector_field")
996 .and_then(Js::as_str)
997 .unwrap_or("embedding");
998 let label = args.get("label").and_then(Js::as_str);
999 let k = args
1000 .get("k")
1001 .and_then(Js::as_u64)
1002 .map(|n| n as usize)
1003 .unwrap_or(10);
1004
1005 let query_vec: Vec<f64> = args
1006 .get("vector")
1007 .and_then(Js::as_array)
1008 .map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect())
1009 .unwrap_or_default();
1010
1011 let hits = {
1012 let g = db.read();
1013 g.search_hybrid(text_field, query_text, vector_field, &query_vec, label, k)
1014 };
1015
1016 let results: Vec<Js> = hits
1017 .into_iter()
1018 .map(|(key, score)| json!({ "key": key, "score": score }))
1019 .collect();
1020
1021 CallOutcome::ToolOk(json!({
1022 "query_text": query_text,
1023 "text_field": text_field,
1024 "vector_field": vector_field,
1025 "label": label,
1026 "k": k,
1027 "results": results
1028 }))
1029}
1030
1031fn tool_node_history(db: &SharedDb, args: &Js) -> CallOutcome {
1032 let Some(key) = args.get("key").and_then(Js::as_str) else {
1033 return CallOutcome::ToolErr("missing key".into());
1034 };
1035 let g = db.read();
1036 let result = match g.node_history(key) {
1037 Ok(e) => e,
1038 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
1039 };
1040 CallOutcome::ToolOk(node_history_json(key, &result))
1041}
1042
1043fn tool_edge_history(db: &SharedDb, args: &Js) -> CallOutcome {
1044 let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1045 return CallOutcome::ToolErr("missing a".into());
1046 };
1047 let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1048 return CallOutcome::ToolErr("missing b".into());
1049 };
1050 let result = {
1051 let g = db.read();
1052 g.edge_history(a, b)
1053 };
1054 match result {
1055 Ok(hr) => CallOutcome::ToolOk(edge_history_result_json(a, b, &hr)),
1056 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1057 }
1058}
1059
1060fn tool_was_linked(db: &SharedDb, args: &Js) -> CallOutcome {
1061 let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1062 return CallOutcome::ToolErr("missing a".into());
1063 };
1064 let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1065 return CallOutcome::ToolErr("missing b".into());
1066 };
1067 let Some(edge_type) = args
1068 .get("edge_type")
1069 .and_then(Js::as_str)
1070 .filter(|s| !s.is_empty())
1071 else {
1072 return CallOutcome::ToolErr("missing edge_type".into());
1073 };
1074 let at_commit = match args.get("at_commit") {
1078 Some(Js::String(date)) => match db.read().resolve_date(date) {
1079 Ok(c) => c,
1080 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
1081 },
1082 Some(v) => match v.as_u64() {
1083 Some(n) => n,
1084 None => {
1085 return CallOutcome::ToolErr(
1086 "at_commit must be a non-negative commit index or an RFC 3339 date".into(),
1087 )
1088 }
1089 },
1090 None => return CallOutcome::ToolErr("missing or invalid at_commit".into()),
1091 };
1092 let result = {
1093 let g = db.read();
1094 g.was_linked(a, b, edge_type, at_commit)
1095 };
1096 match result {
1097 Ok(linked) => CallOutcome::ToolOk(json!({
1098 "a": a,
1099 "b": b,
1100 "edge_type": edge_type,
1101 "at_commit": at_commit,
1102 "linked": linked,
1103 })),
1104 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1105 }
1106}
1107
1108fn tool_rename_node(db: &SharedDb, args: &Js) -> CallOutcome {
1109 let Some(old_key) = args.get("old_key").and_then(Js::as_str) else {
1110 return CallOutcome::ToolErr("missing old_key".into());
1111 };
1112 let Some(new_key) = args.get("new_key").and_then(Js::as_str) else {
1113 return CallOutcome::ToolErr("missing new_key".into());
1114 };
1115 let mut g = db.write();
1116 match g.rename_node(old_key, new_key) {
1117 Ok(()) => CallOutcome::ToolOk(json!({
1118 "ok": true,
1119 "old_key": old_key,
1120 "new_key": new_key,
1121 })),
1122 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1123 }
1124}
1125
1126fn parse_where_arg(args: &Js) -> std::result::Result<Option<PropPredicate>, String> {
1127 let Some(w) = args.get("where") else {
1128 return Ok(None);
1129 };
1130 if w.is_null() {
1131 return Ok(None);
1132 }
1133 let pred: PropPredicate =
1134 serde_json::from_value(w.clone()).map_err(|e| format!("where: {e}"))?;
1135 pred.validate_named("where")?;
1136 Ok(Some(pred))
1137}
1138
1139pub(crate) fn graph_err_msg(e: GraphError) -> String {
1140 match e {
1141 GraphError::QueryError { detail } | GraphError::IngestError { detail } => detail,
1142 other => other.to_string(),
1143 }
1144}
1145
1146fn initialize_result() -> Js {
1147 json!({
1148 "protocolVersion": "2024-11-05",
1149 "capabilities": { "tools": {} },
1150 "serverInfo": { "name": "mushroomdb", "version": env!("CARGO_PKG_VERSION") }
1151 })
1152}
1153
1154const ADVANCED_PREFIX: &str = "Advanced: ";
1160
1161pub const CODE_GRAPH_TOOLS: [&str; 3] = ["explore", "query", "stats"];
1169
1170pub const ASSOCIATION_TOOLS: [&str; 19] = [
1208 "query",
1209 "explain_association",
1210 "neighborhood",
1211 "node_info",
1212 "node_edges",
1213 "was_linked",
1214 "edges_at",
1215 "what_if",
1216 "node_history",
1217 "edge_history",
1218 "find_similar",
1219 "pairwise_similar",
1220 "hybrid_search",
1221 "remember",
1222 "recall",
1223 "upsert_entity",
1224 "ingest_json",
1225 "create_rule",
1226 "stats",
1227];
1228
1229#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1235pub(crate) enum Surface {
1236 CodeGraph,
1239 Memory,
1242}
1243
1244impl Surface {
1245 fn listing(self) -> &'static [&'static str] {
1248 match self {
1249 Surface::CodeGraph => &CODE_GRAPH_TOOLS,
1250 Surface::Memory => &ASSOCIATION_TOOLS,
1251 }
1252 }
1253}
1254
1255fn surface_of(db: &SharedDb) -> Surface {
1258 let ingested = {
1259 let g = db.read();
1260 g.has_node(crate::mcp_tasks::SYNC_KEY)
1261 };
1262 if ingested {
1263 Surface::CodeGraph
1264 } else {
1265 Surface::Memory
1266 }
1267}
1268
1269fn tools_list(all: bool, surface: Surface) -> Js {
1287 let mut served: Vec<Js> = crate::mcp_tasks::task_tools();
1288 for mut tool in graph_tools() {
1289 if let Some(d) = tool.get("description").and_then(Js::as_str) {
1290 let prefixed = format!("{ADVANCED_PREFIX}{d}");
1291 tool["description"] = Js::String(prefixed);
1292 }
1293 served.push(tool);
1294 }
1295 if all {
1296 return json!({ "tools": served });
1297 }
1298 let listing = surface.listing();
1299 let mut tools: Vec<Js> = Vec::with_capacity(listing.len());
1300 for name in listing {
1301 let Some(tool) = served
1302 .iter()
1303 .find(|t| t.get("name").and_then(Js::as_str) == Some(*name))
1304 else {
1305 debug_assert!(false, "{surface:?} lists {name}, which is not served");
1306 continue;
1307 };
1308 tools.push(tool.clone());
1309 }
1310 json!({ "tools": tools })
1311}
1312
1313fn graph_tools() -> Vec<Js> {
1316 let Js::Array(tools) = json!([
1317 {
1318 "name": "query",
1319 "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.",
1320 "inputSchema": {
1321 "type": "object",
1322 "properties": {
1323 "cypher": { "type": "string", "description": "Cypher query text." },
1324 "params": {
1325 "type": "object",
1326 "description": "Named JSON-scalar query parameters."
1327 },
1328 "mask": {
1329 "type": "array",
1330 "items": { "type": "string" },
1331 "description": "Optional node key allow-list. When present, only these nodes are visible; write statements are rejected."
1332 },
1333 "role": {
1334 "type": "string",
1335 "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."
1336 },
1337 "as_of": {
1338 "type": "integer",
1339 "minimum": 0,
1340 "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."
1341 },
1342 "namespace": {
1343 "type": "string",
1344 "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."
1345 }
1346 },
1347 "required": ["cypher"]
1348 }
1349 },
1350 {
1351 "name": "ingest_json",
1352 "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'.",
1353 "inputSchema": {
1354 "type": "object",
1355 "properties": {
1356 "label": { "type": "string" },
1357 "rows_json": {
1358 "type": "string",
1359 "description": "JSON text of an array of objects."
1360 },
1361 "key_field": { "type": "string" },
1362 "auto_fk_suffix": { "type": "string" },
1363 "edges": {
1364 "type": "array",
1365 "description": "Optional user edges [{edge_type, src, dst}]."
1366 },
1367 "namespace": {
1368 "type": "string",
1369 "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."
1370 }
1371 },
1372 "required": ["label", "rows_json"]
1373 }
1374 },
1375 {
1376 "name": "create_rule",
1377 "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.",
1378 "inputSchema": {
1379 "type": "object",
1380 "properties": {
1381 "name": { "type": "string" },
1382 "src_label": { "type": "string" },
1383 "dst_label": { "type": "string" },
1384 "predicate": { "type": "object" },
1385 "edge_type": { "type": "string" },
1386 "weight_prop": {
1387 "type": ["string", "null"],
1388 "description": "Edge property that stores the score (default: weight)."
1389 },
1390 "max_edges": { "type": ["integer", "null"] },
1391 "namespace": {
1392 "type": "string",
1393 "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."
1394 }
1395 },
1396 "required": ["name", "src_label", "dst_label", "predicate", "edge_type"]
1397 }
1398 },
1399 {
1400 "name": "explain",
1401 "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.",
1402 "inputSchema": {
1403 "type": "object",
1404 "properties": {
1405 "a": { "type": "string", "minLength": 1 },
1406 "b": { "type": "string", "minLength": 1 }
1407 },
1408 "required": ["a", "b"]
1409 }
1410 },
1411 {
1412 "name": "stats",
1413 "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.",
1414 "inputSchema": {
1415 "type": "object",
1416 "properties": {
1417 "role": {
1418 "type": "string",
1419 "description": "Report only the namespaces this role may see. The store-wide counts beside them are unchanged."
1420 },
1421 "namespace": {
1422 "type": "string",
1423 "description": "Report only this namespace. Intersects with 'role'."
1424 }
1425 }
1426 }
1427 },
1428 {
1429 "name": "node_info",
1430 "description": "What is K — its label and every property it holds.",
1431 "inputSchema": {
1432 "type": "object",
1433 "properties": {
1434 "key": { "type": "string" }
1435 },
1436 "required": ["key"]
1437 }
1438 },
1439 {
1440 "name": "upsert_entity",
1441 "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.",
1442 "inputSchema": {
1443 "type": "object",
1444 "properties": {
1445 "key": { "type": "string", "description": "Unique node key." },
1446 "label": { "type": "string", "description": "Node label (required when creating a new entity)." },
1447 "props": {
1448 "type": "object",
1449 "description": "Properties to set. Values must be scalars (string, number, bool) or arrays of scalars."
1450 },
1451 "namespace": {
1452 "type": "string",
1453 "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."
1454 }
1455 },
1456 "required": ["key", "props"]
1457 }
1458 },
1459 {
1460 "name": "find_similar",
1461 "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.",
1462 "inputSchema": {
1463 "type": "object",
1464 "properties": {
1465 "vector": {
1466 "type": "array",
1467 "items": { "type": "number" },
1468 "description": "Query embedding vector for vector-similarity search. When present, vector-search mode is used and `key` is ignored."
1469 },
1470 "field": { "type": "string", "description": "Property field holding the embedding vectors (default: embedding). Used in vector-search mode." },
1471 "label": { "type": "string", "description": "Restrict search to nodes with this label. Empty string means all labels. Used in vector-search mode." },
1472 "k": { "type": "integer", "description": "Maximum results to return in vector-search mode (default: 10)." },
1473 "min": { "type": "number", "description": "Minimum cosine similarity threshold in vector-search mode (default: 0.8). The Python binding's find_similar defaults this to 0.0 instead — same operation, same name, different default, so name it explicitly when a call has to agree across both surfaces." },
1474 "mask": {
1475 "type": "array",
1476 "items": { "type": "string" },
1477 "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."
1478 },
1479 "where": {
1480 "type": "object",
1481 "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."
1482 },
1483 "exact": {
1484 "type": "boolean",
1485 "description": "When true, vector-search mode uses exact GEMM brute force and does not consult HNSW. Default false. Edge-traversal mode ignores this."
1486 },
1487 "key": { "type": "string", "description": "Source node key for edge-traversal mode." },
1488 "edge_type": { "type": "string", "description": "Edge type to filter by in edge-traversal mode (default: SIMILAR)." },
1489 "limit": { "type": "integer", "description": "Maximum neighbors to return in edge-traversal mode (default: 10)." }
1490 }
1491 }
1492 },
1493 {
1494 "name": "pairwise_similar",
1495 "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.",
1496 "inputSchema": {
1497 "type": "object",
1498 "properties": {
1499 "keys": {
1500 "type": "array",
1501 "items": { "type": "string" },
1502 "description": "Node keys to score against each other."
1503 },
1504 "field": { "type": "string", "description": "Property field holding the embedding vectors." },
1505 "k": { "type": "integer", "description": "Maximum neighbors per key (default: 10)." },
1506 "min": { "type": "number", "description": "Minimum cosine similarity threshold (default: 0.0)." }
1507 },
1508 "required": ["keys", "field"]
1509 }
1510 },
1511 {
1512 "name": "hybrid_search",
1513 "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.",
1514 "inputSchema": {
1515 "type": "object",
1516 "properties": {
1517 "query_text": { "type": "string", "description": "Fulltext query string." },
1518 "text_field": { "type": "string", "description": "Property field to search with fulltext." },
1519 "vector": {
1520 "type": "array",
1521 "items": { "type": "number" },
1522 "description": "Query embedding vector. Omit for text-only ranking."
1523 },
1524 "vector_field": { "type": "string", "description": "Property field holding embedding vectors (default: embedding)." },
1525 "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." },
1526 "k": { "type": "integer", "description": "Maximum results to return (default: 10)." }
1527 },
1528 "required": ["query_text", "text_field"]
1529 }
1530 },
1531 {
1532 "name": "node_history",
1533 "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.",
1534 "inputSchema": {
1535 "type": "object",
1536 "properties": {
1537 "key": { "type": "string", "description": "Node key to look up." }
1538 },
1539 "required": ["key"]
1540 }
1541 },
1542 {
1543 "name": "edge_history",
1544 "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.",
1545 "inputSchema": {
1546 "type": "object",
1547 "properties": {
1548 "a": { "type": "string", "minLength": 1, "description": "First node key." },
1549 "b": { "type": "string", "minLength": 1, "description": "Second node key." }
1550 },
1551 "required": ["a", "b"]
1552 }
1553 },
1554 {
1555 "name": "was_linked",
1556 "description": "Were A and B linked at commit C — whether an edge of `edge_type` existed between the two keys (either direction) at that WAL commit. Returns an error when `at_commit` is outside the retained horizon (`horizon..total_commits`).",
1557 "inputSchema": {
1558 "type": "object",
1559 "properties": {
1560 "a": { "type": "string", "minLength": 1, "description": "First node key." },
1561 "b": { "type": "string", "minLength": 1, "description": "Second node key." },
1562 "edge_type": { "type": "string", "minLength": 1, "description": "Edge type to check." },
1563 "at_commit": { "type": "integer", "minimum": 0, "description": "0-based WAL commit index to query." }
1564 },
1565 "required": ["a", "b", "edge_type", "at_commit"]
1566 }
1567 },
1568 {
1569 "name": "rename_node",
1570 "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.",
1571 "inputSchema": {
1572 "type": "object",
1573 "properties": {
1574 "old_key": { "type": "string", "minLength": 1, "description": "Current node key." },
1575 "new_key": { "type": "string", "minLength": 1, "description": "Desired new node key." }
1576 },
1577 "required": ["old_key", "new_key"]
1578 }
1579 }
1580 ]) else {
1581 unreachable!("the literal above is an array")
1582 };
1583 tools
1584}
1585
1586fn tool_ok(payload: Js) -> Js {
1587 json!({
1588 "content": [{ "type": "text", "text": payload.to_string() }]
1589 })
1590}
1591
1592fn task_ok(text: &str) -> Js {
1600 json!({
1601 "content": [{ "type": "text", "text": text }]
1602 })
1603}
1604
1605fn tool_err(message: &str) -> Js {
1606 json!({
1607 "content": [{ "type": "text", "text": message }],
1608 "isError": true
1609 })
1610}
1611
1612fn write_result(writer: &mut impl Write, id: Option<Js>, result: Js) -> io::Result<()> {
1613 write_json(
1614 writer,
1615 &json!({
1616 "jsonrpc": "2.0",
1617 "id": id.unwrap_or(Js::Null),
1618 "result": result
1619 }),
1620 )
1621}
1622
1623fn write_error(
1624 writer: &mut impl Write,
1625 id: Option<Js>,
1626 code: i64,
1627 message: &str,
1628) -> io::Result<()> {
1629 write_json(
1630 writer,
1631 &json!({
1632 "jsonrpc": "2.0",
1633 "id": id.unwrap_or(Js::Null),
1634 "error": { "code": code, "message": message }
1635 }),
1636 )
1637}
1638
1639fn write_json(writer: &mut impl Write, value: &Js) -> io::Result<()> {
1640 let s = serde_json::to_string(value).map_err(io::Error::other)?;
1641 writeln!(writer, "{s}")?;
1642 writer.flush()
1643}
1644
1645#[cfg(test)]
1650mod tests {
1651 use super::*;
1652 use core_api::{AutoFk, IngestOptions, Predicate, RuleDef, Value};
1653 use std::path::PathBuf;
1654 use std::sync::atomic::{AtomicU64, Ordering};
1655
1656 fn tmp_dir() -> PathBuf {
1657 static SEQ: AtomicU64 = AtomicU64::new(0);
1658 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1659 let d = std::env::temp_dir().join(format!("mcp-test-{}-{}", std::process::id(), n));
1660 let _ = std::fs::remove_dir_all(&d);
1664 d
1665 }
1666
1667 fn demo_db() -> SharedDb {
1669 let db = SharedDb::open(&tmp_dir()).expect("open");
1670 {
1671 let mut g = db.write();
1672 let opts = IngestOptions {
1673 key_field: "id".into(),
1674 auto_fk: AutoFk::Off,
1675 };
1676 let people: Vec<BTreeMap<String, Value>> = vec![
1678 [
1679 ("id", Value::Str("alice".into())),
1680 ("name", Value::Str("Alice".into())),
1681 (
1682 "emb",
1683 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1684 ),
1685 ]
1686 .into_iter()
1687 .map(|(k, v)| (k.to_string(), v))
1688 .collect(),
1689 [
1690 ("id", Value::Str("bob".into())),
1691 ("name", Value::Str("Bob".into())),
1692 (
1693 "emb",
1694 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1695 ),
1696 ]
1697 .into_iter()
1698 .map(|(k, v)| (k.to_string(), v))
1699 .collect(),
1700 ];
1701 g.ingest("Person", people, &opts).expect("ingest");
1702
1703 g.create_rule(RuleDef {
1705 name: "sim_emb".into(),
1706 src_label: "Person".into(),
1707 dst_label: "Person".into(),
1708 predicate: Predicate::VectorSimilar {
1709 field: "emb".into(),
1710 min: 0.9,
1711 },
1712 edge_type: "SIMILAR".into(),
1713 weight_prop: Some("score".into()),
1714 max_edges: None,
1715 approximate: false,
1716 via_label: None,
1717 via_edge: None,
1718 via_dir: None,
1719 namespace: None,
1720 })
1721 .expect("rule");
1722 }
1723 db
1724 }
1725
1726 fn roundtrip(db: &SharedDb, request: &str) -> Js {
1727 roundtrip_with(db, false, request)
1728 }
1729
1730 fn roundtrip_with(db: &SharedDb, all_tools: bool, request: &str) -> Js {
1731 let input = format!("{request}\n");
1732 let mut output = Vec::new();
1733 run_mcp_stdio_with(db.clone(), None, all_tools, input.as_bytes(), &mut output)
1734 .expect("mcp");
1735 let s = std::str::from_utf8(&output).expect("utf8");
1736 serde_json::from_str(s.trim()).expect("json response")
1737 }
1738
1739 fn tool_call(db: &SharedDb, id: u64, tool: &str, args: Js) -> Js {
1740 let req = json!({
1741 "jsonrpc": "2.0",
1742 "id": id,
1743 "method": "tools/call",
1744 "params": { "name": tool, "arguments": args }
1745 });
1746 roundtrip(db, &req.to_string())
1747 }
1748
1749 fn tool_text(resp: &Js) -> Js {
1751 let text = resp["result"]["content"][0]["text"]
1752 .as_str()
1753 .expect("content[0].text");
1754 serde_json::from_str(text).expect("tool text is json")
1755 }
1756
1757 fn is_error(resp: &Js) -> bool {
1758 resp["result"]["isError"].as_bool().unwrap_or(false)
1759 }
1760
1761 fn tool_err_text(resp: &Js) -> String {
1762 resp["result"]["content"][0]["text"]
1763 .as_str()
1764 .unwrap_or("")
1765 .to_string()
1766 }
1767
1768 #[test]
1771 fn test_tools_list_includes_all_expected() {
1772 let db = demo_db();
1773 let resp = roundtrip_with(
1774 &db,
1775 true,
1776 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
1777 );
1778 let tools = resp["result"]["tools"].as_array().expect("tools array");
1779 let names: Vec<&str> = tools
1780 .iter()
1781 .map(|t| t["name"].as_str().expect("name"))
1782 .collect();
1783 for expected in &[
1784 "explore",
1786 "map",
1787 "context",
1788 "impact",
1789 "owners",
1790 "why",
1791 "explain_association",
1792 "node_edges",
1793 "neighborhood",
1794 "edges_at",
1795 "what_if",
1796 "recall",
1797 "remember",
1798 "sync",
1799 "query",
1801 "ingest_json",
1802 "create_rule",
1803 "explain",
1804 "stats",
1805 "node_info",
1806 "upsert_entity",
1807 "find_similar",
1808 "pairwise_similar",
1809 "hybrid_search",
1810 "node_history",
1811 "edge_history",
1812 "was_linked",
1813 "rename_node",
1814 ] {
1815 assert!(names.contains(expected), "missing tool: {expected}");
1816 }
1817 assert_eq!(
1818 names.len(),
1819 28,
1820 "expected exactly 28 tools, got {}",
1821 names.len()
1822 );
1823 assert_eq!(
1824 &names[..14],
1825 [
1826 "explore",
1827 "map",
1828 "context",
1829 "impact",
1830 "owners",
1831 "why",
1832 "explain_association",
1833 "node_edges",
1834 "neighborhood",
1835 "edges_at",
1836 "what_if",
1837 "recall",
1838 "remember",
1839 "sync"
1840 ],
1841 "the task tools come first, in order"
1842 );
1843 assert_eq!(names[14], "query", "the graph tools follow them");
1844 }
1845
1846 #[test]
1850 fn tools_list_defaults_to_nineteen_on_a_memory_store() {
1851 let db = demo_db();
1852 let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1853 let names: Vec<&str> = resp["result"]["tools"]
1854 .as_array()
1855 .expect("tools array")
1856 .iter()
1857 .map(|t| t["name"].as_str().expect("name"))
1858 .collect();
1859 assert_eq!(names, ASSOCIATION_TOOLS.to_vec());
1860 }
1861
1862 #[test]
1865 fn association_listing_includes_pairwise_similar_after_find_similar() {
1866 let db = demo_db();
1867 let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1868 let names: Vec<&str> = resp["result"]["tools"]
1869 .as_array()
1870 .expect("tools array")
1871 .iter()
1872 .map(|t| t["name"].as_str().expect("name"))
1873 .collect();
1874 const EXPECTED: [&str; 19] = [
1875 "query",
1876 "explain_association",
1877 "neighborhood",
1878 "node_info",
1879 "node_edges",
1880 "was_linked",
1881 "edges_at",
1882 "what_if",
1883 "node_history",
1884 "edge_history",
1885 "find_similar",
1886 "pairwise_similar",
1887 "hybrid_search",
1888 "remember",
1889 "recall",
1890 "upsert_entity",
1891 "ingest_json",
1892 "create_rule",
1893 "stats",
1894 ];
1895 assert_eq!(names, EXPECTED.to_vec());
1896 assert_eq!(ASSOCIATION_TOOLS.as_slice(), EXPECTED.as_slice());
1897 let find = names
1898 .iter()
1899 .position(|&n| n == "find_similar")
1900 .expect("find_similar listed");
1901 assert_eq!(names[find + 1], "pairwise_similar");
1902 }
1903
1904 #[test]
1911 fn the_association_surface_is_entity_tools_only() {
1912 for kept in ["remember", "recall", "explain_association"] {
1913 assert!(
1914 ASSOCIATION_TOOLS.contains(&kept),
1915 "{kept} answers on an entity graph and must be listed"
1916 );
1917 }
1918 for code_only in [
1919 "explore", "map", "context", "impact", "owners", "why", "sync",
1920 ] {
1921 assert!(
1922 !ASSOCIATION_TOOLS.contains(&code_only),
1923 "{code_only} reads a code graph and must not be listed on a memory store"
1924 );
1925 }
1926 let served: Vec<String> = crate::mcp_tasks::task_tools()
1927 .iter()
1928 .chain(graph_tools().iter())
1929 .filter_map(|t| t.get("name").and_then(Js::as_str))
1930 .map(str::to_string)
1931 .collect();
1932 for name in ASSOCIATION_TOOLS {
1933 assert!(
1934 served.iter().any(|s| s == name),
1935 "{name} is listed but not served"
1936 );
1937 }
1938 assert!(
1939 CODE_GRAPH_TOOLS.contains(&"explore"),
1940 "and `explore` is the task tool the other surface lists"
1941 );
1942 }
1943
1944 #[test]
1947 fn tools_list_is_three_tools_on_a_code_graph_store() {
1948 let db = demo_db();
1949 db.write()
1950 .insert_node(
1951 "GitSync",
1952 crate::mcp_tasks::SYNC_KEY,
1953 vec![("id".into(), Value::Str(crate::mcp_tasks::SYNC_KEY.into()))],
1954 )
1955 .expect("marker");
1956 let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1957 let names: Vec<&str> = resp["result"]["tools"]
1958 .as_array()
1959 .expect("tools array")
1960 .iter()
1961 .map(|t| t["name"].as_str().expect("name"))
1962 .collect();
1963 assert_eq!(names, ["explore", "query", "stats"]);
1964 }
1965
1966 #[test]
1967 fn test_stats_returns_node_count() {
1968 let db = demo_db();
1969 let resp = tool_call(&db, 1, "stats", json!({}));
1970 assert!(!is_error(&resp));
1971 let result = tool_text(&resp);
1972 assert_eq!(result["nodes_live"], 2);
1973 }
1974
1975 #[test]
1980 fn mcp_stats_unscoped_omits_namespace_roster() {
1981 let db = SharedDb::open(&tmp_dir()).expect("open");
1982 {
1983 let mut g = db.write();
1984 g.insert_node(
1985 "Doc",
1986 "a",
1987 vec![("ns".into(), Value::Str("tenant-a".into()))],
1988 )
1989 .expect("insert a");
1990 g.insert_node(
1991 "Doc",
1992 "b",
1993 vec![("ns".into(), Value::Str("tenant-b".into()))],
1994 )
1995 .expect("insert b");
1996 }
1997
1998 let unscoped = tool_text(&tool_call(&db, 1, "stats", json!({})));
1999 assert!(
2000 unscoped.get("namespaces").is_none(),
2001 "unscoped stats must omit the roster entirely, not send an empty \
2002 array: {unscoped}"
2003 );
2004 assert_eq!(
2005 unscoped["nodes_live"], 2,
2006 "the store-wide counts beside the roster are unchanged"
2007 );
2008
2009 let scoped = tool_text(&tool_call(
2010 &db,
2011 2,
2012 "stats",
2013 json!({"namespace": "tenant-a"}),
2014 ));
2015 let names: Vec<&str> = scoped["namespaces"]
2016 .as_array()
2017 .expect("a scoped call still carries the roster it may see")
2018 .iter()
2019 .map(|n| n["name"].as_str().expect("name"))
2020 .collect();
2021 assert_eq!(
2022 names,
2023 ["tenant-a"],
2024 "a call that names a namespace sees that one and no other"
2025 );
2026 }
2027
2028 #[test]
2032 fn create_rule_reports_a_build_it_could_not_finish() {
2033 let db = SharedDb::open(&tmp_dir()).expect("open");
2034 {
2035 let mut g = db.write();
2036 for i in 0..300usize {
2037 const D: usize = 32;
2038 let axis = (i / 10) % D;
2039 let mut xs = vec![0.0f64; D];
2040 xs[axis] = 1.0;
2041 xs[(axis + 1) % D] = (i % 10) as f64 * 0.001;
2042 g.insert_node(
2043 "V",
2044 &format!("v{i}"),
2045 vec![(
2046 "emb".into(),
2047 Value::List(xs.into_iter().map(Value::Float).collect()),
2048 )],
2049 )
2050 .expect("insert");
2051 }
2052 g.set_hnsw_build_batch(Some(64));
2053 }
2054 let args = json!({
2055 "name": "sim",
2056 "src_label": "V",
2057 "dst_label": "V",
2058 "predicate": {"VectorSimilar": {"field": "emb", "min": 0.9}},
2059 "edge_type": "SIM",
2060 "weight_prop": null,
2061 "max_edges": null,
2062 "approximate": true
2063 });
2064 let resp = tool_call(&db, 1, "create_rule", args);
2065 assert!(!is_error(&resp), "{resp}");
2066 let result = tool_text(&resp);
2067 assert_eq!(result["name"], json!("sim"));
2068 assert_eq!(result["building"], json!({"indexed": 64, "total": 300}));
2069 let note = result["note"].as_str().expect("a note explaining the wait");
2070 assert!(
2071 note.contains("derives no edges until it finishes") && note.contains("build-index"),
2072 "the note must say the edges are not there yet and how to finish: {note}"
2073 );
2074
2075 let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2077 let rule = stats["rules"]
2078 .as_array()
2079 .expect("rules")
2080 .iter()
2081 .find(|r| r["name"] == "sim")
2082 .expect("the rule is installed while it builds");
2083 assert_eq!(rule["edges"], json!(0));
2084 assert_eq!(
2085 rule["building"],
2086 json!({"rule": "sim", "indexed": 64, "total": 300})
2087 );
2088
2089 while !db.write().pump_index_build().expect("pump").is_empty() {}
2091 let stats = tool_text(&tool_call(&db, 3, "stats", json!({})));
2092 let rule = stats["rules"]
2093 .as_array()
2094 .expect("rules")
2095 .iter()
2096 .find(|r| r["name"] == "sim")
2097 .expect("rule");
2098 assert!(rule.get("building").is_none(), "{rule}");
2099 assert!(rule["edges"].as_u64().expect("edges") > 0);
2100 }
2101
2102 #[test]
2103 fn test_query_runs_cypher() {
2104 let db = demo_db();
2105 let resp = tool_call(
2106 &db,
2107 1,
2108 "query",
2109 json!({ "cypher": "MATCH (n:Person) RETURN n.name ORDER BY n.name" }),
2110 );
2111 assert!(!is_error(&resp));
2112 let result = tool_text(&resp);
2113 assert_eq!(result["columns"], json!(["n.name"]));
2115 assert_eq!(result["rows"].as_array().map(|r| r.len()), Some(2));
2116 }
2117
2118 #[test]
2119 fn test_query_create_is_a_write() {
2120 let db = SharedDb::open(&tmp_dir()).expect("open");
2121 let resp = tool_call(
2122 &db,
2123 1,
2124 "query",
2125 json!({ "cypher": "CREATE (n:L {id: 'k'}) RETURN n" }),
2126 );
2127 assert!(
2128 !is_error(&resp),
2129 "CREATE via MCP query must succeed: {resp}"
2130 );
2131 let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2132 assert_eq!(stats["nodes_live"], 1);
2133 }
2134
2135 #[test]
2136 fn test_ingest_json_inserts_nodes() {
2137 let db = demo_db();
2138 let resp = tool_call(
2139 &db,
2140 1,
2141 "ingest_json",
2142 json!({
2143 "label": "Person",
2144 "rows_json": r#"[{"id":"carol","name":"Carol"}]"#,
2145 "key_field": "id"
2146 }),
2147 );
2148 assert!(!is_error(&resp));
2149 let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2151 assert_eq!(stats["nodes_live"], 3);
2152 }
2153
2154 #[test]
2155 fn test_node_info_returns_props() {
2156 let db = demo_db();
2157 let resp = tool_call(&db, 1, "node_info", json!({ "key": "alice" }));
2158 assert!(!is_error(&resp));
2159 let result = tool_text(&resp);
2160 assert_eq!(result["key"], "alice");
2161 assert_eq!(result["label"], "Person");
2162 assert_eq!(result["props"]["name"], "Alice");
2163 }
2164
2165 #[test]
2168 fn test_node_edges_returns_edges() {
2169 let db = demo_db();
2170 let resp = tool_call(
2171 &db,
2172 1,
2173 "node_edges",
2174 json!({ "key": "alice", "json": true }),
2175 );
2176 assert!(!is_error(&resp));
2177 let result = tool_text(&resp);
2178 assert_eq!(result["key"], "alice");
2179 let types = result["types"].as_array().expect("types");
2180 assert!(
2181 !types.is_empty(),
2182 "alice should have at least one edge type"
2183 );
2184 let similar = types
2185 .iter()
2186 .find(|t| t["edge_type"] == "SIMILAR")
2187 .expect("the rule's edge type");
2188 assert_eq!(similar["count"], json!(2));
2191 let edges = similar["edges"].as_array().expect("edges");
2192 let dirs: Vec<&str> = edges
2193 .iter()
2194 .map(|e| e["direction"].as_str().expect("direction"))
2195 .collect();
2196 assert!(dirs.contains(&"out") && dirs.contains(&"in"), "{similar}");
2197 for edge in edges {
2198 assert_eq!(edge["other"], json!("bob"));
2199 assert_eq!(edge["derived"], json!(true));
2200 assert_eq!(edge["rule"], json!("sim_emb"));
2201 assert_eq!(edge["score"], json!(1.0));
2202 assert!(
2203 edge["predicate"]
2204 .as_str()
2205 .unwrap_or("")
2206 .contains("vector_similar"),
2207 "the predicate travels with the edge: {edge}"
2208 );
2209 }
2210 }
2211
2212 #[test]
2215 fn test_neighborhood_traverses_one_hop() {
2216 let db = demo_db();
2217 let resp = tool_call(
2218 &db,
2219 1,
2220 "neighborhood",
2221 json!({ "key": "alice", "depth": 1, "json": true }),
2222 );
2223 assert!(!is_error(&resp));
2224 let result = tool_text(&resp);
2225 assert_eq!(result["key"], "alice");
2226 assert!(result["types"].as_array().is_some(), "{result}");
2227
2228 let deep = tool_call(
2229 &db,
2230 2,
2231 "neighborhood",
2232 json!({ "key": "alice", "depth": 2 }),
2233 );
2234 assert!(!is_error(&deep));
2235 let table = tool_text(&deep);
2236 assert_eq!(table["columns"], json!(["key", "label", "depth"]));
2237 assert!(table["rows"].as_array().is_some());
2238 }
2239
2240 #[test]
2241 fn test_explain_returns_rule_info() {
2242 let db = demo_db();
2243 let resp = tool_call(&db, 1, "explain", json!({ "a": "alice", "b": "bob" }));
2244 assert!(!is_error(&resp));
2245 let result = tool_text(&resp);
2246 let arr = result.as_array().expect("explain returns array");
2247 assert!(!arr.is_empty(), "expected at least one explanation");
2248 assert_eq!(arr[0]["rule"], "sim_emb");
2249 }
2250
2251 #[test]
2252 fn test_create_rule_backfills() {
2253 let db = SharedDb::open(&tmp_dir()).expect("open");
2254 {
2255 let mut g = db.write();
2256 let opts = IngestOptions {
2257 key_field: "id".into(),
2258 auto_fk: AutoFk::Off,
2259 };
2260 let rows: Vec<BTreeMap<String, Value>> = vec![
2261 [
2262 ("id", Value::Str("x".into())),
2263 ("tag", Value::Str("a".into())),
2264 ]
2265 .into_iter()
2266 .map(|(k, v)| (k.to_string(), v))
2267 .collect(),
2268 [
2269 ("id", Value::Str("y".into())),
2270 ("tag", Value::Str("a".into())),
2271 ]
2272 .into_iter()
2273 .map(|(k, v)| (k.to_string(), v))
2274 .collect(),
2275 ];
2276 g.ingest("Item", rows, &opts).expect("ingest");
2277 }
2278 let resp = tool_call(
2279 &db,
2280 1,
2281 "create_rule",
2282 json!({
2283 "name": "same_tag",
2284 "src_label": "Item",
2285 "dst_label": "Item",
2286 "predicate": { "FieldEqual": { "field": "tag" } },
2287 "edge_type": "SAME_TAG"
2288 }),
2289 );
2290 assert!(!is_error(&resp));
2291 let result = tool_text(&resp);
2292 assert_eq!(result["ok"], true);
2293 let edges_resp = tool_call(&db, 2, "node_edges", json!({ "key": "x", "json": true }));
2295 let edges_result = tool_text(&edges_resp);
2296 let types = edges_result["types"].as_array().expect("types");
2297 assert!(
2298 types.iter().any(|t| t["edge_type"] == "SAME_TAG"),
2299 "SAME_TAG edge not found after create_rule"
2300 );
2301 }
2302
2303 #[test]
2306 fn test_upsert_entity_creates_new_node() {
2307 let db = demo_db();
2308 let resp = tool_call(
2309 &db,
2310 1,
2311 "upsert_entity",
2312 json!({
2313 "key": "carol",
2314 "label": "Person",
2315 "props": { "name": "Carol", "age": 30 }
2316 }),
2317 );
2318 assert!(!is_error(&resp));
2319 let result = tool_text(&resp);
2320 assert_eq!(result["ok"], true);
2321 assert_eq!(result["created"], true);
2322 assert_eq!(result["key"], "carol");
2323 let info = tool_text(&tool_call(&db, 2, "node_info", json!({ "key": "carol" })));
2325 assert_eq!(info["props"]["name"], "Carol");
2326 }
2327
2328 #[test]
2329 fn test_upsert_entity_updates_existing_node() {
2330 let db = demo_db();
2331 let resp = tool_call(
2332 &db,
2333 1,
2334 "upsert_entity",
2335 json!({
2336 "key": "alice",
2337 "props": { "name": "Alice Updated" }
2338 }),
2339 );
2340 assert!(!is_error(&resp));
2341 let result = tool_text(&resp);
2342 assert_eq!(result["ok"], true);
2343 assert_eq!(result["created"], false);
2344 assert_eq!(result["updated_fields"], 1);
2345 let info = tool_text(&tool_call(&db, 2, "node_info", json!({ "key": "alice" })));
2347 assert_eq!(info["props"]["name"], "Alice Updated");
2348 }
2349
2350 #[test]
2351 fn test_upsert_entity_missing_label_on_create_is_error() {
2352 let db = demo_db();
2353 let resp = tool_call(
2354 &db,
2355 1,
2356 "upsert_entity",
2357 json!({ "key": "new-node", "props": { "x": 1 } }),
2358 );
2359 assert!(is_error(&resp), "should error without label for new node");
2360 }
2361
2362 #[test]
2363 fn test_pairwise_similar_excludes_self() {
2364 let db = demo_db();
2365 let resp = tool_call(
2366 &db,
2367 1,
2368 "pairwise_similar",
2369 json!({
2370 "keys": ["alice", "bob"],
2371 "field": "emb",
2372 "k": 10,
2373 "min": 0.0
2374 }),
2375 );
2376 assert!(
2377 !is_error(&resp),
2378 "pairwise_similar must not error: {resp:?}"
2379 );
2380 let result = tool_text(&resp);
2381 let results = result["results"].as_array().expect("results");
2382 assert_eq!(results.len(), 2);
2383 for row in results {
2384 let key = row["key"].as_str().expect("key");
2385 let neighbors = row["neighbors"].as_array().expect("neighbors");
2386 assert!(
2387 neighbors.iter().all(|n| n["key"].as_str() != Some(key)),
2388 "self must be excluded: {row}"
2389 );
2390 assert!(!neighbors.is_empty(), "alice/bob are identical: {row}");
2391 }
2392 }
2393
2394 #[test]
2395 fn test_find_similar_returns_similar_edges() {
2396 let db = demo_db();
2397 let resp = tool_call(
2398 &db,
2399 1,
2400 "find_similar",
2401 json!({ "key": "alice", "edge_type": "SIMILAR" }),
2402 );
2403 assert!(!is_error(&resp));
2404 let result = tool_text(&resp);
2405 assert_eq!(result["key"], "alice");
2406 assert_eq!(result["edge_type"], "SIMILAR");
2407 let similar = result["similar"].as_array().expect("similar array");
2408 assert!(!similar.is_empty(), "expected SIMILAR neighbors for alice");
2409 assert_eq!(similar[0]["neighbor_key"], "bob");
2410 }
2411
2412 #[test]
2413 fn test_find_similar_limit_respected() {
2414 let db = demo_db();
2415 let resp = tool_call(
2416 &db,
2417 1,
2418 "find_similar",
2419 json!({ "key": "alice", "edge_type": "SIMILAR", "limit": 0 }),
2420 );
2421 assert!(!is_error(&resp));
2422 let result = tool_text(&resp);
2423 let similar = result["similar"].as_array().expect("similar array");
2424 assert_eq!(similar.len(), 0);
2425 }
2426
2427 #[test]
2431 fn test_find_similar_vector_default_min_is_0_8() {
2432 let db = SharedDb::open(&tmp_dir()).expect("open");
2433 {
2434 let mut g = db.write();
2435 g.insert_node(
2437 "Item",
2438 "close",
2439 vec![(
2440 "emb".into(),
2441 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2442 )],
2443 )
2444 .unwrap();
2445 g.insert_node(
2447 "Item",
2448 "far",
2449 vec![(
2450 "emb".into(),
2451 Value::List(vec![Value::Float(0.0), Value::Float(1.0)]),
2452 )],
2453 )
2454 .unwrap();
2455 }
2456
2457 let resp = tool_call(
2459 &db,
2460 1,
2461 "find_similar",
2462 json!({
2463 "vector": [1.0, 0.0],
2464 "field": "emb",
2465 "label": "Item",
2466 "k": 10
2467 }),
2468 );
2469 assert!(!is_error(&resp), "vector search must not error");
2470 let result = tool_text(&resp);
2471 let results = result["results"].as_array().expect("results array");
2472
2473 let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2474 assert!(
2475 keys.contains(&"close"),
2476 "close node (sim=1.0) must be included"
2477 );
2478 assert!(
2479 !keys.contains(&"far"),
2480 "far node (sim=0.0) must be excluded by default min=0.8"
2481 );
2482 }
2483
2484 #[test]
2486 fn test_find_similar_vector_mask_excludes_hidden() {
2487 let db = SharedDb::open(&tmp_dir()).expect("open");
2488 {
2489 let mut g = db.write();
2490 g.insert_node(
2492 "Item",
2493 "visible",
2494 vec![(
2495 "emb".into(),
2496 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2497 )],
2498 )
2499 .unwrap();
2500 g.insert_node(
2502 "Item",
2503 "hidden",
2504 vec![(
2505 "emb".into(),
2506 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2507 )],
2508 )
2509 .unwrap();
2510 }
2511
2512 let resp = tool_call(
2513 &db,
2514 1,
2515 "find_similar",
2516 json!({
2517 "vector": [1.0, 0.0],
2518 "field": "emb",
2519 "label": "Item",
2520 "k": 10,
2521 "min": 0.0,
2522 "mask": ["visible"]
2523 }),
2524 );
2525 assert!(!is_error(&resp), "masked vector search must not error");
2526 let result = tool_text(&resp);
2527 let results = result["results"].as_array().expect("results array");
2528
2529 let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2530 assert!(
2531 keys.contains(&"visible"),
2532 "visible node must appear in masked results"
2533 );
2534 assert!(
2535 !keys.contains(&"hidden"),
2536 "hidden node must be excluded by mask"
2537 );
2538 }
2539
2540 #[test]
2542 fn test_find_similar_vector_mask_bad_type_is_error() {
2543 let db = SharedDb::open(&tmp_dir()).expect("open");
2544 let resp = tool_call(
2545 &db,
2546 1,
2547 "find_similar",
2548 json!({
2549 "vector": [1.0, 0.0],
2550 "field": "emb",
2551 "k": 5,
2552 "mask": [42]
2553 }),
2554 );
2555 assert!(
2556 is_error(&resp),
2557 "non-string mask element must produce a tool error"
2558 );
2559 }
2560
2561 #[test]
2563 fn test_find_similar_vector_where_eq() {
2564 let db = SharedDb::open(&tmp_dir()).expect("open");
2565 {
2566 let mut g = db.write();
2567 g.insert_node(
2568 "Document",
2569 "in-scope",
2570 vec![
2571 (
2572 "emb".into(),
2573 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2574 ),
2575 ("resource_scope_id".into(), Value::Str("a".into())),
2576 ],
2577 )
2578 .unwrap();
2579 g.insert_node(
2580 "Document",
2581 "out-scope",
2582 vec![
2583 (
2584 "emb".into(),
2585 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2586 ),
2587 ("resource_scope_id".into(), Value::Str("b".into())),
2588 ],
2589 )
2590 .unwrap();
2591 }
2592 let resp = tool_call(
2593 &db,
2594 1,
2595 "find_similar",
2596 json!({
2597 "vector": [1.0, 0.0],
2598 "field": "emb",
2599 "label": "Document",
2600 "k": 10,
2601 "min": 0.0,
2602 "where": { "field": "resource_scope_id", "eq": "a" }
2603 }),
2604 );
2605 assert!(!is_error(&resp), "where eq must not error: {resp:?}");
2606 let result = tool_text(&resp);
2607 let keys: Vec<&str> = result["results"]
2608 .as_array()
2609 .expect("results")
2610 .iter()
2611 .filter_map(|r| r["key"].as_str())
2612 .collect();
2613 assert_eq!(keys, vec!["in-scope"]);
2614 }
2615
2616 #[test]
2617 fn test_find_similar_vector_where_invalid_is_error() {
2618 let db = SharedDb::open(&tmp_dir()).expect("open");
2619 let resp = tool_call(
2620 &db,
2621 1,
2622 "find_similar",
2623 json!({
2624 "vector": [1.0, 0.0],
2625 "field": "emb",
2626 "where": { "field": "resource_scope_id", "eq": "a", "in": ["b"] }
2627 }),
2628 );
2629 assert!(is_error(&resp), "invalid where must be a tool error");
2630 let msg = format!("{resp:?}");
2631 assert!(
2632 msg.contains("where"),
2633 "tool error must name where, got {msg}"
2634 );
2635 }
2636
2637 #[test]
2639 fn test_find_similar_edge_ignores_where_and_exact() {
2640 let db = demo_db();
2641 let resp = tool_call(
2642 &db,
2643 1,
2644 "find_similar",
2645 json!({
2646 "key": "alice",
2647 "edge_type": "SIMILAR",
2648 "where": { "field": "x", "eq": "y", "in": ["z"] },
2649 "exact": true
2650 }),
2651 );
2652 assert!(
2653 !is_error(&resp),
2654 "edge mode must ignore invalid where: {resp:?}"
2655 );
2656 }
2657
2658 #[test]
2660 fn test_find_similar_edge_mask_excludes_hidden_neighbor() {
2661 let db = SharedDb::open(&tmp_dir()).expect("open");
2662 {
2663 let mut g = db.write();
2664 g.insert_node("P", "alice", vec![]).unwrap();
2665 g.insert_node("P", "bob", vec![]).unwrap(); g.insert_node("P", "carol", vec![]).unwrap(); g.insert_edge("KNOWS", "alice", "bob").unwrap();
2668 g.insert_edge("KNOWS", "alice", "carol").unwrap();
2669 }
2670 let resp = tool_call(
2672 &db,
2673 1,
2674 "find_similar",
2675 json!({
2676 "key": "alice",
2677 "edge_type": "KNOWS",
2678 "mask": ["alice", "bob"]
2679 }),
2680 );
2681 assert!(!is_error(&resp), "masked edge search must not error");
2682 let result = tool_text(&resp);
2683 let similar = result["similar"].as_array().expect("similar array");
2684 let neighbors: Vec<&str> = similar
2685 .iter()
2686 .filter_map(|e| e["neighbor_key"].as_str())
2687 .collect();
2688 assert!(neighbors.contains(&"bob"), "bob (visible) must appear");
2689 assert!(
2690 !neighbors.contains(&"carol"),
2691 "carol (hidden) must be excluded"
2692 );
2693 }
2694
2695 #[test]
2698 fn test_find_similar_edge_mask_hidden_key_is_not_found() {
2699 let db = SharedDb::open(&tmp_dir()).expect("open");
2700 {
2701 let mut g = db.write();
2702 g.insert_node("P", "alice", vec![]).unwrap();
2703 g.insert_node("P", "bob", vec![]).unwrap();
2704 }
2705 let resp_masked = tool_call(
2707 &db,
2708 1,
2709 "find_similar",
2710 json!({ "key": "alice", "edge_type": "KNOWS", "mask": ["bob"] }),
2711 );
2712 let resp_ghost = tool_call(
2714 &db,
2715 2,
2716 "find_similar",
2717 json!({ "key": "ghost", "edge_type": "KNOWS" }),
2718 );
2719 assert!(
2720 is_error(&resp_masked),
2721 "hidden query key must produce a tool error"
2722 );
2723 assert!(
2724 is_error(&resp_ghost),
2725 "nonexistent key must produce a tool error"
2726 );
2727 assert_eq!(
2729 tool_err_text(&resp_masked).contains("alice"),
2730 tool_err_text(&resp_ghost).contains("ghost"),
2731 "error messages should follow same not-found template"
2732 );
2733 }
2734
2735 #[test]
2740 fn test_explain_association_same_as_explain() {
2741 let db = demo_db();
2742 let explain = tool_text(&tool_call(
2743 &db,
2744 1,
2745 "explain",
2746 json!({ "a": "alice", "b": "bob" }),
2747 ));
2748 let assoc = tool_text(&tool_call(
2749 &db,
2750 2,
2751 "explain_association",
2752 json!({ "a": "alice", "b": "bob", "json": true }),
2753 ));
2754 let explain: Vec<Js> = serde_json::from_value(explain).expect("explain array");
2755 let mut assoc: Vec<Js> = serde_json::from_value(assoc).expect("assoc array");
2756 for row in &mut assoc {
2757 let ev = row
2758 .as_object_mut()
2759 .expect("object")
2760 .remove("evidence")
2761 .expect("every derived edge carries its evidence");
2762 assert!(
2763 ev["similarity"].is_number(),
2764 "a vector_similar edge reports the cosine it scored: {ev}"
2765 );
2766 }
2767 assert_eq!(explain, assoc, "evidence is the only addition");
2768
2769 let prose = tool_call(
2770 &db,
2771 3,
2772 "explain_association",
2773 json!({ "a": "alice", "b": "bob" }),
2774 );
2775 let text = prose["result"]["content"][0]["text"]
2776 .as_str()
2777 .expect("text content");
2778 assert!(
2779 text.contains("mushroomdb explain — alice ↔ bob:"),
2780 "the default reply is the digest: {text}"
2781 );
2782 }
2783
2784 #[test]
2789 fn test_edge_history_returns_derived_lifecycle_with_rule() {
2790 let db = demo_db(); let resp = tool_call(&db, 1, "edge_history", json!({ "a": "alice", "b": "bob" }));
2792 assert!(!is_error(&resp), "edge_history must not error: {resp}");
2793 let result = tool_text(&resp);
2794
2795 let total = result["total_commits"].as_u64().expect("total_commits");
2797 assert!(total > 0, "total_commits must be > 0 after ingest + rule");
2798
2799 let events = result["events"].as_array().expect("events array");
2801 assert!(!events.is_empty(), "expected at least one edge event");
2802
2803 let derived_added = events
2805 .iter()
2806 .any(|ev| ev["event"].as_str() == Some("Added") && !ev["rule"].is_null());
2807 assert!(
2808 derived_added,
2809 "expected a derived Added event with rule attribution: {events:?}"
2810 );
2811 }
2812
2813 #[test]
2816 fn test_was_linked_at_valid_commit() {
2817 let db = SharedDb::open(&tmp_dir()).expect("open");
2818 {
2819 let mut g = db.write();
2820 let opts = IngestOptions {
2821 key_field: "id".into(),
2822 auto_fk: AutoFk::Off,
2823 };
2824 let rows: Vec<BTreeMap<String, Value>> = vec![
2825 [("id", Value::Str("x".into()))]
2826 .into_iter()
2827 .map(|(k, v)| (k.to_string(), v))
2828 .collect(),
2829 [("id", Value::Str("y".into()))]
2830 .into_iter()
2831 .map(|(k, v)| (k.to_string(), v))
2832 .collect(),
2833 ];
2834 g.ingest("N", rows, &opts).expect("ingest");
2835 g.insert_edge("LINK", "x", "y").expect("edge");
2836 }
2837 let g = db.read();
2839 let total = g.wal_total_commits().expect("wal_total_commits");
2840 drop(g);
2841
2842 let resp = tool_call(
2843 &db,
2844 1,
2845 "was_linked",
2846 json!({ "a": "x", "b": "y", "edge_type": "LINK", "at_commit": total - 1 }),
2847 );
2848 assert!(!is_error(&resp), "was_linked must not error: {resp}");
2849 let result = tool_text(&resp);
2850 assert_eq!(result["linked"], true);
2851 assert_eq!(result["a"], "x");
2852 assert_eq!(result["edge_type"], "LINK");
2853 }
2854
2855 #[test]
2858 fn test_was_linked_out_of_horizon_returns_tool_error() {
2859 let db = SharedDb::open(&tmp_dir()).expect("open");
2860 {
2861 let mut g = db.write();
2862 g.insert_node("N", "a", vec![]).expect("node a");
2863 g.insert_node("N", "b", vec![]).expect("node b");
2864 }
2865 let resp = tool_call(
2867 &db,
2868 1,
2869 "was_linked",
2870 json!({ "a": "a", "b": "b", "edge_type": "X", "at_commit": 999 }),
2871 );
2872 assert!(
2874 is_error(&resp),
2875 "out-of-range commit must be a tool error: {resp}"
2876 );
2877 let text = resp["result"]["content"][0]["text"].as_str().expect("text");
2878 assert!(
2879 text.contains("out of range") || text.contains("range"),
2880 "error must mention range: {text}"
2881 );
2882 }
2883
2884 #[test]
2887 fn test_node_history_via_mcp() {
2888 let db = demo_db(); let resp = tool_call(&db, 1, "node_history", json!({ "key": "alice" }));
2890 assert!(!is_error(&resp), "node_history must not error: {resp}");
2891 let result = tool_text(&resp);
2892
2893 assert_eq!(result["key"], "alice");
2894 let total = result["total_commits"].as_u64().expect("total_commits");
2895 assert!(total > 0, "total_commits must be > 0");
2896
2897 let history = result["history"].as_array().expect("history array");
2898 assert!(
2899 !history.is_empty(),
2900 "alice should have at least one history entry"
2901 );
2902
2903 let first_change = &history[0]["change"];
2905 assert_eq!(first_change["type"], "NodeInserted");
2906 assert_eq!(first_change["label"], "Person");
2907 }
2908}