1use crate::json::{
52 edge_history_result_json, node_history_json, node_info_json, params_from_json,
53 parse_ingest_edges, result_set_json, rule_def_from_json,
54};
55use core_api::{
56 json_to_rows, json_to_value, AsOfScope, AutoFk, GraphError, IngestOptions, MaskMode, NodeMask,
57 SharedDb, Value,
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),
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 "hybrid_search" => tool_hybrid_search(db, args),
231 "node_history" => tool_node_history(db, args),
232 "edge_history" => tool_edge_history(db, args),
233 "was_linked" => tool_was_linked(db, args),
234 "rename_node" => tool_rename_node(db, args),
235 _ => protocol_invalid(),
236 }
237}
238
239fn protocol_invalid() -> CallOutcome {
240 CallOutcome::Protocol {
241 code: -32602,
242 message: "Invalid params".into(),
243 }
244}
245
246fn tool_query(db: &SharedDb, args: &Js) -> CallOutcome {
247 let Some(cypher) = args.get("cypher").and_then(Js::as_str) else {
248 return CallOutcome::ToolErr("missing cypher".into());
249 };
250 let params = match params_from_json(args.get("params")) {
251 Ok(p) => p,
252 Err(e) => return CallOutcome::ToolErr(e),
253 };
254
255 let role = match args.get("role") {
261 None | Some(Js::Null) => None,
262 Some(Js::String(s)) if !s.is_empty() => Some(s.as_str()),
263 Some(_) => return CallOutcome::ToolErr("role must be a non-empty string".into()),
264 };
265 let mask_keys = match args.get("mask") {
266 None => None,
267 Some(v) => match mask_key_list(v) {
268 Ok(keys) => Some(keys),
269 Err(e) => return CallOutcome::ToolErr(e),
270 },
271 };
272 if role.is_some() && mask_keys.is_some() {
273 return CallOutcome::ToolErr("pass role or mask, not both".into());
274 }
275
276 let as_of = match args.get("as_of") {
280 None | Some(Js::Null) => None,
281 Some(v) => match v.as_u64() {
282 Some(n) => Some(n),
283 None => {
284 return CallOutcome::ToolErr(
285 "as_of must be a non-negative integer commit index".into(),
286 )
287 }
288 },
289 };
290
291 if let Some(commit) = as_of {
292 if args
295 .get("stub_hidden")
296 .and_then(|v| v.as_bool())
297 .unwrap_or(false)
298 {
299 return CallOutcome::ToolErr(
300 "as_of (time-travel) does not compose with stub_hidden".into(),
301 );
302 }
303 let scope = match (role, &mask_keys) {
304 (Some(role), _) => AsOfScope::Role(role),
305 (None, Some(keys)) => AsOfScope::Keys(keys),
306 (None, None) => {
307 return match db.read().query_at(commit, cypher, ¶ms) {
308 Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
309 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
310 }
311 }
312 };
313 return match db.read().query_at_scoped(commit, cypher, ¶ms, scope) {
314 Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
315 Err(GraphError::KeyNotFound { key }) if key.starts_with("role:") => {
316 CallOutcome::ToolErr(format!("unknown role '{}'", &key["role:".len()..]))
317 }
318 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
319 };
320 }
321
322 if role.is_some() || mask_keys.is_some() {
323 let stub_hidden = args
324 .get("stub_hidden")
325 .and_then(|v| v.as_bool())
326 .unwrap_or(false);
327 let g = db.read();
328 let mask = match (role, &mask_keys) {
329 (Some(role), _) => match g.mask_for_role(role) {
330 Ok(m) => m,
331 Err(GraphError::KeyNotFound { .. }) => {
334 return CallOutcome::ToolErr(format!("unknown role '{role}'"))
335 }
336 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
337 },
338 (None, Some(keys)) => NodeMask::from_keys(&*g, keys.iter().map(String::as_str)),
339 (None, None) => unreachable!("one of the two is Some in this branch"),
340 };
341 let mask = if stub_hidden {
342 mask.with_mode(MaskMode::Stub)
343 } else {
344 mask
345 };
346 return match g.query_masked(cypher, ¶ms, &mask) {
347 Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
348 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
349 };
350 }
351
352 let is_write = match core_api::is_write_query(cypher) {
353 Ok(b) => b,
354 Err(e) => return CallOutcome::ToolErr(e),
355 };
356 let rs = if is_write {
357 let mut g = db.write();
358 g.query_write(cypher, ¶ms)
359 } else {
360 let g = db.read();
361 g.query(cypher, ¶ms)
362 };
363 match rs {
364 Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
365 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
366 }
367}
368
369fn mask_key_list(mask: &Js) -> Result<Vec<String>, String> {
372 let arr = mask
373 .as_array()
374 .ok_or_else(|| "mask must be an array of strings".to_string())?;
375 arr.iter()
376 .map(|v| {
377 v.as_str()
378 .map(str::to_string)
379 .ok_or_else(|| "mask must be an array of strings".to_string())
380 })
381 .collect()
382}
383
384fn tool_ingest(db: &SharedDb, args: &Js) -> CallOutcome {
385 let Some(label) = args.get("label").and_then(Js::as_str) else {
386 return CallOutcome::ToolErr("missing label".into());
387 };
388 let Some(rows_json) = args.get("rows_json").and_then(Js::as_str) else {
389 return CallOutcome::ToolErr("missing rows_json".into());
390 };
391 let mut opts = IngestOptions::default();
392 if let Some(kf) = args.get("key_field") {
393 match kf.as_str() {
394 Some(s) => opts.key_field = s.to_string(),
395 None => return CallOutcome::ToolErr("key_field must be a string".into()),
396 }
397 }
398 if let Some(suf) = args.get("auto_fk_suffix") {
399 match suf.as_str() {
400 Some(s) => {
401 opts.auto_fk = AutoFk::Auto {
402 suffix: s.to_string(),
403 }
404 }
405 None => return CallOutcome::ToolErr("auto_fk_suffix must be a string".into()),
406 }
407 }
408 let edges = match args.get("edges") {
409 None | Some(Js::Null) => Vec::new(),
410 Some(raw) => match parse_ingest_edges(raw) {
411 Ok(e) => e,
412 Err(e) => return CallOutcome::ToolErr(e),
413 },
414 };
415 let parsed: Js = match serde_json::from_str(rows_json) {
416 Ok(v) => v,
417 Err(e) => {
418 return CallOutcome::ToolErr(graph_err_msg(GraphError::IngestError {
419 detail: e.to_string(),
420 }))
421 }
422 };
423 let mut converted = match json_to_rows(&parsed) {
424 Ok(c) => c,
425 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
426 };
427 let taken = std::mem::take(&mut converted.rows);
428 let report = {
429 let mut g = db.write();
430 g.ingest_with_edges(label, taken, &opts, &edges)
431 };
432 match report.map(|r| converted.into_report(r)) {
433 Ok(r) => match serde_json::to_value(&r) {
434 Ok(v) => CallOutcome::ToolOk(v),
435 Err(e) => CallOutcome::ToolErr(e.to_string()),
436 },
437 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
438 }
439}
440
441fn tool_create_rule(db: &SharedDb, args: &Js) -> CallOutcome {
442 let def = match rule_def_from_json(args.clone()) {
443 Ok(d) => d,
444 Err(e) => return CallOutcome::ToolErr(e),
445 };
446 let name = def.name.clone();
447 let res = {
448 let mut g = db.write();
449 g.create_rule(def)
450 };
451 match res {
452 Ok(()) => CallOutcome::ToolOk(json!({"ok": true, "name": name})),
453 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
454 }
455}
456
457fn tool_explain(db: &SharedDb, args: &Js) -> CallOutcome {
458 let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
459 return CallOutcome::ToolErr("missing a".into());
460 };
461 let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
462 return CallOutcome::ToolErr("missing b".into());
463 };
464 let out = {
465 let g = db.read();
466 g.explain(a, b)
467 };
468 match out {
469 Ok(v) => match serde_json::to_value(&v) {
470 Ok(j) => CallOutcome::ToolOk(j),
471 Err(e) => CallOutcome::ToolErr(e.to_string()),
472 },
473 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
474 }
475}
476
477fn tool_stats(db: &SharedDb) -> CallOutcome {
478 let snap = {
479 let g = db.read();
480 g.stats()
481 };
482 match serde_json::to_value(&snap) {
483 Ok(v) => CallOutcome::ToolOk(v),
484 Err(e) => CallOutcome::ToolErr(e.to_string()),
485 }
486}
487
488fn tool_node_info(db: &SharedDb, args: &Js) -> CallOutcome {
489 let Some(key) = args.get("key").and_then(Js::as_str) else {
490 return CallOutcome::ToolErr("missing key".into());
491 };
492 let info = {
493 let g = db.read();
494 g.node_info(key)
495 };
496 match info {
497 Some(info) => CallOutcome::ToolOk(node_info_json(&info)),
498 None => CallOutcome::ToolErr(graph_err_msg(GraphError::KeyNotFound {
499 key: key.to_string(),
500 })),
501 }
502}
503
504fn tool_upsert_entity(db: &SharedDb, args: &Js) -> CallOutcome {
512 let Some(key) = args.get("key").and_then(Js::as_str) else {
513 return CallOutcome::ToolErr("missing key".into());
514 };
515 let label_opt = args.get("label").and_then(Js::as_str);
516 let Some(props_obj) = args.get("props").and_then(Js::as_object) else {
517 return CallOutcome::ToolErr("missing props".into());
518 };
519
520 let exists = {
521 let g = db.read();
522 g.has_node(key)
523 };
524
525 if exists {
526 let mut g = db.write();
527 let mut count = 0usize;
528 for (field, json_val) in props_obj {
529 match json_to_value(json_val.clone()) {
530 Some(v) => {
531 if let Err(e) = g.set_prop(key, field, v) {
532 return CallOutcome::ToolErr(graph_err_msg(e));
533 }
534 count += 1;
535 }
536 None => {
537 return CallOutcome::ToolErr(format!(
538 "prop {field} is not a supported value type"
539 ))
540 }
541 }
542 }
543 CallOutcome::ToolOk(json!({
544 "ok": true,
545 "key": key,
546 "created": false,
547 "updated_fields": count
548 }))
549 } else {
550 let Some(label) = label_opt else {
551 return CallOutcome::ToolErr("label required when creating a new entity".into());
552 };
553 let mut row: BTreeMap<String, Value> = BTreeMap::new();
554 row.insert("id".to_string(), Value::Str(key.to_string()));
555 for (field, json_val) in props_obj {
556 if field == "id" {
557 continue;
558 }
559 match json_to_value(json_val.clone()) {
560 Some(v) => {
561 row.insert(field.clone(), v);
562 }
563 None => {
564 return CallOutcome::ToolErr(format!(
565 "prop {field} is not a supported value type"
566 ))
567 }
568 }
569 }
570 let opts = IngestOptions {
571 key_field: "id".to_string(),
572 auto_fk: AutoFk::Off,
573 };
574 let mut g = db.write();
575 match g.ingest(label, vec![row], &opts) {
576 Ok(_) => CallOutcome::ToolOk(json!({ "ok": true, "key": key, "created": true })),
577 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
578 }
579 }
580}
581
582fn tool_find_similar(db: &SharedDb, args: &Js) -> CallOutcome {
589 let mask_keys: Option<Vec<String>> = if let Some(mask_val) = args.get("mask") {
592 match mask_val.as_array() {
593 Some(arr) => {
594 let mut ks: Vec<String> = Vec::with_capacity(arr.len());
595 for v in arr {
596 match v.as_str() {
597 Some(s) => ks.push(s.to_string()),
598 None => {
599 return CallOutcome::ToolErr("mask must be an array of strings".into())
600 }
601 }
602 }
603 Some(ks)
604 }
605 None => return CallOutcome::ToolErr("mask must be an array of strings".into()),
606 }
607 } else {
608 None
609 };
610
611 if let Some(vec_js) = args.get("vector").and_then(Js::as_array) {
614 let q: Vec<f64> = vec_js.iter().filter_map(|v| v.as_f64()).collect();
615 if q.is_empty() {
616 return CallOutcome::ToolErr("vector must be a non-empty array of numbers".into());
617 }
618 let field = args
619 .get("field")
620 .and_then(Js::as_str)
621 .unwrap_or("embedding");
622 let label_str = args.get("label").and_then(Js::as_str).unwrap_or("");
623 let label = if label_str.is_empty() {
624 None
625 } else {
626 Some(label_str)
627 };
628 let k = args
629 .get("k")
630 .and_then(Js::as_u64)
631 .map(|n| n as usize)
632 .unwrap_or(10);
633 let min = args.get("min").and_then(Js::as_f64).unwrap_or(0.8);
634
635 let hits = {
636 let g = db.read();
637 if let Some(ref keys) = mask_keys {
638 let node_mask = NodeMask::from_keys(&*g, keys.iter().map(String::as_str));
639 g.find_similar_vector_masked(field, label, &q, k, min, &node_mask)
640 } else {
641 g.find_similar_vector(field, label, &q, k, min)
642 }
643 };
644 let results: Vec<Js> = hits
645 .into_iter()
646 .map(|(key, score)| json!({ "key": key, "score": score }))
647 .collect();
648 return CallOutcome::ToolOk(json!({
649 "mode": "vector",
650 "field": field,
651 "label": label,
652 "k": k,
653 "min": min,
654 "results": results
655 }));
656 }
657
658 let Some(key) = args.get("key").and_then(Js::as_str) else {
660 return CallOutcome::ToolErr("missing key (or provide vector for vector search)".into());
661 };
662 let edge_type = args
663 .get("edge_type")
664 .and_then(Js::as_str)
665 .unwrap_or("SIMILAR");
666 let limit = args
667 .get("limit")
668 .and_then(Js::as_u64)
669 .map(|n| n as usize)
670 .unwrap_or(10);
671
672 if let Some(ref mask) = mask_keys {
675 let mask_set: std::collections::HashSet<&str> = mask.iter().map(String::as_str).collect();
676 if !mask_set.contains(key) {
677 return CallOutcome::ToolErr(graph_err_msg(GraphError::KeyNotFound {
678 key: key.into(),
679 }));
680 }
681 let out = {
682 let g = db.read();
683 g.node_edges(key)
684 };
685 return match out {
686 Ok(edges) => {
687 let similar: Vec<Js> = edges
688 .iter()
689 .filter(|e| e.edge_type == edge_type)
690 .filter(|e| {
691 let neighbor_key = if e.src_key == key {
693 &e.dst_key
694 } else {
695 &e.src_key
696 };
697 mask_set.contains(neighbor_key.as_str())
698 })
699 .take(limit)
700 .map(|e| {
701 let neighbor_key = if e.src_key == key {
702 &e.dst_key
703 } else {
704 &e.src_key
705 };
706 let direction = if e.src_key == key { "out" } else { "in" };
707 json!({
708 "neighbor_key": neighbor_key,
709 "direction": direction,
710 "edge_type": e.edge_type,
711 "derived": e.derived,
712 })
713 })
714 .collect();
715 CallOutcome::ToolOk(json!({
716 "key": key,
717 "edge_type": edge_type,
718 "similar": similar
719 }))
720 }
721 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
722 };
723 }
724
725 let out = {
726 let g = db.read();
727 g.node_edges(key)
728 };
729 match out {
730 Ok(edges) => {
731 let similar: Vec<Js> = edges
732 .iter()
733 .filter(|e| e.edge_type == edge_type)
734 .take(limit)
735 .map(|e| {
736 let neighbor_key = if e.src_key == key {
737 &e.dst_key
738 } else {
739 &e.src_key
740 };
741 let direction = if e.src_key == key { "out" } else { "in" };
742 json!({
743 "neighbor_key": neighbor_key,
744 "direction": direction,
745 "edge_type": e.edge_type,
746 "derived": e.derived,
747 })
748 })
749 .collect();
750 CallOutcome::ToolOk(json!({
751 "key": key,
752 "edge_type": edge_type,
753 "similar": similar
754 }))
755 }
756 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
757 }
758}
759
760fn tool_hybrid_search(db: &SharedDb, args: &Js) -> CallOutcome {
761 let Some(query_text) = args.get("query_text").and_then(Js::as_str) else {
762 return CallOutcome::ToolErr("missing required field: query_text".into());
763 };
764 let Some(text_field) = args.get("text_field").and_then(Js::as_str) else {
765 return CallOutcome::ToolErr("missing required field: text_field".into());
766 };
767
768 let vector_field = args
769 .get("vector_field")
770 .and_then(Js::as_str)
771 .unwrap_or("embedding");
772 let label = args.get("label").and_then(Js::as_str);
773 let k = args
774 .get("k")
775 .and_then(Js::as_u64)
776 .map(|n| n as usize)
777 .unwrap_or(10);
778
779 let query_vec: Vec<f64> = args
780 .get("vector")
781 .and_then(Js::as_array)
782 .map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect())
783 .unwrap_or_default();
784
785 let hits = {
786 let g = db.read();
787 g.search_hybrid(text_field, query_text, vector_field, &query_vec, label, k)
788 };
789
790 let results: Vec<Js> = hits
791 .into_iter()
792 .map(|(key, score)| json!({ "key": key, "score": score }))
793 .collect();
794
795 CallOutcome::ToolOk(json!({
796 "query_text": query_text,
797 "text_field": text_field,
798 "vector_field": vector_field,
799 "label": label,
800 "k": k,
801 "results": results
802 }))
803}
804
805fn tool_node_history(db: &SharedDb, args: &Js) -> CallOutcome {
806 let Some(key) = args.get("key").and_then(Js::as_str) else {
807 return CallOutcome::ToolErr("missing key".into());
808 };
809 let g = db.read();
810 let result = match g.node_history(key) {
811 Ok(e) => e,
812 Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
813 };
814 CallOutcome::ToolOk(node_history_json(key, &result))
815}
816
817fn tool_edge_history(db: &SharedDb, args: &Js) -> CallOutcome {
818 let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
819 return CallOutcome::ToolErr("missing a".into());
820 };
821 let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
822 return CallOutcome::ToolErr("missing b".into());
823 };
824 let result = {
825 let g = db.read();
826 g.edge_history(a, b)
827 };
828 match result {
829 Ok(hr) => CallOutcome::ToolOk(edge_history_result_json(a, b, &hr)),
830 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
831 }
832}
833
834fn tool_was_linked(db: &SharedDb, args: &Js) -> CallOutcome {
835 let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
836 return CallOutcome::ToolErr("missing a".into());
837 };
838 let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
839 return CallOutcome::ToolErr("missing b".into());
840 };
841 let Some(edge_type) = args
842 .get("edge_type")
843 .and_then(Js::as_str)
844 .filter(|s| !s.is_empty())
845 else {
846 return CallOutcome::ToolErr("missing edge_type".into());
847 };
848 let at_commit = match args.get("at_commit").and_then(Js::as_u64) {
849 Some(n) => n,
850 None => return CallOutcome::ToolErr("missing or invalid at_commit".into()),
851 };
852 let result = {
853 let g = db.read();
854 g.was_linked(a, b, edge_type, at_commit)
855 };
856 match result {
857 Ok(linked) => CallOutcome::ToolOk(json!({
858 "a": a,
859 "b": b,
860 "edge_type": edge_type,
861 "at_commit": at_commit,
862 "linked": linked,
863 })),
864 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
865 }
866}
867
868fn tool_rename_node(db: &SharedDb, args: &Js) -> CallOutcome {
869 let Some(old_key) = args.get("old_key").and_then(Js::as_str) else {
870 return CallOutcome::ToolErr("missing old_key".into());
871 };
872 let Some(new_key) = args.get("new_key").and_then(Js::as_str) else {
873 return CallOutcome::ToolErr("missing new_key".into());
874 };
875 let mut g = db.write();
876 match g.rename_node(old_key, new_key) {
877 Ok(()) => CallOutcome::ToolOk(json!({
878 "ok": true,
879 "old_key": old_key,
880 "new_key": new_key,
881 })),
882 Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
883 }
884}
885
886pub(crate) fn graph_err_msg(e: GraphError) -> String {
887 match e {
888 GraphError::QueryError { detail } | GraphError::IngestError { detail } => detail,
889 other => other.to_string(),
890 }
891}
892
893fn initialize_result() -> Js {
894 json!({
895 "protocolVersion": "2024-11-05",
896 "capabilities": { "tools": {} },
897 "serverInfo": { "name": "mushroomdb", "version": env!("CARGO_PKG_VERSION") }
898 })
899}
900
901const ADVANCED_PREFIX: &str = "Advanced: ";
907
908pub const CODE_GRAPH_TOOLS: [&str; 3] = ["explore", "query", "stats"];
916
917pub const ASSOCIATION_TOOLS: [&str; 15] = [
942 "query",
943 "explain_association",
944 "neighborhood",
945 "node_info",
946 "node_edges",
947 "was_linked",
948 "edges_at",
949 "what_if",
950 "node_history",
951 "edge_history",
952 "find_similar",
953 "hybrid_search",
954 "remember",
955 "recall",
956 "stats",
957];
958
959#[derive(Clone, Copy, Debug, PartialEq, Eq)]
965pub(crate) enum Surface {
966 CodeGraph,
969 Memory,
972}
973
974impl Surface {
975 fn listing(self) -> &'static [&'static str] {
978 match self {
979 Surface::CodeGraph => &CODE_GRAPH_TOOLS,
980 Surface::Memory => &ASSOCIATION_TOOLS,
981 }
982 }
983}
984
985fn surface_of(db: &SharedDb) -> Surface {
988 let ingested = {
989 let g = db.read();
990 g.has_node(crate::mcp_tasks::SYNC_KEY)
991 };
992 if ingested {
993 Surface::CodeGraph
994 } else {
995 Surface::Memory
996 }
997}
998
999fn tools_list(all: bool, surface: Surface) -> Js {
1017 let mut served: Vec<Js> = crate::mcp_tasks::task_tools();
1018 for mut tool in graph_tools() {
1019 if let Some(d) = tool.get("description").and_then(Js::as_str) {
1020 let prefixed = format!("{ADVANCED_PREFIX}{d}");
1021 tool["description"] = Js::String(prefixed);
1022 }
1023 served.push(tool);
1024 }
1025 if all {
1026 return json!({ "tools": served });
1027 }
1028 let listing = surface.listing();
1029 let mut tools: Vec<Js> = Vec::with_capacity(listing.len());
1030 for name in listing {
1031 let Some(tool) = served
1032 .iter()
1033 .find(|t| t.get("name").and_then(Js::as_str) == Some(*name))
1034 else {
1035 debug_assert!(false, "{surface:?} lists {name}, which is not served");
1036 continue;
1037 };
1038 tools.push(tool.clone());
1039 }
1040 json!({ "tools": tools })
1041}
1042
1043fn graph_tools() -> Vec<Js> {
1046 let Js::Array(tools) = json!([
1047 {
1048 "name": "query",
1049 "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'. 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.",
1050 "inputSchema": {
1051 "type": "object",
1052 "properties": {
1053 "cypher": { "type": "string", "description": "Cypher query text." },
1054 "params": {
1055 "type": "object",
1056 "description": "Named JSON-scalar query parameters."
1057 },
1058 "mask": {
1059 "type": "array",
1060 "items": { "type": "string" },
1061 "description": "Optional node key allow-list. When present, only these nodes are visible; write statements are rejected."
1062 },
1063 "role": {
1064 "type": "string",
1065 "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."
1066 },
1067 "as_of": {
1068 "type": "integer",
1069 "minimum": 0,
1070 "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."
1071 }
1072 },
1073 "required": ["cypher"]
1074 }
1075 },
1076 {
1077 "name": "ingest_json",
1078 "description": "Ingest a JSON array of objects as nodes of one label.",
1079 "inputSchema": {
1080 "type": "object",
1081 "properties": {
1082 "label": { "type": "string" },
1083 "rows_json": {
1084 "type": "string",
1085 "description": "JSON text of an array of objects."
1086 },
1087 "key_field": { "type": "string" },
1088 "auto_fk_suffix": { "type": "string" },
1089 "edges": {
1090 "type": "array",
1091 "description": "Optional user edges [{edge_type, src, dst}]."
1092 }
1093 },
1094 "required": ["label", "rows_json"]
1095 }
1096 },
1097 {
1098 "name": "create_rule",
1099 "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.",
1100 "inputSchema": {
1101 "type": "object",
1102 "properties": {
1103 "name": { "type": "string" },
1104 "src_label": { "type": "string" },
1105 "dst_label": { "type": "string" },
1106 "predicate": { "type": "object" },
1107 "edge_type": { "type": "string" },
1108 "weight_prop": {
1109 "type": ["string", "null"],
1110 "description": "Edge property that stores the score (default: weight)."
1111 },
1112 "max_edges": { "type": ["integer", "null"] }
1113 },
1114 "required": ["name", "src_label", "dst_label", "predicate", "edge_type"]
1115 }
1116 },
1117 {
1118 "name": "explain",
1119 "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.",
1120 "inputSchema": {
1121 "type": "object",
1122 "properties": {
1123 "a": { "type": "string", "minLength": 1 },
1124 "b": { "type": "string", "minLength": 1 }
1125 },
1126 "required": ["a", "b"]
1127 }
1128 },
1129 {
1130 "name": "stats",
1131 "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).",
1132 "inputSchema": {
1133 "type": "object",
1134 "properties": {}
1135 }
1136 },
1137 {
1138 "name": "node_info",
1139 "description": "What is K — its label and every property it holds.",
1140 "inputSchema": {
1141 "type": "object",
1142 "properties": {
1143 "key": { "type": "string" }
1144 },
1145 "required": ["key"]
1146 }
1147 },
1148 {
1149 "name": "upsert_entity",
1150 "description": "Record what is now true about K — insert or update a node by key. If the key exists, updates the supplied properties. If not, creates a new node with the given label and properties. Useful for agent memory: store or refresh an entity without checking existence first.",
1151 "inputSchema": {
1152 "type": "object",
1153 "properties": {
1154 "key": { "type": "string", "description": "Unique node key." },
1155 "label": { "type": "string", "description": "Node label (required when creating a new entity)." },
1156 "props": {
1157 "type": "object",
1158 "description": "Properties to set. Values must be scalars (string, number, bool) or arrays of scalars."
1159 }
1160 },
1161 "required": ["key", "props"]
1162 }
1163 },
1164 {
1165 "name": "find_similar",
1166 "description": "What is most like this — two modes: (1) Vector search — provide `vector` (and optionally `field`, `label`, `k`, `min`) to find the k most similar nodes by cosine similarity using the HNSW index when available, brute-force otherwise. (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. 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.",
1167 "inputSchema": {
1168 "type": "object",
1169 "properties": {
1170 "vector": {
1171 "type": "array",
1172 "items": { "type": "number" },
1173 "description": "Query embedding vector for vector-similarity search. When present, vector-search mode is used and `key` is ignored."
1174 },
1175 "field": { "type": "string", "description": "Property field holding the embedding vectors (default: embedding). Used in vector-search mode." },
1176 "label": { "type": "string", "description": "Restrict search to nodes with this label. Empty string means all labels. Used in vector-search mode." },
1177 "k": { "type": "integer", "description": "Maximum results to return in vector-search mode (default: 10)." },
1178 "min": { "type": "number", "description": "Minimum cosine similarity threshold in vector-search mode (default: 0.8)." },
1179 "mask": {
1180 "type": "array",
1181 "items": { "type": "string" },
1182 "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 so callers still receive up to k visible hits. Unknown keys are silently ignored."
1183 },
1184 "key": { "type": "string", "description": "Source node key for edge-traversal mode." },
1185 "edge_type": { "type": "string", "description": "Edge type to filter by in edge-traversal mode (default: SIMILAR)." },
1186 "limit": { "type": "integer", "description": "Maximum neighbors to return in edge-traversal mode (default: 10)." }
1187 }
1188 }
1189 },
1190 {
1191 "name": "hybrid_search",
1192 "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.",
1193 "inputSchema": {
1194 "type": "object",
1195 "properties": {
1196 "query_text": { "type": "string", "description": "Fulltext query string." },
1197 "text_field": { "type": "string", "description": "Property field to search with fulltext." },
1198 "vector": {
1199 "type": "array",
1200 "items": { "type": "number" },
1201 "description": "Query embedding vector. Omit for text-only ranking."
1202 },
1203 "vector_field": { "type": "string", "description": "Property field holding embedding vectors (default: embedding)." },
1204 "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." },
1205 "k": { "type": "integer", "description": "Maximum results to return (default: 10)." }
1206 },
1207 "required": ["query_text", "text_field"]
1208 }
1209 },
1210 {
1211 "name": "node_history",
1212 "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.",
1213 "inputSchema": {
1214 "type": "object",
1215 "properties": {
1216 "key": { "type": "string", "description": "Node key to look up." }
1217 },
1218 "required": ["key"]
1219 }
1220 },
1221 {
1222 "name": "edge_history",
1223 "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.",
1224 "inputSchema": {
1225 "type": "object",
1226 "properties": {
1227 "a": { "type": "string", "minLength": 1, "description": "First node key." },
1228 "b": { "type": "string", "minLength": 1, "description": "Second node key." }
1229 },
1230 "required": ["a", "b"]
1231 }
1232 },
1233 {
1234 "name": "was_linked",
1235 "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`).",
1236 "inputSchema": {
1237 "type": "object",
1238 "properties": {
1239 "a": { "type": "string", "minLength": 1, "description": "First node key." },
1240 "b": { "type": "string", "minLength": 1, "description": "Second node key." },
1241 "edge_type": { "type": "string", "minLength": 1, "description": "Edge type to check." },
1242 "at_commit": { "type": "integer", "minimum": 0, "description": "0-based WAL commit index to query." }
1243 },
1244 "required": ["a", "b", "edge_type", "at_commit"]
1245 }
1246 },
1247 {
1248 "name": "rename_node",
1249 "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.",
1250 "inputSchema": {
1251 "type": "object",
1252 "properties": {
1253 "old_key": { "type": "string", "minLength": 1, "description": "Current node key." },
1254 "new_key": { "type": "string", "minLength": 1, "description": "Desired new node key." }
1255 },
1256 "required": ["old_key", "new_key"]
1257 }
1258 }
1259 ]) else {
1260 unreachable!("the literal above is an array")
1261 };
1262 tools
1263}
1264
1265fn tool_ok(payload: Js) -> Js {
1266 json!({
1267 "content": [{ "type": "text", "text": payload.to_string() }]
1268 })
1269}
1270
1271fn task_ok(text: &str) -> Js {
1279 json!({
1280 "content": [{ "type": "text", "text": text }]
1281 })
1282}
1283
1284fn tool_err(message: &str) -> Js {
1285 json!({
1286 "content": [{ "type": "text", "text": message }],
1287 "isError": true
1288 })
1289}
1290
1291fn write_result(writer: &mut impl Write, id: Option<Js>, result: Js) -> io::Result<()> {
1292 write_json(
1293 writer,
1294 &json!({
1295 "jsonrpc": "2.0",
1296 "id": id.unwrap_or(Js::Null),
1297 "result": result
1298 }),
1299 )
1300}
1301
1302fn write_error(
1303 writer: &mut impl Write,
1304 id: Option<Js>,
1305 code: i64,
1306 message: &str,
1307) -> io::Result<()> {
1308 write_json(
1309 writer,
1310 &json!({
1311 "jsonrpc": "2.0",
1312 "id": id.unwrap_or(Js::Null),
1313 "error": { "code": code, "message": message }
1314 }),
1315 )
1316}
1317
1318fn write_json(writer: &mut impl Write, value: &Js) -> io::Result<()> {
1319 let s = serde_json::to_string(value).map_err(io::Error::other)?;
1320 writeln!(writer, "{s}")?;
1321 writer.flush()
1322}
1323
1324#[cfg(test)]
1329mod tests {
1330 use super::*;
1331 use core_api::{AutoFk, IngestOptions, Predicate, RuleDef, Value};
1332 use std::path::PathBuf;
1333 use std::sync::atomic::{AtomicU64, Ordering};
1334
1335 fn tmp_dir() -> PathBuf {
1336 static SEQ: AtomicU64 = AtomicU64::new(0);
1337 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1338 let d = std::env::temp_dir().join(format!("mcp-test-{}-{}", std::process::id(), n));
1339 let _ = std::fs::remove_dir_all(&d);
1343 d
1344 }
1345
1346 fn demo_db() -> SharedDb {
1348 let db = SharedDb::open(&tmp_dir()).expect("open");
1349 {
1350 let mut g = db.write();
1351 let opts = IngestOptions {
1352 key_field: "id".into(),
1353 auto_fk: AutoFk::Off,
1354 };
1355 let people: Vec<BTreeMap<String, Value>> = vec![
1357 [
1358 ("id", Value::Str("alice".into())),
1359 ("name", Value::Str("Alice".into())),
1360 (
1361 "emb",
1362 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1363 ),
1364 ]
1365 .into_iter()
1366 .map(|(k, v)| (k.to_string(), v))
1367 .collect(),
1368 [
1369 ("id", Value::Str("bob".into())),
1370 ("name", Value::Str("Bob".into())),
1371 (
1372 "emb",
1373 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1374 ),
1375 ]
1376 .into_iter()
1377 .map(|(k, v)| (k.to_string(), v))
1378 .collect(),
1379 ];
1380 g.ingest("Person", people, &opts).expect("ingest");
1381
1382 g.create_rule(RuleDef {
1384 name: "sim_emb".into(),
1385 src_label: "Person".into(),
1386 dst_label: "Person".into(),
1387 predicate: Predicate::VectorSimilar {
1388 field: "emb".into(),
1389 min: 0.9,
1390 },
1391 edge_type: "SIMILAR".into(),
1392 weight_prop: Some("score".into()),
1393 max_edges: None,
1394 approximate: false,
1395 via_label: None,
1396 via_edge: None,
1397 via_dir: None,
1398 })
1399 .expect("rule");
1400 }
1401 db
1402 }
1403
1404 fn roundtrip(db: &SharedDb, request: &str) -> Js {
1405 roundtrip_with(db, false, request)
1406 }
1407
1408 fn roundtrip_with(db: &SharedDb, all_tools: bool, request: &str) -> Js {
1409 let input = format!("{request}\n");
1410 let mut output = Vec::new();
1411 run_mcp_stdio_with(db.clone(), None, all_tools, input.as_bytes(), &mut output)
1412 .expect("mcp");
1413 let s = std::str::from_utf8(&output).expect("utf8");
1414 serde_json::from_str(s.trim()).expect("json response")
1415 }
1416
1417 fn tool_call(db: &SharedDb, id: u64, tool: &str, args: Js) -> Js {
1418 let req = json!({
1419 "jsonrpc": "2.0",
1420 "id": id,
1421 "method": "tools/call",
1422 "params": { "name": tool, "arguments": args }
1423 });
1424 roundtrip(db, &req.to_string())
1425 }
1426
1427 fn tool_text(resp: &Js) -> Js {
1429 let text = resp["result"]["content"][0]["text"]
1430 .as_str()
1431 .expect("content[0].text");
1432 serde_json::from_str(text).expect("tool text is json")
1433 }
1434
1435 fn is_error(resp: &Js) -> bool {
1436 resp["result"]["isError"].as_bool().unwrap_or(false)
1437 }
1438
1439 fn tool_err_text(resp: &Js) -> String {
1440 resp["result"]["content"][0]["text"]
1441 .as_str()
1442 .unwrap_or("")
1443 .to_string()
1444 }
1445
1446 #[test]
1449 fn test_tools_list_includes_all_expected() {
1450 let db = demo_db();
1451 let resp = roundtrip_with(
1452 &db,
1453 true,
1454 r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
1455 );
1456 let tools = resp["result"]["tools"].as_array().expect("tools array");
1457 let names: Vec<&str> = tools
1458 .iter()
1459 .map(|t| t["name"].as_str().expect("name"))
1460 .collect();
1461 for expected in &[
1462 "explore",
1464 "map",
1465 "context",
1466 "impact",
1467 "owners",
1468 "why",
1469 "explain_association",
1470 "node_edges",
1471 "neighborhood",
1472 "edges_at",
1473 "what_if",
1474 "recall",
1475 "remember",
1476 "sync",
1477 "query",
1479 "ingest_json",
1480 "create_rule",
1481 "explain",
1482 "stats",
1483 "node_info",
1484 "upsert_entity",
1485 "find_similar",
1486 "hybrid_search",
1487 "node_history",
1488 "edge_history",
1489 "was_linked",
1490 "rename_node",
1491 ] {
1492 assert!(names.contains(expected), "missing tool: {expected}");
1493 }
1494 assert_eq!(
1495 names.len(),
1496 27,
1497 "expected exactly 27 tools, got {}",
1498 names.len()
1499 );
1500 assert_eq!(
1501 &names[..14],
1502 [
1503 "explore",
1504 "map",
1505 "context",
1506 "impact",
1507 "owners",
1508 "why",
1509 "explain_association",
1510 "node_edges",
1511 "neighborhood",
1512 "edges_at",
1513 "what_if",
1514 "recall",
1515 "remember",
1516 "sync"
1517 ],
1518 "the task tools come first, in order"
1519 );
1520 assert_eq!(names[14], "query", "the graph tools follow them");
1521 }
1522
1523 #[test]
1527 fn tools_list_defaults_to_fifteen_on_a_memory_store() {
1528 let db = demo_db();
1529 let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1530 let names: Vec<&str> = resp["result"]["tools"]
1531 .as_array()
1532 .expect("tools array")
1533 .iter()
1534 .map(|t| t["name"].as_str().expect("name"))
1535 .collect();
1536 assert_eq!(names, ASSOCIATION_TOOLS.to_vec());
1537 }
1538
1539 #[test]
1546 fn the_association_surface_is_entity_tools_only() {
1547 for kept in ["remember", "recall", "explain_association"] {
1548 assert!(
1549 ASSOCIATION_TOOLS.contains(&kept),
1550 "{kept} answers on an entity graph and must be listed"
1551 );
1552 }
1553 for code_only in [
1554 "explore", "map", "context", "impact", "owners", "why", "sync",
1555 ] {
1556 assert!(
1557 !ASSOCIATION_TOOLS.contains(&code_only),
1558 "{code_only} reads a code graph and must not be listed on a memory store"
1559 );
1560 }
1561 let served: Vec<String> = crate::mcp_tasks::task_tools()
1562 .iter()
1563 .chain(graph_tools().iter())
1564 .filter_map(|t| t.get("name").and_then(Js::as_str))
1565 .map(str::to_string)
1566 .collect();
1567 for name in ASSOCIATION_TOOLS {
1568 assert!(
1569 served.iter().any(|s| s == name),
1570 "{name} is listed but not served"
1571 );
1572 }
1573 assert!(
1574 CODE_GRAPH_TOOLS.contains(&"explore"),
1575 "and `explore` is the task tool the other surface lists"
1576 );
1577 }
1578
1579 #[test]
1582 fn tools_list_is_three_tools_on_a_code_graph_store() {
1583 let db = demo_db();
1584 db.write()
1585 .insert_node(
1586 "GitSync",
1587 crate::mcp_tasks::SYNC_KEY,
1588 vec![("id".into(), Value::Str(crate::mcp_tasks::SYNC_KEY.into()))],
1589 )
1590 .expect("marker");
1591 let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1592 let names: Vec<&str> = resp["result"]["tools"]
1593 .as_array()
1594 .expect("tools array")
1595 .iter()
1596 .map(|t| t["name"].as_str().expect("name"))
1597 .collect();
1598 assert_eq!(names, ["explore", "query", "stats"]);
1599 }
1600
1601 #[test]
1602 fn test_stats_returns_node_count() {
1603 let db = demo_db();
1604 let resp = tool_call(&db, 1, "stats", json!({}));
1605 assert!(!is_error(&resp));
1606 let result = tool_text(&resp);
1607 assert_eq!(result["nodes_live"], 2);
1608 }
1609
1610 #[test]
1611 fn test_query_runs_cypher() {
1612 let db = demo_db();
1613 let resp = tool_call(
1614 &db,
1615 1,
1616 "query",
1617 json!({ "cypher": "MATCH (n:Person) RETURN n.name ORDER BY n.name" }),
1618 );
1619 assert!(!is_error(&resp));
1620 let result = tool_text(&resp);
1621 assert_eq!(result["columns"], json!(["n.name"]));
1623 assert_eq!(result["rows"].as_array().map(|r| r.len()), Some(2));
1624 }
1625
1626 #[test]
1627 fn test_query_create_is_a_write() {
1628 let db = SharedDb::open(&tmp_dir()).expect("open");
1629 let resp = tool_call(
1630 &db,
1631 1,
1632 "query",
1633 json!({ "cypher": "CREATE (n:L {id: 'k'}) RETURN n" }),
1634 );
1635 assert!(
1636 !is_error(&resp),
1637 "CREATE via MCP query must succeed: {resp}"
1638 );
1639 let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
1640 assert_eq!(stats["nodes_live"], 1);
1641 }
1642
1643 #[test]
1644 fn test_ingest_json_inserts_nodes() {
1645 let db = demo_db();
1646 let resp = tool_call(
1647 &db,
1648 1,
1649 "ingest_json",
1650 json!({
1651 "label": "Person",
1652 "rows_json": r#"[{"id":"carol","name":"Carol"}]"#,
1653 "key_field": "id"
1654 }),
1655 );
1656 assert!(!is_error(&resp));
1657 let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
1659 assert_eq!(stats["nodes_live"], 3);
1660 }
1661
1662 #[test]
1663 fn test_node_info_returns_props() {
1664 let db = demo_db();
1665 let resp = tool_call(&db, 1, "node_info", json!({ "key": "alice" }));
1666 assert!(!is_error(&resp));
1667 let result = tool_text(&resp);
1668 assert_eq!(result["key"], "alice");
1669 assert_eq!(result["label"], "Person");
1670 assert_eq!(result["props"]["name"], "Alice");
1671 }
1672
1673 #[test]
1676 fn test_node_edges_returns_edges() {
1677 let db = demo_db();
1678 let resp = tool_call(
1679 &db,
1680 1,
1681 "node_edges",
1682 json!({ "key": "alice", "json": true }),
1683 );
1684 assert!(!is_error(&resp));
1685 let result = tool_text(&resp);
1686 assert_eq!(result["key"], "alice");
1687 let types = result["types"].as_array().expect("types");
1688 assert!(
1689 !types.is_empty(),
1690 "alice should have at least one edge type"
1691 );
1692 let similar = types
1693 .iter()
1694 .find(|t| t["edge_type"] == "SIMILAR")
1695 .expect("the rule's edge type");
1696 assert_eq!(similar["count"], json!(2));
1699 let edges = similar["edges"].as_array().expect("edges");
1700 let dirs: Vec<&str> = edges
1701 .iter()
1702 .map(|e| e["direction"].as_str().expect("direction"))
1703 .collect();
1704 assert!(dirs.contains(&"out") && dirs.contains(&"in"), "{similar}");
1705 for edge in edges {
1706 assert_eq!(edge["other"], json!("bob"));
1707 assert_eq!(edge["derived"], json!(true));
1708 assert_eq!(edge["rule"], json!("sim_emb"));
1709 assert_eq!(edge["score"], json!(1.0));
1710 assert!(
1711 edge["predicate"]
1712 .as_str()
1713 .unwrap_or("")
1714 .contains("vector_similar"),
1715 "the predicate travels with the edge: {edge}"
1716 );
1717 }
1718 }
1719
1720 #[test]
1723 fn test_neighborhood_traverses_one_hop() {
1724 let db = demo_db();
1725 let resp = tool_call(
1726 &db,
1727 1,
1728 "neighborhood",
1729 json!({ "key": "alice", "depth": 1, "json": true }),
1730 );
1731 assert!(!is_error(&resp));
1732 let result = tool_text(&resp);
1733 assert_eq!(result["key"], "alice");
1734 assert!(result["types"].as_array().is_some(), "{result}");
1735
1736 let deep = tool_call(
1737 &db,
1738 2,
1739 "neighborhood",
1740 json!({ "key": "alice", "depth": 2 }),
1741 );
1742 assert!(!is_error(&deep));
1743 let table = tool_text(&deep);
1744 assert_eq!(table["columns"], json!(["key", "label", "depth"]));
1745 assert!(table["rows"].as_array().is_some());
1746 }
1747
1748 #[test]
1749 fn test_explain_returns_rule_info() {
1750 let db = demo_db();
1751 let resp = tool_call(&db, 1, "explain", json!({ "a": "alice", "b": "bob" }));
1752 assert!(!is_error(&resp));
1753 let result = tool_text(&resp);
1754 let arr = result.as_array().expect("explain returns array");
1755 assert!(!arr.is_empty(), "expected at least one explanation");
1756 assert_eq!(arr[0]["rule"], "sim_emb");
1757 }
1758
1759 #[test]
1760 fn test_create_rule_backfills() {
1761 let db = SharedDb::open(&tmp_dir()).expect("open");
1762 {
1763 let mut g = db.write();
1764 let opts = IngestOptions {
1765 key_field: "id".into(),
1766 auto_fk: AutoFk::Off,
1767 };
1768 let rows: Vec<BTreeMap<String, Value>> = vec![
1769 [
1770 ("id", Value::Str("x".into())),
1771 ("tag", Value::Str("a".into())),
1772 ]
1773 .into_iter()
1774 .map(|(k, v)| (k.to_string(), v))
1775 .collect(),
1776 [
1777 ("id", Value::Str("y".into())),
1778 ("tag", Value::Str("a".into())),
1779 ]
1780 .into_iter()
1781 .map(|(k, v)| (k.to_string(), v))
1782 .collect(),
1783 ];
1784 g.ingest("Item", rows, &opts).expect("ingest");
1785 }
1786 let resp = tool_call(
1787 &db,
1788 1,
1789 "create_rule",
1790 json!({
1791 "name": "same_tag",
1792 "src_label": "Item",
1793 "dst_label": "Item",
1794 "predicate": { "FieldEqual": { "field": "tag" } },
1795 "edge_type": "SAME_TAG"
1796 }),
1797 );
1798 assert!(!is_error(&resp));
1799 let result = tool_text(&resp);
1800 assert_eq!(result["ok"], true);
1801 let edges_resp = tool_call(&db, 2, "node_edges", json!({ "key": "x", "json": true }));
1803 let edges_result = tool_text(&edges_resp);
1804 let types = edges_result["types"].as_array().expect("types");
1805 assert!(
1806 types.iter().any(|t| t["edge_type"] == "SAME_TAG"),
1807 "SAME_TAG edge not found after create_rule"
1808 );
1809 }
1810
1811 #[test]
1814 fn test_upsert_entity_creates_new_node() {
1815 let db = demo_db();
1816 let resp = tool_call(
1817 &db,
1818 1,
1819 "upsert_entity",
1820 json!({
1821 "key": "carol",
1822 "label": "Person",
1823 "props": { "name": "Carol", "age": 30 }
1824 }),
1825 );
1826 assert!(!is_error(&resp));
1827 let result = tool_text(&resp);
1828 assert_eq!(result["ok"], true);
1829 assert_eq!(result["created"], true);
1830 assert_eq!(result["key"], "carol");
1831 let info = tool_text(&tool_call(&db, 2, "node_info", json!({ "key": "carol" })));
1833 assert_eq!(info["props"]["name"], "Carol");
1834 }
1835
1836 #[test]
1837 fn test_upsert_entity_updates_existing_node() {
1838 let db = demo_db();
1839 let resp = tool_call(
1840 &db,
1841 1,
1842 "upsert_entity",
1843 json!({
1844 "key": "alice",
1845 "props": { "name": "Alice Updated" }
1846 }),
1847 );
1848 assert!(!is_error(&resp));
1849 let result = tool_text(&resp);
1850 assert_eq!(result["ok"], true);
1851 assert_eq!(result["created"], false);
1852 assert_eq!(result["updated_fields"], 1);
1853 let info = tool_text(&tool_call(&db, 2, "node_info", json!({ "key": "alice" })));
1855 assert_eq!(info["props"]["name"], "Alice Updated");
1856 }
1857
1858 #[test]
1859 fn test_upsert_entity_missing_label_on_create_is_error() {
1860 let db = demo_db();
1861 let resp = tool_call(
1862 &db,
1863 1,
1864 "upsert_entity",
1865 json!({ "key": "new-node", "props": { "x": 1 } }),
1866 );
1867 assert!(is_error(&resp), "should error without label for new node");
1868 }
1869
1870 #[test]
1871 fn test_find_similar_returns_similar_edges() {
1872 let db = demo_db();
1873 let resp = tool_call(
1874 &db,
1875 1,
1876 "find_similar",
1877 json!({ "key": "alice", "edge_type": "SIMILAR" }),
1878 );
1879 assert!(!is_error(&resp));
1880 let result = tool_text(&resp);
1881 assert_eq!(result["key"], "alice");
1882 assert_eq!(result["edge_type"], "SIMILAR");
1883 let similar = result["similar"].as_array().expect("similar array");
1884 assert!(!similar.is_empty(), "expected SIMILAR neighbors for alice");
1885 assert_eq!(similar[0]["neighbor_key"], "bob");
1886 }
1887
1888 #[test]
1889 fn test_find_similar_limit_respected() {
1890 let db = demo_db();
1891 let resp = tool_call(
1892 &db,
1893 1,
1894 "find_similar",
1895 json!({ "key": "alice", "edge_type": "SIMILAR", "limit": 0 }),
1896 );
1897 assert!(!is_error(&resp));
1898 let result = tool_text(&resp);
1899 let similar = result["similar"].as_array().expect("similar array");
1900 assert_eq!(similar.len(), 0);
1901 }
1902
1903 #[test]
1907 fn test_find_similar_vector_default_min_is_0_8() {
1908 let db = SharedDb::open(&tmp_dir()).expect("open");
1909 {
1910 let mut g = db.write();
1911 g.insert_node(
1913 "Item",
1914 "close",
1915 vec![(
1916 "emb".into(),
1917 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1918 )],
1919 )
1920 .unwrap();
1921 g.insert_node(
1923 "Item",
1924 "far",
1925 vec![(
1926 "emb".into(),
1927 Value::List(vec![Value::Float(0.0), Value::Float(1.0)]),
1928 )],
1929 )
1930 .unwrap();
1931 }
1932
1933 let resp = tool_call(
1935 &db,
1936 1,
1937 "find_similar",
1938 json!({
1939 "vector": [1.0, 0.0],
1940 "field": "emb",
1941 "label": "Item",
1942 "k": 10
1943 }),
1944 );
1945 assert!(!is_error(&resp), "vector search must not error");
1946 let result = tool_text(&resp);
1947 let results = result["results"].as_array().expect("results array");
1948
1949 let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
1950 assert!(
1951 keys.contains(&"close"),
1952 "close node (sim=1.0) must be included"
1953 );
1954 assert!(
1955 !keys.contains(&"far"),
1956 "far node (sim=0.0) must be excluded by default min=0.8"
1957 );
1958 }
1959
1960 #[test]
1962 fn test_find_similar_vector_mask_excludes_hidden() {
1963 let db = SharedDb::open(&tmp_dir()).expect("open");
1964 {
1965 let mut g = db.write();
1966 g.insert_node(
1968 "Item",
1969 "visible",
1970 vec![(
1971 "emb".into(),
1972 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1973 )],
1974 )
1975 .unwrap();
1976 g.insert_node(
1978 "Item",
1979 "hidden",
1980 vec![(
1981 "emb".into(),
1982 Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1983 )],
1984 )
1985 .unwrap();
1986 }
1987
1988 let resp = tool_call(
1989 &db,
1990 1,
1991 "find_similar",
1992 json!({
1993 "vector": [1.0, 0.0],
1994 "field": "emb",
1995 "label": "Item",
1996 "k": 10,
1997 "min": 0.0,
1998 "mask": ["visible"]
1999 }),
2000 );
2001 assert!(!is_error(&resp), "masked vector search must not error");
2002 let result = tool_text(&resp);
2003 let results = result["results"].as_array().expect("results array");
2004
2005 let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2006 assert!(
2007 keys.contains(&"visible"),
2008 "visible node must appear in masked results"
2009 );
2010 assert!(
2011 !keys.contains(&"hidden"),
2012 "hidden node must be excluded by mask"
2013 );
2014 }
2015
2016 #[test]
2018 fn test_find_similar_vector_mask_bad_type_is_error() {
2019 let db = SharedDb::open(&tmp_dir()).expect("open");
2020 let resp = tool_call(
2021 &db,
2022 1,
2023 "find_similar",
2024 json!({
2025 "vector": [1.0, 0.0],
2026 "field": "emb",
2027 "k": 5,
2028 "mask": [42]
2029 }),
2030 );
2031 assert!(
2032 is_error(&resp),
2033 "non-string mask element must produce a tool error"
2034 );
2035 }
2036
2037 #[test]
2039 fn test_find_similar_edge_mask_excludes_hidden_neighbor() {
2040 let db = SharedDb::open(&tmp_dir()).expect("open");
2041 {
2042 let mut g = db.write();
2043 g.insert_node("P", "alice", vec![]).unwrap();
2044 g.insert_node("P", "bob", vec![]).unwrap(); g.insert_node("P", "carol", vec![]).unwrap(); g.insert_edge("KNOWS", "alice", "bob").unwrap();
2047 g.insert_edge("KNOWS", "alice", "carol").unwrap();
2048 }
2049 let resp = tool_call(
2051 &db,
2052 1,
2053 "find_similar",
2054 json!({
2055 "key": "alice",
2056 "edge_type": "KNOWS",
2057 "mask": ["alice", "bob"]
2058 }),
2059 );
2060 assert!(!is_error(&resp), "masked edge search must not error");
2061 let result = tool_text(&resp);
2062 let similar = result["similar"].as_array().expect("similar array");
2063 let neighbors: Vec<&str> = similar
2064 .iter()
2065 .filter_map(|e| e["neighbor_key"].as_str())
2066 .collect();
2067 assert!(neighbors.contains(&"bob"), "bob (visible) must appear");
2068 assert!(
2069 !neighbors.contains(&"carol"),
2070 "carol (hidden) must be excluded"
2071 );
2072 }
2073
2074 #[test]
2077 fn test_find_similar_edge_mask_hidden_key_is_not_found() {
2078 let db = SharedDb::open(&tmp_dir()).expect("open");
2079 {
2080 let mut g = db.write();
2081 g.insert_node("P", "alice", vec![]).unwrap();
2082 g.insert_node("P", "bob", vec![]).unwrap();
2083 }
2084 let resp_masked = tool_call(
2086 &db,
2087 1,
2088 "find_similar",
2089 json!({ "key": "alice", "edge_type": "KNOWS", "mask": ["bob"] }),
2090 );
2091 let resp_ghost = tool_call(
2093 &db,
2094 2,
2095 "find_similar",
2096 json!({ "key": "ghost", "edge_type": "KNOWS" }),
2097 );
2098 assert!(
2099 is_error(&resp_masked),
2100 "hidden query key must produce a tool error"
2101 );
2102 assert!(
2103 is_error(&resp_ghost),
2104 "nonexistent key must produce a tool error"
2105 );
2106 assert_eq!(
2108 tool_err_text(&resp_masked).contains("alice"),
2109 tool_err_text(&resp_ghost).contains("ghost"),
2110 "error messages should follow same not-found template"
2111 );
2112 }
2113
2114 #[test]
2119 fn test_explain_association_same_as_explain() {
2120 let db = demo_db();
2121 let explain = tool_text(&tool_call(
2122 &db,
2123 1,
2124 "explain",
2125 json!({ "a": "alice", "b": "bob" }),
2126 ));
2127 let assoc = tool_text(&tool_call(
2128 &db,
2129 2,
2130 "explain_association",
2131 json!({ "a": "alice", "b": "bob", "json": true }),
2132 ));
2133 let explain: Vec<Js> = serde_json::from_value(explain).expect("explain array");
2134 let mut assoc: Vec<Js> = serde_json::from_value(assoc).expect("assoc array");
2135 for row in &mut assoc {
2136 let ev = row
2137 .as_object_mut()
2138 .expect("object")
2139 .remove("evidence")
2140 .expect("every derived edge carries its evidence");
2141 assert!(
2142 ev["similarity"].is_number(),
2143 "a vector_similar edge reports the cosine it scored: {ev}"
2144 );
2145 }
2146 assert_eq!(explain, assoc, "evidence is the only addition");
2147
2148 let prose = tool_call(
2149 &db,
2150 3,
2151 "explain_association",
2152 json!({ "a": "alice", "b": "bob" }),
2153 );
2154 let text = prose["result"]["content"][0]["text"]
2155 .as_str()
2156 .expect("text content");
2157 assert!(
2158 text.contains("mushroomdb explain — alice ↔ bob:"),
2159 "the default reply is the digest: {text}"
2160 );
2161 }
2162
2163 #[test]
2168 fn test_edge_history_returns_derived_lifecycle_with_rule() {
2169 let db = demo_db(); let resp = tool_call(&db, 1, "edge_history", json!({ "a": "alice", "b": "bob" }));
2171 assert!(!is_error(&resp), "edge_history must not error: {resp}");
2172 let result = tool_text(&resp);
2173
2174 let total = result["total_commits"].as_u64().expect("total_commits");
2176 assert!(total > 0, "total_commits must be > 0 after ingest + rule");
2177
2178 let events = result["events"].as_array().expect("events array");
2180 assert!(!events.is_empty(), "expected at least one edge event");
2181
2182 let derived_added = events
2184 .iter()
2185 .any(|ev| ev["event"].as_str() == Some("Added") && !ev["rule"].is_null());
2186 assert!(
2187 derived_added,
2188 "expected a derived Added event with rule attribution: {events:?}"
2189 );
2190 }
2191
2192 #[test]
2195 fn test_was_linked_at_valid_commit() {
2196 let db = SharedDb::open(&tmp_dir()).expect("open");
2197 {
2198 let mut g = db.write();
2199 let opts = IngestOptions {
2200 key_field: "id".into(),
2201 auto_fk: AutoFk::Off,
2202 };
2203 let rows: Vec<BTreeMap<String, Value>> = vec![
2204 [("id", Value::Str("x".into()))]
2205 .into_iter()
2206 .map(|(k, v)| (k.to_string(), v))
2207 .collect(),
2208 [("id", Value::Str("y".into()))]
2209 .into_iter()
2210 .map(|(k, v)| (k.to_string(), v))
2211 .collect(),
2212 ];
2213 g.ingest("N", rows, &opts).expect("ingest");
2214 g.insert_edge("LINK", "x", "y").expect("edge");
2215 }
2216 let g = db.read();
2218 let total = g.wal_total_commits().expect("wal_total_commits");
2219 drop(g);
2220
2221 let resp = tool_call(
2222 &db,
2223 1,
2224 "was_linked",
2225 json!({ "a": "x", "b": "y", "edge_type": "LINK", "at_commit": total - 1 }),
2226 );
2227 assert!(!is_error(&resp), "was_linked must not error: {resp}");
2228 let result = tool_text(&resp);
2229 assert_eq!(result["linked"], true);
2230 assert_eq!(result["a"], "x");
2231 assert_eq!(result["edge_type"], "LINK");
2232 }
2233
2234 #[test]
2237 fn test_was_linked_out_of_horizon_returns_tool_error() {
2238 let db = SharedDb::open(&tmp_dir()).expect("open");
2239 {
2240 let mut g = db.write();
2241 g.insert_node("N", "a", vec![]).expect("node a");
2242 g.insert_node("N", "b", vec![]).expect("node b");
2243 }
2244 let resp = tool_call(
2246 &db,
2247 1,
2248 "was_linked",
2249 json!({ "a": "a", "b": "b", "edge_type": "X", "at_commit": 999 }),
2250 );
2251 assert!(
2253 is_error(&resp),
2254 "out-of-range commit must be a tool error: {resp}"
2255 );
2256 let text = resp["result"]["content"][0]["text"].as_str().expect("text");
2257 assert!(
2258 text.contains("out of range") || text.contains("range"),
2259 "error must mention range: {text}"
2260 );
2261 }
2262
2263 #[test]
2266 fn test_node_history_via_mcp() {
2267 let db = demo_db(); let resp = tool_call(&db, 1, "node_history", json!({ "key": "alice" }));
2269 assert!(!is_error(&resp), "node_history must not error: {resp}");
2270 let result = tool_text(&resp);
2271
2272 assert_eq!(result["key"], "alice");
2273 let total = result["total_commits"].as_u64().expect("total_commits");
2274 assert!(total > 0, "total_commits must be > 0");
2275
2276 let history = result["history"].as_array().expect("history array");
2277 assert!(
2278 !history.is_empty(),
2279 "alice should have at least one history entry"
2280 );
2281
2282 let first_change = &history[0]["change"];
2284 assert_eq!(first_change["type"], "NodeInserted");
2285 assert_eq!(first_change["label"], "Person");
2286 }
2287}