Skip to main content

server/
mcp.rs

1//! MCP server: JSON-RPC 2.0 over newline-delimited stdio.
2//!
3//! Framing is **one JSON object per line**. LSP-style `Content-Length`
4//! headers are not accepted — a header line is a parse error (`-32700`)
5//! and the loop continues. Blank lines are skipped.
6//!
7//! # Methods
8//!
9//! - `initialize` — `protocolVersion` `"2024-11-05"`, `capabilities.tools`,
10//!   `serverInfo.name` `"mushroomdb"`, `serverInfo.version` (crate version)
11//! - `notifications/initialized` — ignored
12//! - `tools/list` — the default listing follows the store the server opened
13//!   (see [`Surface`]): a store a repository was ingested into lists three —
14//!   `explore`, `query`, `stats` — and any other store lists the fifteen of
15//!   [`ASSOCIATION_TOOLS`], the tools that answer a question about an entity
16//!   graph, in that order. Graph-tool descriptions carry the
17//!   prefix `Advanced: ` so a host ranking tools by description puts the task
18//!   tools in front. `mushroomdb mcp --all-tools` lists all twenty-seven; the
19//!   rest are callable either way, just not advertised
20//! - `tools/call` — dispatch; success for a graph tool is
21//!   `{content:[{type:"text", text:<json string>}]}`, and for a task tool one
22//!   text block holding the rendered digest — or, with `json: true`, the
23//!   serialised report. No task tool returns `structuredContent`
24//!
25//! Unknown methods on a **request** (has `id`) → `-32601`. A notification
26//! (no `id` member) never writes a response, including unknown methods.
27//!
28//! # Error split
29//!
30//! Protocol errors are JSON-RPC `error` objects:
31//! - `-32700` parse — unparseable line (invalid JSON / invalid UTF-8)
32//! - `-32600` invalid request — parsed JSON that is not an object, or a
33//!   request with missing / non-string `method`
34//! - `-32601` method — unknown `method` on a request
35//! - `-32602` params — `tools/call` envelope invalid: `params` not an object,
36//!   missing / non-string `name`, `arguments` present but not an object,
37//!   or unknown tool name
38//!
39//! Tool-level failures are JSON-RPC **results** with `isError: true` and a
40//! text message: missing or wrong-typed fields inside a known tool's
41//! `arguments`, and every [`GraphError`] from core-api.
42//!
43//! # Deadlock
44//!
45//! [`SharedDb::read`] / [`SharedDb::write`] guards are held only for the
46//! public core-api call, then dropped before serializing or writing. Do not
47//! nest a second lock on the same handle (the `RwLock` is not re-entrant).
48//!
49//! EOF on `reader` returns `Ok(())`. Read/write I/O errors propagate.
50
51use 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
64/// Run the MCP loop until `reader` hits EOF.
65///
66/// `db_dir` is where the store lives on disk. `mushroomdb mcp <db>` passes it;
67/// a caller that has only a handle passes `None`, and the one tool that needs a
68/// path — `sync`, which re-runs this binary against the store — reports that it
69/// cannot run rather than guessing one.
70pub 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
79/// [`run_mcp_stdio`], with the tool list chosen by the caller.
80///
81/// `all_tools` false lists what the store's [`Surface`] names — three on a
82/// code graph, fifteen on a memory store; true lists all twenty-seven. Either
83/// way every tool remains callable — the flag decides what is advertised, not
84/// what is served.
85///
86/// The surface is read once, here, rather than per `tools/list`: a store does
87/// not become a code graph half way through a session, and a listing that
88/// changed under a host that caches it would be worse than one that is merely
89/// stale.
90pub 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    /// A graph tool's JSON payload, returned as a JSON string in `content`.
195    ToolOk(Js),
196    /// A task tool's answer, as text and nothing else: the rendered digest, or
197    /// the serialised report when the call passed `json: true`.
198    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    // The repository task tools first, in the order `tools/list` advertises.
218    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    // Two ways to ask the same restricted question: a `role` names one the
256    // store already defines, a `mask` writes the allow-list out by hand. Both
257    // route to `query_masked` (read-only). Passing both is not a merge of the
258    // two — it is a caller that has not decided which restriction applies, so
259    // it is refused rather than silently resolved one way.
260    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    // Optional time travel: a 0-based WAL commit index. The graph is read as
277    // of that commit; a `role` is still the role the store defines now, since
278    // `roles.json` is a sidecar and is never a WAL record.
279    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        // Stub mode discloses node existence, which is exactly the question an
293        // as-of read is asking. The two do not compose.
294        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, &params) {
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, &params, 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                // The one error a caller can fix by rereading `roles.json`,
332                // told apart from a store whose roles never loaded at all.
333                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, &params, &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, &params)
359    } else {
360        let g = db.read();
361        g.query(cypher, &params)
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
369/// A `mask` argument as a key list. `Err` when it is anything but an array of
370/// strings — including `null`, which is a caller that meant to pass one.
371fn 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
504/// Insert a new node or update an existing node's properties, keyed by `key`.
505///
506/// If the node exists: each prop in `props` is written via `set_prop`.
507/// If the node does not exist: `label` is required; the node is ingested with
508/// `key_field = "id"` and the supplied props.
509///
510/// Returns `{ok, key, created, updated_fields?}`.
511fn 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
582/// Return neighbors connected by a given edge type (default `"SIMILAR"`).
583///
584/// Results are read from edges already materialized by a derivation rule
585/// (e.g. a `VectorSimilar` rule). Without a matching rule the returned list
586/// is empty — no live cosine computation is performed here.
587/// Returns up to `limit` (default 10) neighbor entries.
588fn tool_find_similar(db: &SharedDb, args: &Js) -> CallOutcome {
589    // Parse the optional mask once — it applies to both vector and edge paths.
590    // An invalid mask value (non-array or non-string element) fails closed.
591    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    // When a `vector` array is provided, use the HNSW / brute-force vector
612    // similarity path instead of looking up pre-derived edges.
613    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    // Edge-traversal path: return neighbors connected by the given edge type.
659    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    // When a mask is present, a hidden query key behaves identically to a
673    // nonexistent key — we do not confirm its existence.
674    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                        // Keep only edges where the neighbor is also visible.
692                        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
901/// The prefix every graph tool's description carries.
902///
903/// A host that ranks tools by their description now has one signal that the
904/// repository task tools are the ones to reach for first, and that everything
905/// under this prefix is the lower-level surface beneath them.
906const ADVANCED_PREFIX: &str = "Advanced: ";
907
908/// The three a code-graph store advertises: one tool to find, one to ask an
909/// arbitrary question, one to size the store.
910///
911/// `ingest_json` is not among them. A store built by `ingest-git` is written by
912/// `sync` and `touch`, not by an assistant bulk-loading rows into it, and the
913/// tool that is never the right one on this surface is the one worth not
914/// listing.
915pub const CODE_GRAPH_TOOLS: [&str; 3] = ["explore", "query", "stats"];
916
917/// The fifteen a memory store advertises, in the order it lists them.
918///
919/// A store with no repository in it used to be handed the code door's own task
920/// tools — `map`, `context`, `impact`, `owners`, `why`, `sync` — which answer
921/// from a code graph there is none of, plus `ingest_json`. Six of the eleven
922/// names an assistant found answered from a repository the store did not
923/// hold. These are
924/// the questions an entity graph *can* answer: what is there (`query` — now
925/// with a `role`), why two things are associated, what is around a node, what
926/// it is and what it is joined to, whether a link held at a commit and when it
927/// changed, what is like it, and what was written down about it.
928///
929/// Listing order is ranking: a host that defers schemas shows this list in
930/// order, so the two questions this door exists for come first.
931///
932/// `edges_at` and `what_if` are the two the first association benchmark run
933/// showed missing: a run asked what a node's relationships were at a past
934/// commit and spent twenty to sixty-seven turns replaying `edge_history` for
935/// it, and had no way at all to ask what a change would do. They sit after
936/// `was_linked`, which is the narrowest form of the same time question.
937///
938/// The code task tools stay served on a memory store, as these stay served on
939/// a code-graph one — [`tools_list`] decides what is *advertised*, never what
940/// is answered.
941pub 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/// Which door a store is: which default tool list it gets.
960///
961/// Decided from the store the server opened, once, at startup — not from an
962/// install flag — so one `.mcp.json` serves both and neither has to be
963/// configured for.
964#[derive(Clone, Copy, Debug, PartialEq, Eq)]
965pub(crate) enum Surface {
966    /// A repository was ingested into this store: the `GitSync` marker is
967    /// there, and `explore` has a code graph to explore.
968    CodeGraph,
969    /// Any other store, including an empty one: the fifteen-tool association
970    /// surface, where `explore` would have nothing to answer from.
971    Memory,
972}
973
974impl Surface {
975    /// The tools a default `tools/list` on this surface advertises, in the
976    /// order it advertises them.
977    fn listing(self) -> &'static [&'static str] {
978        match self {
979            Surface::CodeGraph => &CODE_GRAPH_TOOLS,
980            Surface::Memory => &ASSOCIATION_TOOLS,
981        }
982    }
983}
984
985/// The surface the store `db` holds: [`Surface::CodeGraph`] when it carries the
986/// `GitSync` marker `ingest-git` writes, [`Surface::Memory`] otherwise.
987fn 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
999/// The tools `tools/list` advertises: the fourteen task tools, then the
1000/// graph tools with their descriptions prefixed.
1001///
1002/// `all` false — the default — lists what `surface` names, **in the order that
1003/// surface names it**: three on a code graph, fifteen on a memory store. The
1004/// order is the point. A host that defers tool schemas makes a model search
1005/// for them, and the list it searches is read top-down, so each surface ranks
1006/// its own tools rather than inheriting the task-tools-then-graph-tools order
1007/// that only the code door has a reason for.
1008///
1009/// `all` true lists all twenty-seven in that established order whichever store
1010/// this is, which is what `mushroomdb mcp --all-tools` runs and what the
1011/// published server card documents: a caller that asked for everything asked
1012/// for the whole surface, not for one door's ranking of it.
1013///
1014/// Either way every tool stays callable: the flag and the surface decide what
1015/// is advertised, not what is served.
1016fn 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
1043/// The thirteen graph tools, in the order they have always been listed, with
1044/// their descriptions unprefixed. [`tools_list`] adds the prefix.
1045fn 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
1271/// A task tool's result: one text block, and no `structuredContent`.
1272///
1273/// The report used to ride along beside the digest, repeating it verbatim
1274/// under a `text` key. Nothing bound it — no task tool declares an
1275/// `outputSchema` — and it tripled the size of every reply, so a caller that
1276/// wants the numbers now asks for them with `json: true` and gets the report
1277/// *as* the text.
1278fn 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// ---------------------------------------------------------------------------
1325// Tests: MCP tool round-trips via stdio
1326// ---------------------------------------------------------------------------
1327
1328#[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        // These stores are never cleaned up, so a process id the OS hands out
1340        // again lands on a previous run's data and every assertion about counts
1341        // fails. `tests/mcp.rs::tmp` already clears its path for this reason.
1342        let _ = std::fs::remove_dir_all(&d);
1343        d
1344    }
1345
1346    /// Open a SharedDb with two Person nodes and one derived SIMILAR edge.
1347    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            // Two people with identical embeddings → will fire SIMILAR rule.
1356            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            // Rule: VectorSimilar on emb → SIMILAR edge (cosine(ident,ident)=1.0 ≥ 0.9).
1383            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    /// Unwrap the `text` field from a successful tool response.
1428    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    // --- existing tools ---
1447
1448    #[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            // The fourteen task tools, first and in order.
1463            "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            // The thirteen graph tools.
1478            "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    /// Binding: on a store no repository was ingested into, the default
1524    /// listing is the fifteen association tools, in [`ASSOCIATION_TOOLS`]
1525    /// order, and nothing else.
1526    #[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    /// Binding: [`ASSOCIATION_TOOLS`] is a surface of its own, not the code
1540    /// door's list with a name changed.
1541    ///
1542    /// It keeps the two task tools an entity store can answer with — the notes
1543    /// it wrote and the notes it kept — and none of the seven that read a code
1544    /// graph there is none of. Every name in it is served.
1545    #[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    /// Binding: the same server on a store carrying the `GitSync` marker lists
1580    /// three. One tool to find, one to query, one to size the store.
1581    #[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        // columns + 2 rows
1622        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        // Verify node visible via stats
1658        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    /// Binding: `node_edges` groups by edge type and names the rule and score
1674    /// behind each derived edge, in the report as in the digest.
1675    #[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        // A symmetric rule derives the edge both ways, and both are listed
1697        // with the direction that tells them apart.
1698        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    /// Binding: a depth-1 `neighborhood` is the same relationship listing, and
1721    /// anything deeper is still the traversal table.
1722    #[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        // Derived edges should now exist.
1802        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    // --- new tools ---
1812
1813    #[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        // Verify node exists
1832        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        // Verify prop changed
1854        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    /// When `min` is omitted from a vector-mode find_similar call, the server
1904    /// must apply the spec default of 0.8.  A node whose cosine similarity to
1905    /// the query is 0.0 (orthogonal) must not appear in the results.
1906    #[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            // close: [1,0] → cosine 1.0 with query [1,0] (above 0.8)
1912            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            // far: [0,1] → cosine 0.0 with query [1,0] (below 0.8, must be excluded)
1922            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        // No `min` in the request — must default to 0.8.
1934        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    /// `find_similar` with `mask` must exclude hidden node keys from results.
1961    #[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            // visible: [1,0] — should appear in results.
1967            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            // hidden: [1,0] — same direction as query but must not appear.
1977            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    /// `find_similar` with `mask` — bad mask value returns a tool error.
2017    #[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    /// Edge-traversal mode with `mask` must exclude hidden neighbors.
2038    #[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(); // visible
2045            g.insert_node("P", "carol", vec![]).unwrap(); // hidden
2046            g.insert_edge("KNOWS", "alice", "bob").unwrap();
2047            g.insert_edge("KNOWS", "alice", "carol").unwrap();
2048        }
2049        // Mask: alice and bob visible; carol hidden.
2050        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    /// Edge-traversal mode with `mask`: a hidden query key must not reveal
2075    /// its existence — response must be a tool error identical to a nonexistent key.
2076    #[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        // alice exists but is not in the mask — must look like not-found.
2085        let resp_masked = tool_call(
2086            &db,
2087            1,
2088            "find_similar",
2089            json!({ "key": "alice", "edge_type": "KNOWS", "mask": ["bob"] }),
2090        );
2091        // ghost never exists — use as the reference for "not found".
2092        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        // Both errors must carry the same shape (both are key-not-found).
2107        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    /// Binding: `explain_association` now answers in prose, and the report
2115    /// behind it — what `json: true` returns — is still `explain`'s array,
2116    /// with one `evidence` object added per relationship and every other
2117    /// field unchanged.
2118    #[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    // ── history tools ──────────────────────────────────────────────────────────
2164
2165    /// `edge_history` must return the derived-edge lifecycle (Added event with
2166    /// rule attribution) and include the `total_commits` horizon field.
2167    #[test]
2168    fn test_edge_history_returns_derived_lifecycle_with_rule() {
2169        let db = demo_db(); // alice+bob + sim_emb rule → SIMILAR derived edge
2170        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        // Must carry horizon metadata.
2175        let total = result["total_commits"].as_u64().expect("total_commits");
2176        assert!(total > 0, "total_commits must be > 0 after ingest + rule");
2177
2178        // Must have at least one event (the SIMILAR derived-edge addition).
2179        let events = result["events"].as_array().expect("events array");
2180        assert!(!events.is_empty(), "expected at least one edge event");
2181
2182        // At least one event must be Added with a non-null rule (derived edge).
2183        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    /// `was_linked` must return `true` for an edge that was active at the given commit,
2193    /// and the response must include the echo fields.
2194    #[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        // There are now at least 2 commits (ingest + edge). Check at the last one.
2217        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    /// `was_linked` with an out-of-horizon commit must return a tool error (not
2235    /// a protocol error), and the error message must mention the commit range.
2236    #[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        // Commit 999 is well beyond the WAL.
2245        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        // isError true = tool-level error (not a JSON-RPC protocol error).
2252        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    /// `node_history` tool must return the node's WAL history and the
2264    /// `total_commits` horizon field.
2265    #[test]
2266    fn test_node_history_via_mcp() {
2267        let db = demo_db(); // alice + bob, with a SIMILAR rule
2268        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        // First event should be a NodeInserted.
2283        let first_change = &history[0]["change"];
2284        assert_eq!(first_change["type"], "NodeInserted");
2285        assert_eq!(first_change["label"], "Person");
2286    }
2287}