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").and_then(Js::as_u64) {
1075 Some(n) => n,
1076 None => return CallOutcome::ToolErr("missing or invalid at_commit".into()),
1077 };
1078 let result = {
1079 let g = db.read();
1080 g.was_linked(a, b, edge_type, at_commit)
1081 };
1082 match result {
1083 Ok(linked) => CallOutcome::ToolOk(json!({
1084 "a": a,
1085 "b": b,
1086 "edge_type": edge_type,
1087 "at_commit": at_commit,
1088 "linked": linked,
1089 })),
1090 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1091 }
1092}
1093
1094fn tool_rename_node(db: &SharedDb, args: &Js) -> CallOutcome {
1095 let Some(old_key) = args.get("old_key").and_then(Js::as_str) else {
1096 return CallOutcome::ToolErr("missing old_key".into());
1097 };
1098 let Some(new_key) = args.get("new_key").and_then(Js::as_str) else {
1099 return CallOutcome::ToolErr("missing new_key".into());
1100 };
1101 let mut g = db.write();
1102 match g.rename_node(old_key, new_key) {
1103 Ok(()) => CallOutcome::ToolOk(json!({
1104 "ok": true,
1105 "old_key": old_key,
1106 "new_key": new_key,
1107 })),
1108 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1109 }
1110}
1111
1112fn parse_where_arg(args: &Js) -> std::result::Result<Option<PropPredicate>, String> {
1113 let Some(w) = args.get("where") else {
1114 return Ok(None);
1115 };
1116 if w.is_null() {
1117 return Ok(None);
1118 }
1119 let pred: PropPredicate =
1120 serde_json::from_value(w.clone()).map_err(|e| format!("where: {e}"))?;
1121 pred.validate_named("where")?;
1122 Ok(Some(pred))
1123}
1124
1125pub(crate) fn graph_err_msg(e: GraphError) -> String {
1126 match e {
1127 GraphError::QueryError { detail } | GraphError::IngestError { detail } => detail,
1128 other => other.to_string(),
1129 }
1130}
1131
1132fn initialize_result() -> Js {
1133 json!({
1134 "protocolVersion": "2024-11-05",
1135 "capabilities": { "tools": {} },
1136 "serverInfo": { "name": "mushroomdb", "version": env!("CARGO_PKG_VERSION") }
1137 })
1138}
1139
1140const ADVANCED_PREFIX: &str = "Advanced: ";
1146
1147pub const CODE_GRAPH_TOOLS: [&str; 3] = ["explore", "query", "stats"];
1155
1156pub const ASSOCIATION_TOOLS: [&str; 16] = [
1181 "query",
1182 "explain_association",
1183 "neighborhood",
1184 "node_info",
1185 "node_edges",
1186 "was_linked",
1187 "edges_at",
1188 "what_if",
1189 "node_history",
1190 "edge_history",
1191 "find_similar",
1192 "pairwise_similar",
1193 "hybrid_search",
1194 "remember",
1195 "recall",
1196 "stats",
1197];
1198
1199#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1205pub(crate) enum Surface {
1206 CodeGraph,
1209 Memory,
1212}
1213
1214impl Surface {
1215 fn listing(self) -> &'static [&'static str] {
1218 match self {
1219 Surface::CodeGraph => &CODE_GRAPH_TOOLS,
1220 Surface::Memory => &ASSOCIATION_TOOLS,
1221 }
1222 }
1223}
1224
1225fn surface_of(db: &SharedDb) -> Surface {
1228 let ingested = {
1229 let g = db.read();
1230 g.has_node(crate::mcp_tasks::SYNC_KEY)
1231 };
1232 if ingested {
1233 Surface::CodeGraph
1234 } else {
1235 Surface::Memory
1236 }
1237}
1238
1239fn tools_list(all: bool, surface: Surface) -> Js {
1257 let mut served: Vec<Js> = crate::mcp_tasks::task_tools();
1258 for mut tool in graph_tools() {
1259 if let Some(d) = tool.get("description").and_then(Js::as_str) {
1260 let prefixed = format!("{ADVANCED_PREFIX}{d}");
1261 tool["description"] = Js::String(prefixed);
1262 }
1263 served.push(tool);
1264 }
1265 if all {
1266 return json!({ "tools": served });
1267 }
1268 let listing = surface.listing();
1269 let mut tools: Vec<Js> = Vec::with_capacity(listing.len());
1270 for name in listing {
1271 let Some(tool) = served
1272 .iter()
1273 .find(|t| t.get("name").and_then(Js::as_str) == Some(*name))
1274 else {
1275 debug_assert!(false, "{surface:?} lists {name}, which is not served");
1276 continue;
1277 };
1278 tools.push(tool.clone());
1279 }
1280 json!({ "tools": tools })
1281}
1282
1283fn graph_tools() -> Vec<Js> {
1286 let Js::Array(tools) = json!([
1287 {
1288 "name": "query",
1289 "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.",
1290 "inputSchema": {
1291 "type": "object",
1292 "properties": {
1293 "cypher": { "type": "string", "description": "Cypher query text." },
1294 "params": {
1295 "type": "object",
1296 "description": "Named JSON-scalar query parameters."
1297 },
1298 "mask": {
1299 "type": "array",
1300 "items": { "type": "string" },
1301 "description": "Optional node key allow-list. When present, only these nodes are visible; write statements are rejected."
1302 },
1303 "role": {
1304 "type": "string",
1305 "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."
1306 },
1307 "as_of": {
1308 "type": "integer",
1309 "minimum": 0,
1310 "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."
1311 },
1312 "namespace": {
1313 "type": "string",
1314 "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."
1315 }
1316 },
1317 "required": ["cypher"]
1318 }
1319 },
1320 {
1321 "name": "ingest_json",
1322 "description": "Ingest a JSON array of objects as nodes of one label.",
1323 "inputSchema": {
1324 "type": "object",
1325 "properties": {
1326 "label": { "type": "string" },
1327 "rows_json": {
1328 "type": "string",
1329 "description": "JSON text of an array of objects."
1330 },
1331 "key_field": { "type": "string" },
1332 "auto_fk_suffix": { "type": "string" },
1333 "edges": {
1334 "type": "array",
1335 "description": "Optional user edges [{edge_type, src, dst}]."
1336 },
1337 "namespace": {
1338 "type": "string",
1339 "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."
1340 }
1341 },
1342 "required": ["label", "rows_json"]
1343 }
1344 },
1345 {
1346 "name": "create_rule",
1347 "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.",
1348 "inputSchema": {
1349 "type": "object",
1350 "properties": {
1351 "name": { "type": "string" },
1352 "src_label": { "type": "string" },
1353 "dst_label": { "type": "string" },
1354 "predicate": { "type": "object" },
1355 "edge_type": { "type": "string" },
1356 "weight_prop": {
1357 "type": ["string", "null"],
1358 "description": "Edge property that stores the score (default: weight)."
1359 },
1360 "max_edges": { "type": ["integer", "null"] },
1361 "namespace": {
1362 "type": "string",
1363 "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."
1364 }
1365 },
1366 "required": ["name", "src_label", "dst_label", "predicate", "edge_type"]
1367 }
1368 },
1369 {
1370 "name": "explain",
1371 "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.",
1372 "inputSchema": {
1373 "type": "object",
1374 "properties": {
1375 "a": { "type": "string", "minLength": 1 },
1376 "b": { "type": "string", "minLength": 1 }
1377 },
1378 "required": ["a", "b"]
1379 }
1380 },
1381 {
1382 "name": "stats",
1383 "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.",
1384 "inputSchema": {
1385 "type": "object",
1386 "properties": {
1387 "role": {
1388 "type": "string",
1389 "description": "Report only the namespaces this role may see. The store-wide counts beside them are unchanged."
1390 },
1391 "namespace": {
1392 "type": "string",
1393 "description": "Report only this namespace. Intersects with 'role'."
1394 }
1395 }
1396 }
1397 },
1398 {
1399 "name": "node_info",
1400 "description": "What is K — its label and every property it holds.",
1401 "inputSchema": {
1402 "type": "object",
1403 "properties": {
1404 "key": { "type": "string" }
1405 },
1406 "required": ["key"]
1407 }
1408 },
1409 {
1410 "name": "upsert_entity",
1411 "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.",
1412 "inputSchema": {
1413 "type": "object",
1414 "properties": {
1415 "key": { "type": "string", "description": "Unique node key." },
1416 "label": { "type": "string", "description": "Node label (required when creating a new entity)." },
1417 "props": {
1418 "type": "object",
1419 "description": "Properties to set. Values must be scalars (string, number, bool) or arrays of scalars."
1420 },
1421 "namespace": {
1422 "type": "string",
1423 "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."
1424 }
1425 },
1426 "required": ["key", "props"]
1427 }
1428 },
1429 {
1430 "name": "find_similar",
1431 "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.",
1432 "inputSchema": {
1433 "type": "object",
1434 "properties": {
1435 "vector": {
1436 "type": "array",
1437 "items": { "type": "number" },
1438 "description": "Query embedding vector for vector-similarity search. When present, vector-search mode is used and `key` is ignored."
1439 },
1440 "field": { "type": "string", "description": "Property field holding the embedding vectors (default: embedding). Used in vector-search mode." },
1441 "label": { "type": "string", "description": "Restrict search to nodes with this label. Empty string means all labels. Used in vector-search mode." },
1442 "k": { "type": "integer", "description": "Maximum results to return in vector-search mode (default: 10)." },
1443 "min": { "type": "number", "description": "Minimum cosine similarity threshold in vector-search mode (default: 0.8)." },
1444 "mask": {
1445 "type": "array",
1446 "items": { "type": "string" },
1447 "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."
1448 },
1449 "where": {
1450 "type": "object",
1451 "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."
1452 },
1453 "exact": {
1454 "type": "boolean",
1455 "description": "When true, vector-search mode uses exact GEMM brute force and does not consult HNSW. Default false. Edge-traversal mode ignores this."
1456 },
1457 "key": { "type": "string", "description": "Source node key for edge-traversal mode." },
1458 "edge_type": { "type": "string", "description": "Edge type to filter by in edge-traversal mode (default: SIMILAR)." },
1459 "limit": { "type": "integer", "description": "Maximum neighbors to return in edge-traversal mode (default: 10)." }
1460 }
1461 }
1462 },
1463 {
1464 "name": "pairwise_similar",
1465 "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.",
1466 "inputSchema": {
1467 "type": "object",
1468 "properties": {
1469 "keys": {
1470 "type": "array",
1471 "items": { "type": "string" },
1472 "description": "Node keys to score against each other."
1473 },
1474 "field": { "type": "string", "description": "Property field holding the embedding vectors." },
1475 "k": { "type": "integer", "description": "Maximum neighbors per key (default: 10)." },
1476 "min": { "type": "number", "description": "Minimum cosine similarity threshold (default: 0.0)." }
1477 },
1478 "required": ["keys", "field"]
1479 }
1480 },
1481 {
1482 "name": "hybrid_search",
1483 "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.",
1484 "inputSchema": {
1485 "type": "object",
1486 "properties": {
1487 "query_text": { "type": "string", "description": "Fulltext query string." },
1488 "text_field": { "type": "string", "description": "Property field to search with fulltext." },
1489 "vector": {
1490 "type": "array",
1491 "items": { "type": "number" },
1492 "description": "Query embedding vector. Omit for text-only ranking."
1493 },
1494 "vector_field": { "type": "string", "description": "Property field holding embedding vectors (default: embedding)." },
1495 "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." },
1496 "k": { "type": "integer", "description": "Maximum results to return (default: 10)." }
1497 },
1498 "required": ["query_text", "text_field"]
1499 }
1500 },
1501 {
1502 "name": "node_history",
1503 "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.",
1504 "inputSchema": {
1505 "type": "object",
1506 "properties": {
1507 "key": { "type": "string", "description": "Node key to look up." }
1508 },
1509 "required": ["key"]
1510 }
1511 },
1512 {
1513 "name": "edge_history",
1514 "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.",
1515 "inputSchema": {
1516 "type": "object",
1517 "properties": {
1518 "a": { "type": "string", "minLength": 1, "description": "First node key." },
1519 "b": { "type": "string", "minLength": 1, "description": "Second node key." }
1520 },
1521 "required": ["a", "b"]
1522 }
1523 },
1524 {
1525 "name": "was_linked",
1526 "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`).",
1527 "inputSchema": {
1528 "type": "object",
1529 "properties": {
1530 "a": { "type": "string", "minLength": 1, "description": "First node key." },
1531 "b": { "type": "string", "minLength": 1, "description": "Second node key." },
1532 "edge_type": { "type": "string", "minLength": 1, "description": "Edge type to check." },
1533 "at_commit": { "type": "integer", "minimum": 0, "description": "0-based WAL commit index to query." }
1534 },
1535 "required": ["a", "b", "edge_type", "at_commit"]
1536 }
1537 },
1538 {
1539 "name": "rename_node",
1540 "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.",
1541 "inputSchema": {
1542 "type": "object",
1543 "properties": {
1544 "old_key": { "type": "string", "minLength": 1, "description": "Current node key." },
1545 "new_key": { "type": "string", "minLength": 1, "description": "Desired new node key." }
1546 },
1547 "required": ["old_key", "new_key"]
1548 }
1549 }
1550 ]) else {
1551 unreachable!("the literal above is an array")
1552 };
1553 tools
1554}
1555
1556fn tool_ok(payload: Js) -> Js {
1557 json!({
1558 "content": [{ "type": "text", "text": payload.to_string() }]
1559 })
1560}
1561
1562fn task_ok(text: &str) -> Js {
1570 json!({
1571 "content": [{ "type": "text", "text": text }]
1572 })
1573}
1574
1575fn tool_err(message: &str) -> Js {
1576 json!({
1577 "content": [{ "type": "text", "text": message }],
1578 "isError": true
1579 })
1580}
1581
1582fn write_result(writer: &mut impl Write, id: Option<Js>, result: Js) -> io::Result<()> {
1583 write_json(
1584 writer,
1585 &json!({
1586 "jsonrpc": "2.0",
1587 "id": id.unwrap_or(Js::Null),
1588 "result": result
1589 }),
1590 )
1591}
1592
1593fn write_error(
1594 writer: &mut impl Write,
1595 id: Option<Js>,
1596 code: i64,
1597 message: &str,
1598) -> io::Result<()> {
1599 write_json(
1600 writer,
1601 &json!({
1602 "jsonrpc": "2.0",
1603 "id": id.unwrap_or(Js::Null),
1604 "error": { "code": code, "message": message }
1605 }),
1606 )
1607}
1608
1609fn write_json(writer: &mut impl Write, value: &Js) -> io::Result<()> {
1610 let s = serde_json::to_string(value).map_err(io::Error::other)?;
1611 writeln!(writer, "{s}")?;
1612 writer.flush()
1613}
1614
1615#[cfg(test)]
1620mod tests {
1621 use super::*;
1622 use core_api::{AutoFk, IngestOptions, Predicate, RuleDef, Value};
1623 use std::path::PathBuf;
1624 use std::sync::atomic::{AtomicU64, Ordering};
1625
1626 fn tmp_dir() -> PathBuf {
1627 static SEQ: AtomicU64 = AtomicU64::new(0);
1628 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1629 let d = std::env::temp_dir().join(format!("mcp-test-{}-{}", std::process::id(), n));
1630 let _ = std::fs::remove_dir_all(&d);
1634 d
1635 }
1636
1637 fn demo_db() -> SharedDb {
1639 let db = SharedDb::open(&tmp_dir()).expect("open");
1640 {
1641 let mut g = db.write();
1642 let opts = IngestOptions {
1643 key_field: "id".into(),
1644 auto_fk: AutoFk::Off,
1645 };
1646 let people: Vec<BTreeMap<String, Value>> = vec![
1648 [
1649 ("id", Value::Str("alice".into())),
1650 ("name", Value::Str("Alice".into())),
1651 (
1652 "emb",
1653 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1654 ),
1655 ]
1656 .into_iter()
1657 .map(|(k, v)| (k.to_string(), v))
1658 .collect(),
1659 [
1660 ("id", Value::Str("bob".into())),
1661 ("name", Value::Str("Bob".into())),
1662 (
1663 "emb",
1664 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1665 ),
1666 ]
1667 .into_iter()
1668 .map(|(k, v)| (k.to_string(), v))
1669 .collect(),
1670 ];
1671 g.ingest("Person", people, &opts).expect("ingest");
1672
1673 g.create_rule(RuleDef {
1675 name: "sim_emb".into(),
1676 src_label: "Person".into(),
1677 dst_label: "Person".into(),
1678 predicate: Predicate::VectorSimilar {
1679 field: "emb".into(),
1680 min: 0.9,
1681 },
1682 edge_type: "SIMILAR".into(),
1683 weight_prop: Some("score".into()),
1684 max_edges: None,
1685 approximate: false,
1686 via_label: None,
1687 via_edge: None,
1688 via_dir: None,
1689 namespace: None,
1690 })
1691 .expect("rule");
1692 }
1693 db
1694 }
1695
1696 fn roundtrip(db: &SharedDb, request: &str) -> Js {
1697 roundtrip_with(db, false, request)
1698 }
1699
1700 fn roundtrip_with(db: &SharedDb, all_tools: bool, request: &str) -> Js {
1701 let input = format!("{request}\n");
1702 let mut output = Vec::new();
1703 run_mcp_stdio_with(db.clone(), None, all_tools, input.as_bytes(), &mut output)
1704 .expect("mcp");
1705 let s = std::str::from_utf8(&output).expect("utf8");
1706 serde_json::from_str(s.trim()).expect("json response")
1707 }
1708
1709 fn tool_call(db: &SharedDb, id: u64, tool: &str, args: Js) -> Js {
1710 let req = json!({
1711 "jsonrpc": "2.0",
1712 "id": id,
1713 "method": "tools/call",
1714 "params": { "name": tool, "arguments": args }
1715 });
1716 roundtrip(db, &req.to_string())
1717 }
1718
1719 fn tool_text(resp: &Js) -> Js {
1721 let text = resp["result"]["content"][0]["text"]
1722 .as_str()
1723 .expect("content[0].text");
1724 serde_json::from_str(text).expect("tool text is json")
1725 }
1726
1727 fn is_error(resp: &Js) -> bool {
1728 resp["result"]["isError"].as_bool().unwrap_or(false)
1729 }
1730
1731 fn tool_err_text(resp: &Js) -> String {
1732 resp["result"]["content"][0]["text"]
1733 .as_str()
1734 .unwrap_or("")
1735 .to_string()
1736 }
1737
1738 #[test]
1741 fn test_tools_list_includes_all_expected() {
1742 let db = demo_db();
1743 let resp = roundtrip_with(
1744 &db,
1745 true,
1746 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
1747 );
1748 let tools = resp["result"]["tools"].as_array().expect("tools array");
1749 let names: Vec<&str> = tools
1750 .iter()
1751 .map(|t| t["name"].as_str().expect("name"))
1752 .collect();
1753 for expected in &[
1754 "explore",
1756 "map",
1757 "context",
1758 "impact",
1759 "owners",
1760 "why",
1761 "explain_association",
1762 "node_edges",
1763 "neighborhood",
1764 "edges_at",
1765 "what_if",
1766 "recall",
1767 "remember",
1768 "sync",
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 28,
1790 "expected exactly 28 tools, got {}",
1791 names.len()
1792 );
1793 assert_eq!(
1794 &names[..14],
1795 [
1796 "explore",
1797 "map",
1798 "context",
1799 "impact",
1800 "owners",
1801 "why",
1802 "explain_association",
1803 "node_edges",
1804 "neighborhood",
1805 "edges_at",
1806 "what_if",
1807 "recall",
1808 "remember",
1809 "sync"
1810 ],
1811 "the task tools come first, in order"
1812 );
1813 assert_eq!(names[14], "query", "the graph tools follow them");
1814 }
1815
1816 #[test]
1820 fn tools_list_defaults_to_sixteen_on_a_memory_store() {
1821 let db = demo_db();
1822 let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1823 let names: Vec<&str> = resp["result"]["tools"]
1824 .as_array()
1825 .expect("tools array")
1826 .iter()
1827 .map(|t| t["name"].as_str().expect("name"))
1828 .collect();
1829 assert_eq!(names, ASSOCIATION_TOOLS.to_vec());
1830 }
1831
1832 #[test]
1835 fn association_listing_includes_pairwise_similar_after_find_similar() {
1836 let db = demo_db();
1837 let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1838 let names: Vec<&str> = resp["result"]["tools"]
1839 .as_array()
1840 .expect("tools array")
1841 .iter()
1842 .map(|t| t["name"].as_str().expect("name"))
1843 .collect();
1844 const EXPECTED: [&str; 16] = [
1845 "query",
1846 "explain_association",
1847 "neighborhood",
1848 "node_info",
1849 "node_edges",
1850 "was_linked",
1851 "edges_at",
1852 "what_if",
1853 "node_history",
1854 "edge_history",
1855 "find_similar",
1856 "pairwise_similar",
1857 "hybrid_search",
1858 "remember",
1859 "recall",
1860 "stats",
1861 ];
1862 assert_eq!(names, EXPECTED.to_vec());
1863 assert_eq!(ASSOCIATION_TOOLS.as_slice(), EXPECTED.as_slice());
1864 let find = names
1865 .iter()
1866 .position(|&n| n == "find_similar")
1867 .expect("find_similar listed");
1868 assert_eq!(names[find + 1], "pairwise_similar");
1869 }
1870
1871 #[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 for code_only in [
1886 "explore", "map", "context", "impact", "owners", "why", "sync",
1887 ] {
1888 assert!(
1889 !ASSOCIATION_TOOLS.contains(&code_only),
1890 "{code_only} reads a code graph and must not be listed on a memory store"
1891 );
1892 }
1893 let served: Vec<String> = crate::mcp_tasks::task_tools()
1894 .iter()
1895 .chain(graph_tools().iter())
1896 .filter_map(|t| t.get("name").and_then(Js::as_str))
1897 .map(str::to_string)
1898 .collect();
1899 for name in ASSOCIATION_TOOLS {
1900 assert!(
1901 served.iter().any(|s| s == name),
1902 "{name} is listed but not served"
1903 );
1904 }
1905 assert!(
1906 CODE_GRAPH_TOOLS.contains(&"explore"),
1907 "and `explore` is the task tool the other surface lists"
1908 );
1909 }
1910
1911 #[test]
1914 fn tools_list_is_three_tools_on_a_code_graph_store() {
1915 let db = demo_db();
1916 db.write()
1917 .insert_node(
1918 "GitSync",
1919 crate::mcp_tasks::SYNC_KEY,
1920 vec![("id".into(), Value::Str(crate::mcp_tasks::SYNC_KEY.into()))],
1921 )
1922 .expect("marker");
1923 let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1924 let names: Vec<&str> = resp["result"]["tools"]
1925 .as_array()
1926 .expect("tools array")
1927 .iter()
1928 .map(|t| t["name"].as_str().expect("name"))
1929 .collect();
1930 assert_eq!(names, ["explore", "query", "stats"]);
1931 }
1932
1933 #[test]
1934 fn test_stats_returns_node_count() {
1935 let db = demo_db();
1936 let resp = tool_call(&db, 1, "stats", json!({}));
1937 assert!(!is_error(&resp));
1938 let result = tool_text(&resp);
1939 assert_eq!(result["nodes_live"], 2);
1940 }
1941
1942 #[test]
1947 fn mcp_stats_unscoped_omits_namespace_roster() {
1948 let db = SharedDb::open(&tmp_dir()).expect("open");
1949 {
1950 let mut g = db.write();
1951 g.insert_node(
1952 "Doc",
1953 "a",
1954 vec![("ns".into(), Value::Str("tenant-a".into()))],
1955 )
1956 .expect("insert a");
1957 g.insert_node(
1958 "Doc",
1959 "b",
1960 vec![("ns".into(), Value::Str("tenant-b".into()))],
1961 )
1962 .expect("insert b");
1963 }
1964
1965 let unscoped = tool_text(&tool_call(&db, 1, "stats", json!({})));
1966 assert!(
1967 unscoped.get("namespaces").is_none(),
1968 "unscoped stats must omit the roster entirely, not send an empty \
1969 array: {unscoped}"
1970 );
1971 assert_eq!(
1972 unscoped["nodes_live"], 2,
1973 "the store-wide counts beside the roster are unchanged"
1974 );
1975
1976 let scoped = tool_text(&tool_call(
1977 &db,
1978 2,
1979 "stats",
1980 json!({"namespace": "tenant-a"}),
1981 ));
1982 let names: Vec<&str> = scoped["namespaces"]
1983 .as_array()
1984 .expect("a scoped call still carries the roster it may see")
1985 .iter()
1986 .map(|n| n["name"].as_str().expect("name"))
1987 .collect();
1988 assert_eq!(
1989 names,
1990 ["tenant-a"],
1991 "a call that names a namespace sees that one and no other"
1992 );
1993 }
1994
1995 #[test]
1999 fn create_rule_reports_a_build_it_could_not_finish() {
2000 let db = SharedDb::open(&tmp_dir()).expect("open");
2001 {
2002 let mut g = db.write();
2003 for i in 0..300usize {
2004 const D: usize = 32;
2005 let axis = (i / 10) % D;
2006 let mut xs = vec![0.0f64; D];
2007 xs[axis] = 1.0;
2008 xs[(axis + 1) % D] = (i % 10) as f64 * 0.001;
2009 g.insert_node(
2010 "V",
2011 &format!("v{i}"),
2012 vec![(
2013 "emb".into(),
2014 Value::List(xs.into_iter().map(Value::Float).collect()),
2015 )],
2016 )
2017 .expect("insert");
2018 }
2019 g.set_hnsw_build_batch(Some(64));
2020 }
2021 let args = json!({
2022 "name": "sim",
2023 "src_label": "V",
2024 "dst_label": "V",
2025 "predicate": {"VectorSimilar": {"field": "emb", "min": 0.9}},
2026 "edge_type": "SIM",
2027 "weight_prop": null,
2028 "max_edges": null,
2029 "approximate": true
2030 });
2031 let resp = tool_call(&db, 1, "create_rule", args);
2032 assert!(!is_error(&resp), "{resp}");
2033 let result = tool_text(&resp);
2034 assert_eq!(result["name"], json!("sim"));
2035 assert_eq!(result["building"], json!({"indexed": 64, "total": 300}));
2036 let note = result["note"].as_str().expect("a note explaining the wait");
2037 assert!(
2038 note.contains("derives no edges until it finishes") && note.contains("build-index"),
2039 "the note must say the edges are not there yet and how to finish: {note}"
2040 );
2041
2042 let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2044 let rule = stats["rules"]
2045 .as_array()
2046 .expect("rules")
2047 .iter()
2048 .find(|r| r["name"] == "sim")
2049 .expect("the rule is installed while it builds");
2050 assert_eq!(rule["edges"], json!(0));
2051 assert_eq!(
2052 rule["building"],
2053 json!({"rule": "sim", "indexed": 64, "total": 300})
2054 );
2055
2056 while !db.write().pump_index_build().expect("pump").is_empty() {}
2058 let stats = tool_text(&tool_call(&db, 3, "stats", json!({})));
2059 let rule = stats["rules"]
2060 .as_array()
2061 .expect("rules")
2062 .iter()
2063 .find(|r| r["name"] == "sim")
2064 .expect("rule");
2065 assert!(rule.get("building").is_none(), "{rule}");
2066 assert!(rule["edges"].as_u64().expect("edges") > 0);
2067 }
2068
2069 #[test]
2070 fn test_query_runs_cypher() {
2071 let db = demo_db();
2072 let resp = tool_call(
2073 &db,
2074 1,
2075 "query",
2076 json!({ "cypher": "MATCH (n:Person) RETURN n.name ORDER BY n.name" }),
2077 );
2078 assert!(!is_error(&resp));
2079 let result = tool_text(&resp);
2080 assert_eq!(result["columns"], json!(["n.name"]));
2082 assert_eq!(result["rows"].as_array().map(|r| r.len()), Some(2));
2083 }
2084
2085 #[test]
2086 fn test_query_create_is_a_write() {
2087 let db = SharedDb::open(&tmp_dir()).expect("open");
2088 let resp = tool_call(
2089 &db,
2090 1,
2091 "query",
2092 json!({ "cypher": "CREATE (n:L {id: 'k'}) RETURN n" }),
2093 );
2094 assert!(
2095 !is_error(&resp),
2096 "CREATE via MCP query must succeed: {resp}"
2097 );
2098 let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2099 assert_eq!(stats["nodes_live"], 1);
2100 }
2101
2102 #[test]
2103 fn test_ingest_json_inserts_nodes() {
2104 let db = demo_db();
2105 let resp = tool_call(
2106 &db,
2107 1,
2108 "ingest_json",
2109 json!({
2110 "label": "Person",
2111 "rows_json": r#"[{"id":"carol","name":"Carol"}]"#,
2112 "key_field": "id"
2113 }),
2114 );
2115 assert!(!is_error(&resp));
2116 let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2118 assert_eq!(stats["nodes_live"], 3);
2119 }
2120
2121 #[test]
2122 fn test_node_info_returns_props() {
2123 let db = demo_db();
2124 let resp = tool_call(&db, 1, "node_info", json!({ "key": "alice" }));
2125 assert!(!is_error(&resp));
2126 let result = tool_text(&resp);
2127 assert_eq!(result["key"], "alice");
2128 assert_eq!(result["label"], "Person");
2129 assert_eq!(result["props"]["name"], "Alice");
2130 }
2131
2132 #[test]
2135 fn test_node_edges_returns_edges() {
2136 let db = demo_db();
2137 let resp = tool_call(
2138 &db,
2139 1,
2140 "node_edges",
2141 json!({ "key": "alice", "json": true }),
2142 );
2143 assert!(!is_error(&resp));
2144 let result = tool_text(&resp);
2145 assert_eq!(result["key"], "alice");
2146 let types = result["types"].as_array().expect("types");
2147 assert!(
2148 !types.is_empty(),
2149 "alice should have at least one edge type"
2150 );
2151 let similar = types
2152 .iter()
2153 .find(|t| t["edge_type"] == "SIMILAR")
2154 .expect("the rule's edge type");
2155 assert_eq!(similar["count"], json!(2));
2158 let edges = similar["edges"].as_array().expect("edges");
2159 let dirs: Vec<&str> = edges
2160 .iter()
2161 .map(|e| e["direction"].as_str().expect("direction"))
2162 .collect();
2163 assert!(dirs.contains(&"out") && dirs.contains(&"in"), "{similar}");
2164 for edge in edges {
2165 assert_eq!(edge["other"], json!("bob"));
2166 assert_eq!(edge["derived"], json!(true));
2167 assert_eq!(edge["rule"], json!("sim_emb"));
2168 assert_eq!(edge["score"], json!(1.0));
2169 assert!(
2170 edge["predicate"]
2171 .as_str()
2172 .unwrap_or("")
2173 .contains("vector_similar"),
2174 "the predicate travels with the edge: {edge}"
2175 );
2176 }
2177 }
2178
2179 #[test]
2182 fn test_neighborhood_traverses_one_hop() {
2183 let db = demo_db();
2184 let resp = tool_call(
2185 &db,
2186 1,
2187 "neighborhood",
2188 json!({ "key": "alice", "depth": 1, "json": true }),
2189 );
2190 assert!(!is_error(&resp));
2191 let result = tool_text(&resp);
2192 assert_eq!(result["key"], "alice");
2193 assert!(result["types"].as_array().is_some(), "{result}");
2194
2195 let deep = tool_call(
2196 &db,
2197 2,
2198 "neighborhood",
2199 json!({ "key": "alice", "depth": 2 }),
2200 );
2201 assert!(!is_error(&deep));
2202 let table = tool_text(&deep);
2203 assert_eq!(table["columns"], json!(["key", "label", "depth"]));
2204 assert!(table["rows"].as_array().is_some());
2205 }
2206
2207 #[test]
2208 fn test_explain_returns_rule_info() {
2209 let db = demo_db();
2210 let resp = tool_call(&db, 1, "explain", json!({ "a": "alice", "b": "bob" }));
2211 assert!(!is_error(&resp));
2212 let result = tool_text(&resp);
2213 let arr = result.as_array().expect("explain returns array");
2214 assert!(!arr.is_empty(), "expected at least one explanation");
2215 assert_eq!(arr[0]["rule"], "sim_emb");
2216 }
2217
2218 #[test]
2219 fn test_create_rule_backfills() {
2220 let db = SharedDb::open(&tmp_dir()).expect("open");
2221 {
2222 let mut g = db.write();
2223 let opts = IngestOptions {
2224 key_field: "id".into(),
2225 auto_fk: AutoFk::Off,
2226 };
2227 let rows: Vec<BTreeMap<String, Value>> = vec![
2228 [
2229 ("id", Value::Str("x".into())),
2230 ("tag", Value::Str("a".into())),
2231 ]
2232 .into_iter()
2233 .map(|(k, v)| (k.to_string(), v))
2234 .collect(),
2235 [
2236 ("id", Value::Str("y".into())),
2237 ("tag", Value::Str("a".into())),
2238 ]
2239 .into_iter()
2240 .map(|(k, v)| (k.to_string(), v))
2241 .collect(),
2242 ];
2243 g.ingest("Item", rows, &opts).expect("ingest");
2244 }
2245 let resp = tool_call(
2246 &db,
2247 1,
2248 "create_rule",
2249 json!({
2250 "name": "same_tag",
2251 "src_label": "Item",
2252 "dst_label": "Item",
2253 "predicate": { "FieldEqual": { "field": "tag" } },
2254 "edge_type": "SAME_TAG"
2255 }),
2256 );
2257 assert!(!is_error(&resp));
2258 let result = tool_text(&resp);
2259 assert_eq!(result["ok"], true);
2260 let edges_resp = tool_call(&db, 2, "node_edges", json!({ "key": "x", "json": true }));
2262 let edges_result = tool_text(&edges_resp);
2263 let types = edges_result["types"].as_array().expect("types");
2264 assert!(
2265 types.iter().any(|t| t["edge_type"] == "SAME_TAG"),
2266 "SAME_TAG edge not found after create_rule"
2267 );
2268 }
2269
2270 #[test]
2273 fn test_upsert_entity_creates_new_node() {
2274 let db = demo_db();
2275 let resp = tool_call(
2276 &db,
2277 1,
2278 "upsert_entity",
2279 json!({
2280 "key": "carol",
2281 "label": "Person",
2282 "props": { "name": "Carol", "age": 30 }
2283 }),
2284 );
2285 assert!(!is_error(&resp));
2286 let result = tool_text(&resp);
2287 assert_eq!(result["ok"], true);
2288 assert_eq!(result["created"], true);
2289 assert_eq!(result["key"], "carol");
2290 let info = tool_text(&tool_call(&db, 2, "node_info", json!({ "key": "carol" })));
2292 assert_eq!(info["props"]["name"], "Carol");
2293 }
2294
2295 #[test]
2296 fn test_upsert_entity_updates_existing_node() {
2297 let db = demo_db();
2298 let resp = tool_call(
2299 &db,
2300 1,
2301 "upsert_entity",
2302 json!({
2303 "key": "alice",
2304 "props": { "name": "Alice Updated" }
2305 }),
2306 );
2307 assert!(!is_error(&resp));
2308 let result = tool_text(&resp);
2309 assert_eq!(result["ok"], true);
2310 assert_eq!(result["created"], false);
2311 assert_eq!(result["updated_fields"], 1);
2312 let info = tool_text(&tool_call(&db, 2, "node_info", json!({ "key": "alice" })));
2314 assert_eq!(info["props"]["name"], "Alice Updated");
2315 }
2316
2317 #[test]
2318 fn test_upsert_entity_missing_label_on_create_is_error() {
2319 let db = demo_db();
2320 let resp = tool_call(
2321 &db,
2322 1,
2323 "upsert_entity",
2324 json!({ "key": "new-node", "props": { "x": 1 } }),
2325 );
2326 assert!(is_error(&resp), "should error without label for new node");
2327 }
2328
2329 #[test]
2330 fn test_pairwise_similar_excludes_self() {
2331 let db = demo_db();
2332 let resp = tool_call(
2333 &db,
2334 1,
2335 "pairwise_similar",
2336 json!({
2337 "keys": ["alice", "bob"],
2338 "field": "emb",
2339 "k": 10,
2340 "min": 0.0
2341 }),
2342 );
2343 assert!(
2344 !is_error(&resp),
2345 "pairwise_similar must not error: {resp:?}"
2346 );
2347 let result = tool_text(&resp);
2348 let results = result["results"].as_array().expect("results");
2349 assert_eq!(results.len(), 2);
2350 for row in results {
2351 let key = row["key"].as_str().expect("key");
2352 let neighbors = row["neighbors"].as_array().expect("neighbors");
2353 assert!(
2354 neighbors.iter().all(|n| n["key"].as_str() != Some(key)),
2355 "self must be excluded: {row}"
2356 );
2357 assert!(!neighbors.is_empty(), "alice/bob are identical: {row}");
2358 }
2359 }
2360
2361 #[test]
2362 fn test_find_similar_returns_similar_edges() {
2363 let db = demo_db();
2364 let resp = tool_call(
2365 &db,
2366 1,
2367 "find_similar",
2368 json!({ "key": "alice", "edge_type": "SIMILAR" }),
2369 );
2370 assert!(!is_error(&resp));
2371 let result = tool_text(&resp);
2372 assert_eq!(result["key"], "alice");
2373 assert_eq!(result["edge_type"], "SIMILAR");
2374 let similar = result["similar"].as_array().expect("similar array");
2375 assert!(!similar.is_empty(), "expected SIMILAR neighbors for alice");
2376 assert_eq!(similar[0]["neighbor_key"], "bob");
2377 }
2378
2379 #[test]
2380 fn test_find_similar_limit_respected() {
2381 let db = demo_db();
2382 let resp = tool_call(
2383 &db,
2384 1,
2385 "find_similar",
2386 json!({ "key": "alice", "edge_type": "SIMILAR", "limit": 0 }),
2387 );
2388 assert!(!is_error(&resp));
2389 let result = tool_text(&resp);
2390 let similar = result["similar"].as_array().expect("similar array");
2391 assert_eq!(similar.len(), 0);
2392 }
2393
2394 #[test]
2398 fn test_find_similar_vector_default_min_is_0_8() {
2399 let db = SharedDb::open(&tmp_dir()).expect("open");
2400 {
2401 let mut g = db.write();
2402 g.insert_node(
2404 "Item",
2405 "close",
2406 vec![(
2407 "emb".into(),
2408 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2409 )],
2410 )
2411 .unwrap();
2412 g.insert_node(
2414 "Item",
2415 "far",
2416 vec![(
2417 "emb".into(),
2418 Value::List(vec![Value::Float(0.0), Value::Float(1.0)]),
2419 )],
2420 )
2421 .unwrap();
2422 }
2423
2424 let resp = tool_call(
2426 &db,
2427 1,
2428 "find_similar",
2429 json!({
2430 "vector": [1.0, 0.0],
2431 "field": "emb",
2432 "label": "Item",
2433 "k": 10
2434 }),
2435 );
2436 assert!(!is_error(&resp), "vector search must not error");
2437 let result = tool_text(&resp);
2438 let results = result["results"].as_array().expect("results array");
2439
2440 let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2441 assert!(
2442 keys.contains(&"close"),
2443 "close node (sim=1.0) must be included"
2444 );
2445 assert!(
2446 !keys.contains(&"far"),
2447 "far node (sim=0.0) must be excluded by default min=0.8"
2448 );
2449 }
2450
2451 #[test]
2453 fn test_find_similar_vector_mask_excludes_hidden() {
2454 let db = SharedDb::open(&tmp_dir()).expect("open");
2455 {
2456 let mut g = db.write();
2457 g.insert_node(
2459 "Item",
2460 "visible",
2461 vec![(
2462 "emb".into(),
2463 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2464 )],
2465 )
2466 .unwrap();
2467 g.insert_node(
2469 "Item",
2470 "hidden",
2471 vec![(
2472 "emb".into(),
2473 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2474 )],
2475 )
2476 .unwrap();
2477 }
2478
2479 let resp = tool_call(
2480 &db,
2481 1,
2482 "find_similar",
2483 json!({
2484 "vector": [1.0, 0.0],
2485 "field": "emb",
2486 "label": "Item",
2487 "k": 10,
2488 "min": 0.0,
2489 "mask": ["visible"]
2490 }),
2491 );
2492 assert!(!is_error(&resp), "masked vector search must not error");
2493 let result = tool_text(&resp);
2494 let results = result["results"].as_array().expect("results array");
2495
2496 let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2497 assert!(
2498 keys.contains(&"visible"),
2499 "visible node must appear in masked results"
2500 );
2501 assert!(
2502 !keys.contains(&"hidden"),
2503 "hidden node must be excluded by mask"
2504 );
2505 }
2506
2507 #[test]
2509 fn test_find_similar_vector_mask_bad_type_is_error() {
2510 let db = SharedDb::open(&tmp_dir()).expect("open");
2511 let resp = tool_call(
2512 &db,
2513 1,
2514 "find_similar",
2515 json!({
2516 "vector": [1.0, 0.0],
2517 "field": "emb",
2518 "k": 5,
2519 "mask": [42]
2520 }),
2521 );
2522 assert!(
2523 is_error(&resp),
2524 "non-string mask element must produce a tool error"
2525 );
2526 }
2527
2528 #[test]
2530 fn test_find_similar_vector_where_eq() {
2531 let db = SharedDb::open(&tmp_dir()).expect("open");
2532 {
2533 let mut g = db.write();
2534 g.insert_node(
2535 "Document",
2536 "in-scope",
2537 vec![
2538 (
2539 "emb".into(),
2540 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2541 ),
2542 ("resource_scope_id".into(), Value::Str("a".into())),
2543 ],
2544 )
2545 .unwrap();
2546 g.insert_node(
2547 "Document",
2548 "out-scope",
2549 vec![
2550 (
2551 "emb".into(),
2552 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2553 ),
2554 ("resource_scope_id".into(), Value::Str("b".into())),
2555 ],
2556 )
2557 .unwrap();
2558 }
2559 let resp = tool_call(
2560 &db,
2561 1,
2562 "find_similar",
2563 json!({
2564 "vector": [1.0, 0.0],
2565 "field": "emb",
2566 "label": "Document",
2567 "k": 10,
2568 "min": 0.0,
2569 "where": { "field": "resource_scope_id", "eq": "a" }
2570 }),
2571 );
2572 assert!(!is_error(&resp), "where eq must not error: {resp:?}");
2573 let result = tool_text(&resp);
2574 let keys: Vec<&str> = result["results"]
2575 .as_array()
2576 .expect("results")
2577 .iter()
2578 .filter_map(|r| r["key"].as_str())
2579 .collect();
2580 assert_eq!(keys, vec!["in-scope"]);
2581 }
2582
2583 #[test]
2584 fn test_find_similar_vector_where_invalid_is_error() {
2585 let db = SharedDb::open(&tmp_dir()).expect("open");
2586 let resp = tool_call(
2587 &db,
2588 1,
2589 "find_similar",
2590 json!({
2591 "vector": [1.0, 0.0],
2592 "field": "emb",
2593 "where": { "field": "resource_scope_id", "eq": "a", "in": ["b"] }
2594 }),
2595 );
2596 assert!(is_error(&resp), "invalid where must be a tool error");
2597 let msg = format!("{resp:?}");
2598 assert!(
2599 msg.contains("where"),
2600 "tool error must name where, got {msg}"
2601 );
2602 }
2603
2604 #[test]
2606 fn test_find_similar_edge_ignores_where_and_exact() {
2607 let db = demo_db();
2608 let resp = tool_call(
2609 &db,
2610 1,
2611 "find_similar",
2612 json!({
2613 "key": "alice",
2614 "edge_type": "SIMILAR",
2615 "where": { "field": "x", "eq": "y", "in": ["z"] },
2616 "exact": true
2617 }),
2618 );
2619 assert!(
2620 !is_error(&resp),
2621 "edge mode must ignore invalid where: {resp:?}"
2622 );
2623 }
2624
2625 #[test]
2627 fn test_find_similar_edge_mask_excludes_hidden_neighbor() {
2628 let db = SharedDb::open(&tmp_dir()).expect("open");
2629 {
2630 let mut g = db.write();
2631 g.insert_node("P", "alice", vec![]).unwrap();
2632 g.insert_node("P", "bob", vec![]).unwrap(); g.insert_node("P", "carol", vec![]).unwrap(); g.insert_edge("KNOWS", "alice", "bob").unwrap();
2635 g.insert_edge("KNOWS", "alice", "carol").unwrap();
2636 }
2637 let resp = tool_call(
2639 &db,
2640 1,
2641 "find_similar",
2642 json!({
2643 "key": "alice",
2644 "edge_type": "KNOWS",
2645 "mask": ["alice", "bob"]
2646 }),
2647 );
2648 assert!(!is_error(&resp), "masked edge search must not error");
2649 let result = tool_text(&resp);
2650 let similar = result["similar"].as_array().expect("similar array");
2651 let neighbors: Vec<&str> = similar
2652 .iter()
2653 .filter_map(|e| e["neighbor_key"].as_str())
2654 .collect();
2655 assert!(neighbors.contains(&"bob"), "bob (visible) must appear");
2656 assert!(
2657 !neighbors.contains(&"carol"),
2658 "carol (hidden) must be excluded"
2659 );
2660 }
2661
2662 #[test]
2665 fn test_find_similar_edge_mask_hidden_key_is_not_found() {
2666 let db = SharedDb::open(&tmp_dir()).expect("open");
2667 {
2668 let mut g = db.write();
2669 g.insert_node("P", "alice", vec![]).unwrap();
2670 g.insert_node("P", "bob", vec![]).unwrap();
2671 }
2672 let resp_masked = tool_call(
2674 &db,
2675 1,
2676 "find_similar",
2677 json!({ "key": "alice", "edge_type": "KNOWS", "mask": ["bob"] }),
2678 );
2679 let resp_ghost = tool_call(
2681 &db,
2682 2,
2683 "find_similar",
2684 json!({ "key": "ghost", "edge_type": "KNOWS" }),
2685 );
2686 assert!(
2687 is_error(&resp_masked),
2688 "hidden query key must produce a tool error"
2689 );
2690 assert!(
2691 is_error(&resp_ghost),
2692 "nonexistent key must produce a tool error"
2693 );
2694 assert_eq!(
2696 tool_err_text(&resp_masked).contains("alice"),
2697 tool_err_text(&resp_ghost).contains("ghost"),
2698 "error messages should follow same not-found template"
2699 );
2700 }
2701
2702 #[test]
2707 fn test_explain_association_same_as_explain() {
2708 let db = demo_db();
2709 let explain = tool_text(&tool_call(
2710 &db,
2711 1,
2712 "explain",
2713 json!({ "a": "alice", "b": "bob" }),
2714 ));
2715 let assoc = tool_text(&tool_call(
2716 &db,
2717 2,
2718 "explain_association",
2719 json!({ "a": "alice", "b": "bob", "json": true }),
2720 ));
2721 let explain: Vec<Js> = serde_json::from_value(explain).expect("explain array");
2722 let mut assoc: Vec<Js> = serde_json::from_value(assoc).expect("assoc array");
2723 for row in &mut assoc {
2724 let ev = row
2725 .as_object_mut()
2726 .expect("object")
2727 .remove("evidence")
2728 .expect("every derived edge carries its evidence");
2729 assert!(
2730 ev["similarity"].is_number(),
2731 "a vector_similar edge reports the cosine it scored: {ev}"
2732 );
2733 }
2734 assert_eq!(explain, assoc, "evidence is the only addition");
2735
2736 let prose = tool_call(
2737 &db,
2738 3,
2739 "explain_association",
2740 json!({ "a": "alice", "b": "bob" }),
2741 );
2742 let text = prose["result"]["content"][0]["text"]
2743 .as_str()
2744 .expect("text content");
2745 assert!(
2746 text.contains("mushroomdb explain — alice ↔ bob:"),
2747 "the default reply is the digest: {text}"
2748 );
2749 }
2750
2751 #[test]
2756 fn test_edge_history_returns_derived_lifecycle_with_rule() {
2757 let db = demo_db(); let resp = tool_call(&db, 1, "edge_history", json!({ "a": "alice", "b": "bob" }));
2759 assert!(!is_error(&resp), "edge_history must not error: {resp}");
2760 let result = tool_text(&resp);
2761
2762 let total = result["total_commits"].as_u64().expect("total_commits");
2764 assert!(total > 0, "total_commits must be > 0 after ingest + rule");
2765
2766 let events = result["events"].as_array().expect("events array");
2768 assert!(!events.is_empty(), "expected at least one edge event");
2769
2770 let derived_added = events
2772 .iter()
2773 .any(|ev| ev["event"].as_str() == Some("Added") && !ev["rule"].is_null());
2774 assert!(
2775 derived_added,
2776 "expected a derived Added event with rule attribution: {events:?}"
2777 );
2778 }
2779
2780 #[test]
2783 fn test_was_linked_at_valid_commit() {
2784 let db = SharedDb::open(&tmp_dir()).expect("open");
2785 {
2786 let mut g = db.write();
2787 let opts = IngestOptions {
2788 key_field: "id".into(),
2789 auto_fk: AutoFk::Off,
2790 };
2791 let rows: Vec<BTreeMap<String, Value>> = vec![
2792 [("id", Value::Str("x".into()))]
2793 .into_iter()
2794 .map(|(k, v)| (k.to_string(), v))
2795 .collect(),
2796 [("id", Value::Str("y".into()))]
2797 .into_iter()
2798 .map(|(k, v)| (k.to_string(), v))
2799 .collect(),
2800 ];
2801 g.ingest("N", rows, &opts).expect("ingest");
2802 g.insert_edge("LINK", "x", "y").expect("edge");
2803 }
2804 let g = db.read();
2806 let total = g.wal_total_commits().expect("wal_total_commits");
2807 drop(g);
2808
2809 let resp = tool_call(
2810 &db,
2811 1,
2812 "was_linked",
2813 json!({ "a": "x", "b": "y", "edge_type": "LINK", "at_commit": total - 1 }),
2814 );
2815 assert!(!is_error(&resp), "was_linked must not error: {resp}");
2816 let result = tool_text(&resp);
2817 assert_eq!(result["linked"], true);
2818 assert_eq!(result["a"], "x");
2819 assert_eq!(result["edge_type"], "LINK");
2820 }
2821
2822 #[test]
2825 fn test_was_linked_out_of_horizon_returns_tool_error() {
2826 let db = SharedDb::open(&tmp_dir()).expect("open");
2827 {
2828 let mut g = db.write();
2829 g.insert_node("N", "a", vec![]).expect("node a");
2830 g.insert_node("N", "b", vec![]).expect("node b");
2831 }
2832 let resp = tool_call(
2834 &db,
2835 1,
2836 "was_linked",
2837 json!({ "a": "a", "b": "b", "edge_type": "X", "at_commit": 999 }),
2838 );
2839 assert!(
2841 is_error(&resp),
2842 "out-of-range commit must be a tool error: {resp}"
2843 );
2844 let text = resp["result"]["content"][0]["text"].as_str().expect("text");
2845 assert!(
2846 text.contains("out of range") || text.contains("range"),
2847 "error must mention range: {text}"
2848 );
2849 }
2850
2851 #[test]
2854 fn test_node_history_via_mcp() {
2855 let db = demo_db(); let resp = tool_call(&db, 1, "node_history", json!({ "key": "alice" }));
2857 assert!(!is_error(&resp), "node_history must not error: {resp}");
2858 let result = tool_text(&resp);
2859
2860 assert_eq!(result["key"], "alice");
2861 let total = result["total_commits"].as_u64().expect("total_commits");
2862 assert!(total > 0, "total_commits must be > 0");
2863
2864 let history = result["history"].as_array().expect("history array");
2865 assert!(
2866 !history.is_empty(),
2867 "alice should have at least one history entry"
2868 );
2869
2870 let first_change = &history[0]["change"];
2872 assert_eq!(first_change["type"], "NodeInserted");
2873 assert_eq!(first_change["label"], "Person");
2874 }
2875}