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 is
13//!   [`ASSOCIATION_TOOLS`], the tools that answer a question about an entity
14//!   graph, in that order, whatever store the server opened. Graph-tool
15//!   descriptions carry the prefix `Advanced: ` so a host ranking tools by
16//!   description puts the task tools in front. `mushroomdb mcp --all-tools`
17//!   lists every served tool; the rest are callable either way, just not
18//!   advertised
19//! - `tools/call` — dispatch; success for a graph tool is
20//!   `{content:[{type:"text", text:<json string>}]}`, and for a task tool one
21//!   text block holding the rendered digest — or, with `json: true`, the
22//!   serialised report. No task tool returns `structuredContent`
23//!
24//! Unknown methods on a **request** (has `id`) → `-32601`. A notification
25//! (no `id` member) never writes a response, including unknown methods.
26//!
27//! # Error split
28//!
29//! Protocol errors are JSON-RPC `error` objects:
30//! - `-32700` parse — unparseable line (invalid JSON / invalid UTF-8)
31//! - `-32600` invalid request — parsed JSON that is not an object, or a
32//!   request with missing / non-string `method`
33//! - `-32601` method — unknown `method` on a request
34//! - `-32602` params — `tools/call` envelope invalid: `params` not an object,
35//!   missing / non-string `name`, `arguments` present but not an object,
36//!   or unknown tool name
37//!
38//! Tool-level failures are JSON-RPC **results** with `isError: true` and a
39//! text message: missing or wrong-typed fields inside a known tool's
40//! `arguments`, and every [`GraphError`] from core-api.
41//!
42//! # Deadlock
43//!
44//! [`SharedDb::read`] / [`SharedDb::write`] guards are held only for the
45//! public core-api call, then dropped before serializing or writing. Do not
46//! nest a second lock on the same handle (the `RwLock` is not re-entrant).
47//!
48//! EOF on `reader` returns `Ok(())`. Read/write I/O errors propagate.
49
50use crate::json::{
51    edge_history_result_json, namespace_arg, node_history_json, node_info_json, params_from_json,
52    parse_ingest_edges, result_set_json, rule_def_from_json, stamp_namespace, stamp_namespace_row,
53};
54use mushroomdb::{
55    json_to_rows, json_to_value, memory, AsOfScope, AutoFk, GraphError, IngestOptions, MaskMode,
56    NodeMask, PropPredicate, SharedDb, Value,
57};
58use serde_json::{json, Value as Js};
59use std::collections::BTreeMap;
60use std::io::{self, BufRead, Write};
61use std::path::{Path, PathBuf};
62
63/// Run the MCP loop until `reader` hits EOF.
64///
65/// `db_dir` is where the store lives on disk. `mushroomdb mcp <db>` passes it;
66/// a caller that has only a handle passes `None`. Its one consumer is
67/// `recall`, which names the store by this path in its digest header and in
68/// the `schema apply` command it suggests when there is no text index; with
69/// `None` it says "store" instead.
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 [`ASSOCIATION_TOOLS`]; true lists every served
82/// tool. Either way every tool remains callable — the flag decides
83/// what is advertised, not what is served.
84pub fn run_mcp_stdio_with(
85    db: SharedDb,
86    db_dir: Option<PathBuf>,
87    all_tools: bool,
88    mut reader: impl BufRead,
89    mut writer: impl Write,
90) -> io::Result<()> {
91    let mut buf = Vec::new();
92    loop {
93        buf.clear();
94        let n = reader.read_until(b'\n', &mut buf)?;
95        if n == 0 {
96            return Ok(());
97        }
98        match std::str::from_utf8(&buf) {
99            Ok(s) if s.trim().is_empty() => continue,
100            Ok(s) => handle_line(&db, db_dir.as_deref(), all_tools, s.trim(), &mut writer)?,
101            Err(_) => write_error(&mut writer, None, -32700, "Parse error")?,
102        }
103    }
104}
105
106fn handle_line(
107    db: &SharedDb,
108    db_dir: Option<&Path>,
109    all_tools: bool,
110    line: &str,
111    writer: &mut impl Write,
112) -> io::Result<()> {
113    let msg: Js = match serde_json::from_str(line) {
114        Ok(v) => v,
115        Err(_) => return write_error(writer, None, -32700, "Parse error"),
116    };
117    let Some(obj) = msg.as_object() else {
118        return write_error(writer, None, -32600, "Invalid Request");
119    };
120    let is_request = obj.contains_key("id");
121    let id = obj.get("id").cloned();
122    let method = match obj.get("method").and_then(Js::as_str) {
123        Some(m) => m,
124        None => {
125            if is_request {
126                write_error(writer, id, -32600, "Invalid Request")?;
127            }
128            return Ok(());
129        }
130    };
131    match method {
132        "initialize" => {
133            if is_request {
134                write_result(writer, id, initialize_result())?;
135            }
136        }
137        "notifications/initialized" => {
138            if is_request {
139                write_result(writer, id, json!({}))?;
140            }
141        }
142        "tools/list" => {
143            if is_request {
144                write_result(writer, id, tools_list(all_tools))?;
145            }
146        }
147        "tools/call" => {
148            if is_request {
149                match dispatch_call(db, db_dir, obj.get("params")) {
150                    CallOutcome::Protocol { code, message } => {
151                        write_error(writer, id, code, &message)?;
152                    }
153                    CallOutcome::ToolOk(payload) => {
154                        write_result(writer, id, tool_ok(payload))?;
155                    }
156                    CallOutcome::TaskOk { text } => {
157                        write_result(writer, id, task_ok(&text))?;
158                    }
159                    CallOutcome::ToolErr(message) => {
160                        write_result(writer, id, tool_err(&message))?;
161                    }
162                }
163            }
164        }
165        _ => {
166            if is_request {
167                write_error(writer, id, -32601, "Method not found")?;
168            }
169        }
170    }
171    Ok(())
172}
173
174pub(crate) enum CallOutcome {
175    Protocol {
176        code: i64,
177        message: String,
178    },
179    /// A graph tool's JSON payload, returned as a JSON string in `content`.
180    ToolOk(Js),
181    /// A task tool's answer, as text and nothing else: the rendered digest, or
182    /// the serialised report when the call passed `json: true`.
183    TaskOk {
184        text: String,
185    },
186    ToolErr(String),
187}
188
189fn dispatch_call(db: &SharedDb, db_dir: Option<&Path>, params: Option<&Js>) -> CallOutcome {
190    let Some(params) = params.and_then(Js::as_object) else {
191        return protocol_invalid();
192    };
193    let Some(name) = params.get("name").and_then(Js::as_str) else {
194        return protocol_invalid();
195    };
196    let empty = json!({});
197    let args = match params.get("arguments") {
198        None => &empty,
199        Some(a) if a.is_object() => a,
200        Some(_) => return protocol_invalid(),
201    };
202    // The task tools first, in the order `tools/list` advertises.
203    if let Some(outcome) = crate::mcp_tasks::dispatch(db, db_dir, name, args) {
204        return outcome;
205    }
206    match name {
207        "query" => tool_query(db, args),
208        "ingest_json" => tool_ingest(db, args),
209        "create_rule" => tool_create_rule(db, args),
210        "explain" => tool_explain(db, args),
211        "stats" => tool_stats(db, args),
212        "node_info" => tool_node_info(db, args),
213        "upsert_entity" => tool_upsert_entity(db, args),
214        "find_similar" => tool_find_similar(db, args),
215        "pairwise_similar" => tool_pairwise_similar(db, args),
216        "hybrid_search" => tool_hybrid_search(db, args),
217        "node_history" => tool_node_history(db, args),
218        "edge_history" => tool_edge_history(db, args),
219        "was_linked" => tool_was_linked(db, args),
220        "rename_node" => tool_rename_node(db, args),
221        _ => protocol_invalid(),
222    }
223}
224
225fn protocol_invalid() -> CallOutcome {
226    CallOutcome::Protocol {
227        code: -32602,
228        message: "Invalid params".into(),
229    }
230}
231
232fn tool_query(db: &SharedDb, args: &Js) -> CallOutcome {
233    let Some(cypher) = args.get("cypher").and_then(Js::as_str) else {
234        return CallOutcome::ToolErr("missing cypher".into());
235    };
236    let params = match params_from_json(args.get("params")) {
237        Ok(p) => p,
238        Err(e) => return CallOutcome::ToolErr(e),
239    };
240
241    // Two ways to ask the same restricted question: a `role` names one the
242    // store already defines, a `mask` writes the allow-list out by hand. Both
243    // route to `query_masked` (read-only). Passing both is not a merge of the
244    // two — it is a caller that has not decided which restriction applies, so
245    // it is refused rather than silently resolved one way.
246    let role = match args.get("role") {
247        None | Some(Js::Null) => None,
248        Some(Js::String(s)) if !s.is_empty() => Some(s.as_str()),
249        Some(_) => return CallOutcome::ToolErr("role must be a non-empty string".into()),
250    };
251    let mask_keys = match args.get("mask") {
252        None => None,
253        Some(v) => match mask_key_list(v) {
254            Ok(keys) => Some(keys),
255            Err(e) => return CallOutcome::ToolErr(e),
256        },
257    };
258    if role.is_some() && mask_keys.is_some() {
259        return CallOutcome::ToolErr("pass role or mask, not both".into());
260    }
261
262    // The second visibility axis. `namespace` is not a third way to say what
263    // `role` and `mask` say — it is a leg that **intersects** whichever of them
264    // is present (and stands alone when neither is), so it can only narrow what
265    // they already allow. A role bound to namespaces honours them with no
266    // argument here; passing one outside the binding is the empty intersection,
267    // never the union.
268    let namespace = match namespace_arg(args.get("namespace")) {
269        Ok(n) => n,
270        Err(e) => return CallOutcome::ToolErr(e),
271    };
272
273    // Optional time travel: a 0-based WAL commit index. The graph is read as
274    // of that commit; a `role` is still the role the store defines now, since
275    // `roles.json` is a sidecar and is never a WAL record.
276    let as_of = match args.get("as_of") {
277        None | Some(Js::Null) => None,
278        Some(v) => match v.as_u64() {
279            Some(n) => Some(n),
280            None => {
281                return CallOutcome::ToolErr(
282                    "as_of must be a non-negative integer commit index".into(),
283                )
284            }
285        },
286    };
287
288    if let Some(commit) = as_of {
289        // Stub mode discloses node existence, which is exactly the question an
290        // as-of read is asking. The two do not compose.
291        if args
292            .get("stub_hidden")
293            .and_then(|v| v.as_bool())
294            .unwrap_or(false)
295        {
296            return CallOutcome::ToolErr(
297                "as_of (time-travel) does not compose with stub_hidden".into(),
298            );
299        }
300        let scope = match (role, &mask_keys) {
301            (Some(role), _) => AsOfScope::Role(role),
302            (None, Some(keys)) => AsOfScope::Keys(keys),
303            (None, None) => match namespace.as_deref() {
304                // A namespace alone is its own as-of scope.
305                Some(ns) => AsOfScope::Namespace(ns),
306                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        };
314        let g = db.read();
315        let out = match (namespace.as_deref(), role.is_some() || mask_keys.is_some()) {
316            // Both legs: the namespace intersects the scope at that commit.
317            (Some(ns), true) => g.query_at_scoped_in_namespace(commit, cypher, &params, scope, ns),
318            _ => g.query_at_scoped(commit, cypher, &params, scope),
319        };
320        return match out {
321            Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
322            Err(GraphError::KeyNotFound { key }) if key.starts_with("role:") => {
323                CallOutcome::ToolErr(format!("unknown role '{}'", &key["role:".len()..]))
324            }
325            Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
326        };
327    }
328
329    if role.is_some() || mask_keys.is_some() || namespace.is_some() {
330        let stub_hidden = args
331            .get("stub_hidden")
332            .and_then(|v| v.as_bool())
333            .unwrap_or(false);
334        let g = db.read();
335        let mask = match (role, &mask_keys) {
336            (Some(role), _) => match g.mask_for_role(role) {
337                Ok(m) => m,
338                // The one error a caller can fix by rereading `roles.json`,
339                // told apart from a store whose roles never loaded at all.
340                Err(GraphError::KeyNotFound { .. }) => {
341                    return CallOutcome::ToolErr(format!("unknown role '{role}'"))
342                }
343                Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
344            },
345            (None, Some(keys)) => NodeMask::from_keys(&*g, keys.iter().map(String::as_str)),
346            // A namespace alone: the namespace leg is the whole mask.
347            (None, None) => g.mask_for_namespace(
348                namespace
349                    .as_deref()
350                    .expect("one of the three is Some in this branch"),
351            ),
352        };
353        // With a role or a client mask present, the namespace is a second leg
354        // intersected into it — the same `NodeMask::intersect` the
355        // role-plus-client-mask path uses, so never-widen holds by construction.
356        let mask = match (namespace.as_deref(), role.is_some() || mask_keys.is_some()) {
357            (Some(ns), true) => mask.intersect(&g.mask_for_namespace(ns)),
358            _ => mask,
359        };
360        let mask = if stub_hidden {
361            mask.with_mode(MaskMode::Stub)
362        } else {
363            mask
364        };
365        return match g.query_masked(cypher, &params, &mask) {
366            Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
367            Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
368        };
369    }
370
371    let is_write = match mushroomdb::is_write_query(cypher) {
372        Ok(b) => b,
373        Err(e) => return CallOutcome::ToolErr(e),
374    };
375    let rs = if is_write {
376        let mut g = db.write();
377        g.query_write(cypher, &params)
378    } else {
379        let g = db.read();
380        g.query(cypher, &params)
381    };
382    match rs {
383        Ok(rs) => CallOutcome::ToolOk(result_set_json(&rs)),
384        Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
385    }
386}
387
388/// A `mask` argument as a key list. `Err` when it is anything but an array of
389/// strings — including `null`, which is a caller that meant to pass one.
390fn mask_key_list(mask: &Js) -> Result<Vec<String>, String> {
391    let arr = mask
392        .as_array()
393        .ok_or_else(|| "mask must be an array of strings".to_string())?;
394    arr.iter()
395        .map(|v| {
396            v.as_str()
397                .map(str::to_string)
398                .ok_or_else(|| "mask must be an array of strings".to_string())
399        })
400        .collect()
401}
402
403fn tool_ingest(db: &SharedDb, args: &Js) -> CallOutcome {
404    let Some(label) = args.get("label").and_then(Js::as_str) else {
405        return CallOutcome::ToolErr("missing label".into());
406    };
407    let Some(rows_json) = args.get("rows_json").and_then(Js::as_str) else {
408        return CallOutcome::ToolErr("missing rows_json".into());
409    };
410    let mut opts = IngestOptions::default();
411    if let Some(kf) = args.get("key_field") {
412        match kf.as_str() {
413            Some(s) => opts.key_field = s.to_string(),
414            None => return CallOutcome::ToolErr("key_field must be a string".into()),
415        }
416    }
417    if let Some(suf) = args.get("auto_fk_suffix") {
418        match suf.as_str() {
419            Some(s) => {
420                opts.auto_fk = AutoFk::Auto {
421                    suffix: s.to_string(),
422                }
423            }
424            None => return CallOutcome::ToolErr("auto_fk_suffix must be a string".into()),
425        }
426    }
427    let edges = match args.get("edges") {
428        None | Some(Js::Null) => Vec::new(),
429        Some(raw) => match parse_ingest_edges(raw) {
430            Ok(e) => e,
431            Err(e) => return CallOutcome::ToolErr(e),
432        },
433    };
434    let parsed: Js = match serde_json::from_str(rows_json) {
435        Ok(v) => v,
436        Err(e) => {
437            return CallOutcome::ToolErr(graph_err_msg(GraphError::IngestError {
438                detail: e.to_string(),
439            }))
440        }
441    };
442    let mut converted = match json_to_rows(&parsed) {
443        Ok(c) => c,
444        Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
445    };
446    // `namespace` applies to every node this call creates.
447    let namespace = match namespace_arg(args.get("namespace")) {
448        Ok(n) => n,
449        Err(e) => return CallOutcome::ToolErr(e),
450    };
451    if let Err(e) = stamp_namespace(&mut converted.rows, namespace.as_deref()) {
452        return CallOutcome::ToolErr(e);
453    }
454    let taken = std::mem::take(&mut converted.rows);
455    let report = {
456        let mut g = db.write();
457        g.ingest_with_edges(label, taken, &opts, &edges)
458    };
459    match report.map(|r| converted.into_report(r)) {
460        Ok(r) => match serde_json::to_value(&r) {
461            Ok(v) => CallOutcome::ToolOk(v),
462            Err(e) => CallOutcome::ToolErr(e.to_string()),
463        },
464        Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
465    }
466}
467
468fn tool_create_rule(db: &SharedDb, args: &Js) -> CallOutcome {
469    let def = match rule_def_from_json(args.clone()) {
470        Ok(d) => d,
471        Err(e) => return CallOutcome::ToolErr(e),
472    };
473    let name = def.name.clone();
474    let res = {
475        let mut g = db.write();
476        g.create_rule(def)
477    };
478    if let Err(e) = res {
479        return CallOutcome::ToolErr(graph_err_msg(e));
480    }
481    // A rule over a corpus too large to index in one commit is installed but
482    // derives nothing yet. Saying "ok" there would tell the caller to go and
483    // query edges that do not exist, so report the build instead — `stats`
484    // carries the same progress under each rule's `building`.
485    let building = db
486        .read()
487        .builds_in_progress()
488        .into_iter()
489        .find(|b| b.rule == name);
490    match building {
491        Some(b) => CallOutcome::ToolOk(json!({
492            "ok": true,
493            "name": name,
494            "building": {"indexed": b.indexed, "total": b.total},
495            "note": format!(
496                "the vector index for {name:?} is still being built ({}/{} vectors); \
497                 this rule derives no edges until it finishes. Every write advances it, \
498                 and `mushroomdb build-index <db-dir>` finishes it now. Poll `stats` — \
499                 the rule's `building` field disappears when its edges are in.",
500                b.indexed, b.total
501            ),
502        })),
503        None => CallOutcome::ToolOk(json!({"ok": true, "name": name})),
504    }
505}
506
507fn tool_explain(db: &SharedDb, args: &Js) -> CallOutcome {
508    let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
509        return CallOutcome::ToolErr("missing a".into());
510    };
511    let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
512        return CallOutcome::ToolErr("missing b".into());
513    };
514    let out = {
515        let g = db.read();
516        g.explain(a, b)
517    };
518    match out {
519        Ok(v) => match serde_json::to_value(&v) {
520            Ok(j) => CallOutcome::ToolOk(j),
521            Err(e) => CallOutcome::ToolErr(e.to_string()),
522        },
523        Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
524    }
525}
526
527/// `stats`, with the namespace roster narrowed when the caller names a role or a
528/// namespace.
529///
530/// The roster is the one part of `stats` that is a list of *other tenants*:
531/// every namespace and its live count. A caller answering as a role should be
532/// told about its own namespaces and no others, so `role` narrows the roster to
533/// the role's binding and `namespace` to that one name. The store-wide counts
534/// beside it are unchanged — they were never per-namespace and narrowing them
535/// would make the two halves of one body disagree.
536fn tool_stats(db: &SharedDb, args: &Js) -> CallOutcome {
537    let role = match args.get("role") {
538        None | Some(Js::Null) => None,
539        Some(Js::String(s)) if !s.is_empty() => Some(s.clone()),
540        Some(_) => return CallOutcome::ToolErr("role must be a non-empty string".into()),
541    };
542    let namespace = match namespace_arg(args.get("namespace")) {
543        Ok(n) => n,
544        Err(e) => return CallOutcome::ToolErr(e),
545    };
546    let (snap, role_def) = {
547        let g = db.read();
548        let def = match &role {
549            Some(r) => {
550                // Resolve through the same resolver `query` uses, so a store
551                // whose `roles.json` was corrupt at open says so here too
552                // instead of reporting the role simply unknown — one answer per
553                // cause, the same one on both tools. The mask is memoised per
554                // (role, commit_seq), so asking costs nothing a `query` with the
555                // same role would not already have paid.
556                if let Err(e) = g.mask_for_role(r) {
557                    return match e {
558                        GraphError::KeyNotFound { .. } => {
559                            CallOutcome::ToolErr(format!("unknown role '{r}'"))
560                        }
561                        other => CallOutcome::ToolErr(graph_err_msg(other)),
562                    };
563                }
564                g.roles().into_iter().find(|d| &d.name == r)
565            }
566            None => None,
567        };
568        (g.stats(), def)
569    };
570    let mut snap = snap;
571    // Scoped on the ARGUMENTS, not on `role_def`: naming a role that resolved
572    // is a scoped call even in the window where the definition lookup misses,
573    // and the safe direction there is to omit the roster rather than send all
574    // of it.
575    let scoped = role.is_some() || namespace.is_some();
576    if scoped {
577        snap.namespaces.retain(|n| {
578            role_def.as_ref().is_none_or(|d| d.sees_namespace(&n.name))
579                && namespace.as_deref().is_none_or(|ns| ns == n.name)
580        });
581    }
582    match serde_json::to_value(&snap) {
583        Ok(mut v) => {
584            // An unscoped caller gets the store-wide counts without the roster.
585            // Omitted, not emptied: `"namespaces": []` still discloses that the
586            // roster exists and invites a guess at its size.
587            if !scoped {
588                if let Some(obj) = v.as_object_mut() {
589                    obj.remove("namespaces");
590                }
591            }
592            CallOutcome::ToolOk(v)
593        }
594        Err(e) => CallOutcome::ToolErr(e.to_string()),
595    }
596}
597
598fn tool_node_info(db: &SharedDb, args: &Js) -> CallOutcome {
599    let Some(key) = args.get("key").and_then(Js::as_str) else {
600        return CallOutcome::ToolErr("missing key".into());
601    };
602    let info = {
603        let g = db.read();
604        g.node_info(key)
605    };
606    match info {
607        Some(info) => CallOutcome::ToolOk(node_info_json(&info)),
608        None => CallOutcome::ToolErr(graph_err_msg(GraphError::KeyNotFound {
609            key: key.to_string(),
610        })),
611    }
612}
613
614/// Insert a new node or update an existing node's properties, keyed by `key`.
615///
616/// The policy and the write are [`memory::remember::upsert_entity`] — the one
617/// implementation this tool and the Python binding share — which also clears
618/// a provisional mark (`memory_schema::PROVISIONAL_PROP`) on `key` if it had
619/// one, whoever set it. This function parses the arguments and shapes the
620/// reply.
621///
622/// If the node exists: every supplied property is checked (reserved names, the
623/// `ns` rule, a view-owned field, type) and then all of them are written in one
624/// engine commit — a refusal leaves the node unchanged. If the node does not
625/// exist: `label` is required; the node is created and then given the
626/// supplied props, `id` among them.
627///
628/// `namespace` is the namespace a node this call **creates** is created in. On a
629/// node that already exists it is written like any other property, which is what
630/// makes naming the namespace the node is already in a no-op and naming another
631/// one the engine's `NamespaceImmutable` refusal — one rule, stated once, in the
632/// place that owns it. A no-op `ns` writes nothing and is not counted in
633/// `updated_fields`, because nothing was updated.
634///
635/// `id` in `props` is **dropped on both paths**: it is the node's key. The create
636/// path stores `id` from `key`, and `rename_node` is the only way to change it.
637/// One row builder now serves the create and the update path, so the rule is the
638/// same on both — before v0.6.6 the update path wrote `props.id` straight through
639/// `set_prop`, which could leave a stored `id` disagreeing with the key the node
640/// is reached by, while the create path had always ignored it.
641///
642/// A node's **label never changes on an update** — the engine has no
643/// label-mutation path at all, and `describe_entity` uses `label` only on
644/// create. A `label` that names something else on an existing `key` is a
645/// refusal, checked before anything is written: nothing in `props` is
646/// touched either, matching every other pre-write check this tool makes (a
647/// namespace mismatch, a view-owned field). Before fix round 2 this was
648/// silently accepted and dropped — every update, not only a provisional
649/// stub's — which is worse than it sounds for the one case the feature
650/// exists for: a provisional entity `remember` created is *always* `Entity`,
651/// permanently, and `{"created":false,"ok":true}` beside a quietly-ignored
652/// `label` is not a signal an agent acts on. There is a working path instead
653/// — `remember`'s `entities`, at first mention, produces a correctly
654/// labelled, non-provisional node with its `ABOUT` edge intact — so refusing
655/// costs nothing a caller could not already avoid.
656///
657/// Returns `{ok, key, label, created, updated_fields?}`.
658fn tool_upsert_entity(db: &SharedDb, args: &Js) -> CallOutcome {
659    let Some(key) = args.get("key").and_then(Js::as_str) else {
660        return CallOutcome::ToolErr("missing key".into());
661    };
662    let label_opt = args.get("label").and_then(Js::as_str);
663    let Some(props_obj) = args.get("props").and_then(Js::as_object) else {
664        return CallOutcome::ToolErr("missing props".into());
665    };
666    let namespace = match namespace_arg(args.get("namespace")) {
667        Ok(n) => n,
668        Err(e) => return CallOutcome::ToolErr(e),
669    };
670    let aliases: Vec<String> = match args.get("aliases") {
671        None | Some(Js::Null) => Vec::new(),
672        Some(Js::Array(items)) => match items
673            .iter()
674            .map(|v| v.as_str().map(str::to_string))
675            .collect::<Option<Vec<_>>>()
676        {
677            Some(a) => a,
678            None => return CallOutcome::ToolErr("aliases must be an array of strings".into()),
679        },
680        Some(_) => return CallOutcome::ToolErr("aliases must be an array of strings".into()),
681    };
682
683    // One row, stamped with the namespace, whichever path takes it: the
684    // conflict rule between an explicit `props.ns` and `namespace` is then the
685    // same one `ingest_json` applies.
686    let mut row: BTreeMap<String, Value> = BTreeMap::new();
687    for (field, json_val) in props_obj {
688        if field == "id" {
689            continue;
690        }
691        match json_to_value(json_val.clone()) {
692            Ok(Some(v)) => {
693                row.insert(field.clone(), v);
694            }
695            Ok(None) => {
696                return CallOutcome::ToolErr(format!("prop {field} is not a supported value type"))
697            }
698            Err(e) => return CallOutcome::ToolErr(format!("prop {field}: {e}")),
699        }
700    }
701    if let Some(ns) = namespace.as_deref() {
702        if let Err(e) = stamp_namespace_row(&mut row, ns) {
703            return CallOutcome::ToolErr(e);
704        }
705    }
706
707    // One write guard for the existence check and the write: the policy —
708    // label on create, no relabel, the namespace no-op, `id` from the key —
709    // is `core-api`'s, shared with the Python binding.
710    let outcome = {
711        let mut g = db.write();
712        match memory::remember::upsert_entity(&mut *g, key, label_opt, row, &aliases) {
713            Ok(o) => o,
714            Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
715        }
716    };
717    if outcome.created {
718        return CallOutcome::ToolOk(json!({
719            "ok": true,
720            "key": key,
721            "label": outcome.label,
722            "created": true
723        }));
724    }
725    let mut reply = json!({
726        "ok": true,
727        "key": key,
728        "label": outcome.label,
729        "created": false,
730        "updated_fields": outcome.updated_fields
731    });
732    // Absent when nothing was lost, so an ordinary update's reply is
733    // unchanged.
734    let lost = &outcome.same_as_lost;
735    if !lost.is_empty() {
736        reply["same_as_lost"] = crate::mcp_tasks::same_as_lost_json(lost);
737        reply["same_as_lost_total"] = json!(lost.len());
738        reply["same_as_lost_note"] = json!(format!(
739            "this write retracted {} SAME_AS link(s): {}",
740            lost.len(),
741            crate::mcp_tasks::SAME_AS_LOST_REMEDY
742        ));
743    }
744    // Absent unless the read-back failed, so an ordinary reply is unchanged.
745    if let Some(unread) = &outcome.same_as_unread {
746        reply["same_as_unread"] = json!(unread);
747        reply["same_as_unread_note"] = json!(format!(
748            "this write committed, but its SAME_AS links could not be read afterwards, \
749             so same_as_lost is not known: {unread}"
750        ));
751    }
752    CallOutcome::ToolOk(reply)
753}
754
755/// Return neighbors connected by a given edge type (default `"SIMILAR"`).
756///
757/// Results are read from edges already materialized by a derivation rule
758/// (e.g. a `VectorSimilar` rule). Without a matching rule the returned list
759/// is empty — no live cosine computation is performed here.
760/// Returns up to `limit` (default 10) neighbor entries.
761fn tool_find_similar(db: &SharedDb, args: &Js) -> CallOutcome {
762    // Parse the optional mask once — it applies to both vector and edge paths.
763    // An invalid mask value (non-array or non-string element) fails closed.
764    let mask_keys: Option<Vec<String>> = if let Some(mask_val) = args.get("mask") {
765        match mask_val.as_array() {
766            Some(arr) => {
767                let mut ks: Vec<String> = Vec::with_capacity(arr.len());
768                for v in arr {
769                    match v.as_str() {
770                        Some(s) => ks.push(s.to_string()),
771                        None => {
772                            return CallOutcome::ToolErr("mask must be an array of strings".into())
773                        }
774                    }
775                }
776                Some(ks)
777            }
778            None => return CallOutcome::ToolErr("mask must be an array of strings".into()),
779        }
780    } else {
781        None
782    };
783
784    // When a `vector` array is provided, use the HNSW / brute-force vector
785    // similarity path instead of looking up pre-derived edges.
786    let vector = match vector_arg(args) {
787        Ok(v) => v,
788        Err(e) => return CallOutcome::ToolErr(e),
789    };
790    if let Some(q) = vector {
791        if q.is_empty() {
792            return CallOutcome::ToolErr("vector must be a non-empty array of numbers".into());
793        }
794        let field = args
795            .get("field")
796            .and_then(Js::as_str)
797            .unwrap_or("embedding");
798        let label_str = args.get("label").and_then(Js::as_str).unwrap_or("");
799        let label = if label_str.is_empty() {
800            None
801        } else {
802            Some(label_str)
803        };
804        // Absent or null takes the default; anything else that is not the
805        // number asked for is refused, as HTTP refuses it.
806        let k = match args.get("k") {
807            None | Some(Js::Null) => 10usize,
808            Some(v) => match v.as_u64() {
809                Some(n) => n as usize,
810                None => return CallOutcome::ToolErr("k must be a non-negative integer".into()),
811            },
812        };
813        let min = match args.get("min") {
814            None | Some(Js::Null) => mushroomdb::FIND_SIMILAR_DEFAULT_MIN,
815            Some(v) => match v.as_f64() {
816                Some(n) => n,
817                None => return CallOutcome::ToolErr("min must be a number".into()),
818            },
819        };
820        let where_pred = match parse_where_arg(args) {
821            Ok(p) => p,
822            Err(e) => return CallOutcome::ToolErr(e),
823        };
824        let exact = match args.get("exact") {
825            None => false,
826            Some(v) => match v.as_bool() {
827                Some(b) => b,
828                None => return CallOutcome::ToolErr("exact must be a boolean".into()),
829            },
830        };
831        let exact = exact || where_pred.is_some();
832
833        let hits = {
834            let g = db.read();
835            let node_mask = mask_keys
836                .as_ref()
837                .map(|keys| NodeMask::from_keys(&*g, keys.iter().map(String::as_str)));
838            match g.find_similar_vector_filtered(
839                field,
840                label,
841                &q,
842                k,
843                min,
844                node_mask.as_ref(),
845                where_pred.as_ref(),
846                exact,
847            ) {
848                Ok(h) => h,
849                Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
850            }
851        };
852        let results: Vec<Js> = hits
853            .into_iter()
854            .map(|(key, score)| json!({ "key": key, "score": score }))
855            .collect();
856        return CallOutcome::ToolOk(json!({
857            "mode": "vector",
858            "field": field,
859            "label": label,
860            "k": k,
861            "min": min,
862            "results": results
863        }));
864    }
865
866    // Edge-traversal path: return neighbors connected by the given edge type.
867    let Some(key) = args.get("key").and_then(Js::as_str) else {
868        return CallOutcome::ToolErr("missing key (or provide vector for vector search)".into());
869    };
870    let edge_type = args
871        .get("edge_type")
872        .and_then(Js::as_str)
873        .unwrap_or("SIMILAR");
874    let limit = match args.get("limit") {
875        None | Some(Js::Null) => 10usize,
876        Some(v) => match v.as_u64() {
877            Some(n) => n as usize,
878            None => return CallOutcome::ToolErr("limit must be a non-negative integer".into()),
879        },
880    };
881
882    // When a mask is present, a hidden query key behaves identically to a
883    // nonexistent key — we do not confirm its existence.
884    if let Some(ref mask) = mask_keys {
885        let mask_set: std::collections::HashSet<&str> = mask.iter().map(String::as_str).collect();
886        if !mask_set.contains(key) {
887            return CallOutcome::ToolErr(graph_err_msg(GraphError::KeyNotFound {
888                key: key.into(),
889            }));
890        }
891        let out = {
892            let g = db.read();
893            g.node_edges(key)
894        };
895        return match out {
896            Ok(edges) => {
897                let similar: Vec<Js> = edges
898                    .iter()
899                    .filter(|e| e.edge_type == edge_type)
900                    .filter(|e| {
901                        // Keep only edges where the neighbor is also visible.
902                        let neighbor_key = if e.src_key == key {
903                            &e.dst_key
904                        } else {
905                            &e.src_key
906                        };
907                        mask_set.contains(neighbor_key.as_str())
908                    })
909                    .take(limit)
910                    .map(|e| {
911                        let neighbor_key = if e.src_key == key {
912                            &e.dst_key
913                        } else {
914                            &e.src_key
915                        };
916                        let direction = if e.src_key == key { "out" } else { "in" };
917                        json!({
918                            "neighbor_key": neighbor_key,
919                            "direction": direction,
920                            "edge_type": e.edge_type,
921                            "derived": e.derived,
922                        })
923                    })
924                    .collect();
925                CallOutcome::ToolOk(json!({
926                    "key": key,
927                    "edge_type": edge_type,
928                    "similar": similar
929                }))
930            }
931            Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
932        };
933    }
934
935    let out = {
936        let g = db.read();
937        g.node_edges(key)
938    };
939    match out {
940        Ok(edges) => {
941            let similar: Vec<Js> = edges
942                .iter()
943                .filter(|e| e.edge_type == edge_type)
944                .take(limit)
945                .map(|e| {
946                    let neighbor_key = if e.src_key == key {
947                        &e.dst_key
948                    } else {
949                        &e.src_key
950                    };
951                    let direction = if e.src_key == key { "out" } else { "in" };
952                    json!({
953                        "neighbor_key": neighbor_key,
954                        "direction": direction,
955                        "edge_type": e.edge_type,
956                        "derived": e.derived,
957                    })
958                })
959                .collect();
960            CallOutcome::ToolOk(json!({
961                "key": key,
962                "edge_type": edge_type,
963                "similar": similar
964            }))
965        }
966        Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
967    }
968}
969
970/// The `vector` argument: `None` when absent or null, the numbers when it is
971/// an array of numbers, an error otherwise. An element that is not a number is
972/// never dropped (`[1, "x", 0]` is not `[1, 0]`), and a `vector` that is not an
973/// array is never taken for absent.
974fn vector_arg(args: &Js) -> Result<Option<Vec<f64>>, String> {
975    match args.get("vector") {
976        None | Some(Js::Null) => Ok(None),
977        Some(Js::Array(items)) => items
978            .iter()
979            .map(|v| {
980                v.as_f64()
981                    .ok_or_else(|| "vector elements must all be numbers".to_string())
982            })
983            .collect::<Result<Vec<_>, _>>()
984            .map(Some),
985        Some(_) => Err("vector must be an array of numbers".to_string()),
986    }
987}
988
989/// A non-negative integer argument. Absent or null takes `default`; present
990/// and not that integer is an error naming the argument, never the default.
991fn count_arg(args: &Js, name: &str, default: usize) -> Result<usize, String> {
992    match args.get(name) {
993        None | Some(Js::Null) => Ok(default),
994        Some(v) => v
995            .as_u64()
996            .map(|n| n as usize)
997            .ok_or_else(|| format!("{name} must be a non-negative integer")),
998    }
999}
1000
1001/// A number argument, under the same rule as [`count_arg`].
1002fn number_arg(args: &Js, name: &str, default: f64) -> Result<f64, String> {
1003    match args.get(name) {
1004        None | Some(Js::Null) => Ok(default),
1005        Some(v) => v.as_f64().ok_or_else(|| format!("{name} must be a number")),
1006    }
1007}
1008
1009/// Exact cosine top-k among a caller key set. Self excluded. Never HNSW.
1010fn tool_pairwise_similar(db: &SharedDb, args: &Js) -> CallOutcome {
1011    let Some(keys_js) = args.get("keys").and_then(Js::as_array) else {
1012        return CallOutcome::ToolErr("missing required field: keys".into());
1013    };
1014    let mut keys: Vec<String> = Vec::with_capacity(keys_js.len());
1015    for v in keys_js {
1016        match v.as_str() {
1017            Some(s) => keys.push(s.to_string()),
1018            None => return CallOutcome::ToolErr("keys must be an array of strings".into()),
1019        }
1020    }
1021    let Some(field) = args.get("field").and_then(Js::as_str) else {
1022        return CallOutcome::ToolErr("missing required field: field".into());
1023    };
1024    let k = match count_arg(args, "k", 10) {
1025        Ok(k) => k,
1026        Err(e) => return CallOutcome::ToolErr(e),
1027    };
1028    let min = match number_arg(args, "min", 0.0) {
1029        Ok(min) => min,
1030        Err(e) => return CallOutcome::ToolErr(e),
1031    };
1032    let refs: Vec<&str> = keys.iter().map(String::as_str).collect();
1033    let out = {
1034        let g = db.read();
1035        g.pairwise_similar(&refs, field, k, min)
1036    };
1037    match out {
1038        Ok(pairs) => {
1039            let results: Vec<Js> = pairs
1040                .into_iter()
1041                .map(|(key, neighbors)| {
1042                    json!({
1043                        "key": key,
1044                        "neighbors": neighbors
1045                            .into_iter()
1046                            .map(|(n, score)| json!({ "key": n, "score": score }))
1047                            .collect::<Vec<_>>(),
1048                    })
1049                })
1050                .collect();
1051            CallOutcome::ToolOk(json!({
1052                "field": field,
1053                "k": k,
1054                "min": min,
1055                "results": results
1056            }))
1057        }
1058        Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1059    }
1060}
1061
1062fn tool_hybrid_search(db: &SharedDb, args: &Js) -> CallOutcome {
1063    let Some(query_text) = args.get("query_text").and_then(Js::as_str) else {
1064        return CallOutcome::ToolErr("missing required field: query_text".into());
1065    };
1066    let Some(text_field) = args.get("text_field").and_then(Js::as_str) else {
1067        return CallOutcome::ToolErr("missing required field: text_field".into());
1068    };
1069
1070    let vector_field = args
1071        .get("vector_field")
1072        .and_then(Js::as_str)
1073        .unwrap_or("embedding");
1074    let label = args.get("label").and_then(Js::as_str);
1075    let k = match count_arg(args, "k", 10) {
1076        Ok(k) => k,
1077        Err(e) => return CallOutcome::ToolErr(e),
1078    };
1079
1080    let query_vec: Vec<f64> = match vector_arg(args) {
1081        Ok(v) => v.unwrap_or_default(),
1082        Err(e) => return CallOutcome::ToolErr(e),
1083    };
1084
1085    let hits = {
1086        let g = db.read();
1087        g.search_hybrid(text_field, query_text, vector_field, &query_vec, label, k)
1088    };
1089
1090    let results: Vec<Js> = hits
1091        .into_iter()
1092        .map(|(key, score)| json!({ "key": key, "score": score }))
1093        .collect();
1094
1095    CallOutcome::ToolOk(json!({
1096        "query_text": query_text,
1097        "text_field": text_field,
1098        "vector_field": vector_field,
1099        "label": label,
1100        "k": k,
1101        "results": results
1102    }))
1103}
1104
1105fn tool_node_history(db: &SharedDb, args: &Js) -> CallOutcome {
1106    let Some(key) = args.get("key").and_then(Js::as_str) else {
1107        return CallOutcome::ToolErr("missing key".into());
1108    };
1109    let g = db.read();
1110    let result = match g.node_history(key) {
1111        Ok(e) => e,
1112        Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
1113    };
1114    CallOutcome::ToolOk(node_history_json(key, &result))
1115}
1116
1117fn tool_edge_history(db: &SharedDb, args: &Js) -> CallOutcome {
1118    let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1119        return CallOutcome::ToolErr("missing a".into());
1120    };
1121    let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1122        return CallOutcome::ToolErr("missing b".into());
1123    };
1124    let result = {
1125        let g = db.read();
1126        g.edge_history(a, b)
1127    };
1128    match result {
1129        Ok(hr) => CallOutcome::ToolOk(edge_history_result_json(a, b, &hr)),
1130        Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1131    }
1132}
1133
1134fn tool_was_linked(db: &SharedDb, args: &Js) -> CallOutcome {
1135    let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1136        return CallOutcome::ToolErr("missing a".into());
1137    };
1138    let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1139        return CallOutcome::ToolErr("missing b".into());
1140    };
1141    let Some(edge_type) = args
1142        .get("edge_type")
1143        .and_then(Js::as_str)
1144        .filter(|s| !s.is_empty())
1145    else {
1146        return CallOutcome::ToolErr("missing edge_type".into());
1147    };
1148    // Like `edges_at`, `at_commit` takes a date as well as an index. A caller
1149    // asking "were they linked on 2026-06-19" should not have to find the
1150    // commit themselves.
1151    let at_commit = match args.get("at_commit") {
1152        Some(Js::String(date)) => match db.read().resolve_date(date) {
1153            Ok(c) => c,
1154            Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
1155        },
1156        Some(v) => match v.as_u64() {
1157            Some(n) => n,
1158            None => {
1159                return CallOutcome::ToolErr(
1160                    "at_commit must be a non-negative commit index or an RFC 3339 date".into(),
1161                )
1162            }
1163        },
1164        None => return CallOutcome::ToolErr("missing or invalid at_commit".into()),
1165    };
1166    let result = {
1167        let g = db.read();
1168        g.was_linked(a, b, edge_type, at_commit)
1169    };
1170    match result {
1171        Ok(linked) => CallOutcome::ToolOk(json!({
1172            "a": a,
1173            "b": b,
1174            "edge_type": edge_type,
1175            "at_commit": at_commit,
1176            "linked": linked,
1177        })),
1178        Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1179    }
1180}
1181
1182fn tool_rename_node(db: &SharedDb, args: &Js) -> CallOutcome {
1183    let Some(old_key) = args.get("old_key").and_then(Js::as_str) else {
1184        return CallOutcome::ToolErr("missing old_key".into());
1185    };
1186    let Some(new_key) = args.get("new_key").and_then(Js::as_str) else {
1187        return CallOutcome::ToolErr("missing new_key".into());
1188    };
1189    let mut g = db.write();
1190    match g.rename_node(old_key, new_key) {
1191        Ok(()) => CallOutcome::ToolOk(json!({
1192            "ok": true,
1193            "old_key": old_key,
1194            "new_key": new_key,
1195        })),
1196        Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1197    }
1198}
1199
1200fn parse_where_arg(args: &Js) -> std::result::Result<Option<PropPredicate>, String> {
1201    let Some(w) = args.get("where") else {
1202        return Ok(None);
1203    };
1204    if w.is_null() {
1205        return Ok(None);
1206    }
1207    let pred: PropPredicate =
1208        serde_json::from_value(w.clone()).map_err(|e| format!("where: {e}"))?;
1209    pred.validate_named("where")?;
1210    Ok(Some(pred))
1211}
1212
1213pub(crate) fn graph_err_msg(e: GraphError) -> String {
1214    match e {
1215        GraphError::QueryError { detail } | GraphError::IngestError { detail } => detail,
1216        // The detail names paths and uids; it goes to the server's log only.
1217        GraphError::Unwritable { path, detail, .. } => {
1218            tracing::error!(path = %path, "{detail}");
1219            GraphError::UNWRITABLE_CLIENT_MESSAGE.to_string()
1220        }
1221        other => other.to_string(),
1222    }
1223}
1224
1225fn initialize_result() -> Js {
1226    json!({
1227        "protocolVersion": "2024-11-05",
1228        "capabilities": { "tools": {} },
1229        "serverInfo": { "name": "mushroomdb", "version": env!("CARGO_PKG_VERSION") }
1230    })
1231}
1232
1233/// The prefix every graph tool's description carries.
1234///
1235/// A host that ranks tools by their description now has one signal that the
1236/// task tools are the ones to reach for first, and that everything
1237/// under this prefix is the lower-level surface beneath them.
1238const ADVANCED_PREFIX: &str = "Advanced: ";
1239
1240/// The tools every store advertises, in the order it lists them.
1241///
1242/// A store with no repository in it used to be handed the code door's own task
1243/// tools, which answered from a code graph there was none of. Those tools are
1244/// gone, and a store `ingest-git` built is an entity store like any other, so
1245/// it is served this same listing. These are
1246/// the questions an entity graph *can* answer: what is there (`query` — now
1247/// with a `role`), why two things are associated, what is around a node, what
1248/// it is and what it is joined to, whether a link held at a commit and when it
1249/// changed, what is like it, and what was written down about it.
1250///
1251/// Listing order is ranking: a host that defers schemas shows this list in
1252/// order, so the two questions this door exists for come first.
1253///
1254/// `edges_at` and `what_if` are the two the first association benchmark run
1255/// showed missing: a run asked what a node's relationships were at a past
1256/// commit and spent twenty to sixty-seven turns replaying `edge_history` for
1257/// it, and had no way at all to ask what a change would do. They sit after
1258/// `was_linked`, which is the narrowest form of the same time question.
1259///
1260/// [`tools_list`] decides what is *advertised*, never what is answered: the
1261/// two served tools this leaves out, `explain` and `rename_node`, stay callable.
1262///
1263/// `upsert_entity`, `ingest_json` and `create_rule` were added in 0.6.12, and
1264/// the reason is the shape of a first session. A new install opens on an empty
1265/// store; the skill's first instruction is to offer to fill it, naming
1266/// `ingest_json` for a batch and `upsert_entity` for one; and the README sells
1267/// `upsert_entity -> create_rule -> find_similar -> explain_association` as the
1268/// minimal workflow. Two of those four were served and not advertised — and a
1269/// host builds its tool set from `tools/list`, so "served" did not help. The
1270/// listing described a store you could question but never populate.
1271///
1272/// They sit after the readers deliberately. Listing order is ranking and the
1273/// questions remain the point; writing is what a session does once, at the
1274/// start, before it has anything to ask.
1275pub const ASSOCIATION_TOOLS: [&str; 23] = [
1276    "query",
1277    "explain_association",
1278    "neighborhood",
1279    "node_info",
1280    "node_edges",
1281    "was_linked",
1282    "edges_at",
1283    "what_if",
1284    "node_history",
1285    "edge_history",
1286    "find_similar",
1287    "pairwise_similar",
1288    "hybrid_search",
1289    "remember",
1290    "recall",
1291    "upsert_entity",
1292    "ingest_json",
1293    "create_rule",
1294    "stats",
1295    "schema",
1296    "analyze",
1297    "suggest_rules",
1298    "forget",
1299];
1300
1301/// The tools `tools/list` advertises: the task tools, then the
1302/// graph tools with their descriptions prefixed.
1303///
1304/// `all` false — the default — lists [`ASSOCIATION_TOOLS`], **in the order it
1305/// names them**. The order is the point. A host that defers tool schemas makes
1306/// a model search for them, and the list it searches is read top-down.
1307///
1308/// `all` true lists every served tool, task tools first and graph tools after,
1309/// which is what `mushroomdb mcp --all-tools` runs and what the published
1310/// server card documents.
1311///
1312/// Either way every tool stays callable: the flag decides what is advertised,
1313/// not what is served.
1314fn tools_list(all: bool) -> Js {
1315    let mut served: Vec<Js> = crate::mcp_tasks::task_tools();
1316    for mut tool in graph_tools() {
1317        if let Some(d) = tool.get("description").and_then(Js::as_str) {
1318            let prefixed = format!("{ADVANCED_PREFIX}{d}");
1319            tool["description"] = Js::String(prefixed);
1320        }
1321        served.push(tool);
1322    }
1323    if all {
1324        return json!({ "tools": served });
1325    }
1326    let mut tools: Vec<Js> = Vec::with_capacity(ASSOCIATION_TOOLS.len());
1327    for name in ASSOCIATION_TOOLS {
1328        let Some(tool) = served
1329            .iter()
1330            .find(|t| t.get("name").and_then(Js::as_str) == Some(name))
1331        else {
1332            debug_assert!(false, "ASSOCIATION_TOOLS lists {name}, which is not served");
1333            continue;
1334        };
1335        tools.push(tool.clone());
1336    }
1337    json!({ "tools": tools })
1338}
1339
1340/// The fourteen graph tools, in the order they have always been listed, with
1341/// their descriptions unprefixed. [`tools_list`] adds the prefix.
1342fn graph_tools() -> Vec<Js> {
1343    let Js::Array(tools) = json!([
1344            {
1345                "name": "query",
1346                "description": "Who may see this, and anything else one pattern can answer — run a Cypher query (read or write) against the graph. Pass 'role' to answer as one of the store's roles: only the nodes that role may see, writes refused. 'mask' is the same restriction written out as an explicit key allow-list. Pass 'as_of' to answer from a past commit; it composes with 'role' or with 'mask'. Pass 'namespace' to answer from one namespace only. Cypher dialect: MATCH/WHERE/RETURN, CREATE, MERGE, SET, DELETE, with $named parameters in 'params'. A node's key and label read as properties (n.key, n.label) or as key(n)/labels(n). One MATCH takes comma-separated patterns that share variables — MATCH (t)-[:A]->(c), (t)-[:B]->(c) is the intersection of both, and count(DISTINCT t) after WITH counts each t once. WHERE takes STARTS WITH, ENDS WITH, CONTAINS, IN, and a list subscript (n.location[0]) — which is null when the index is out of range or the list is null, and an error when the property is not a list or map or the index is not an integer.",
1347                "inputSchema": {
1348                    "type": "object",
1349                    "properties": {
1350                        "cypher": { "type": "string", "description": "Cypher query text." },
1351                        "params": {
1352                            "type": "object",
1353                            "description": "Named JSON-scalar query parameters."
1354                        },
1355                        "mask": {
1356                            "type": "array",
1357                            "items": { "type": "string" },
1358                            "description": "Optional node key allow-list. When present, only these nodes are visible; write statements are rejected."
1359                        },
1360                        "role": {
1361                            "type": "string",
1362                            "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."
1363                        },
1364                        "as_of": {
1365                            "type": "integer",
1366                            "minimum": 0,
1367                            "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."
1368                        },
1369                        "namespace": {
1370                            "type": "string",
1371                            "description": "Answer only from this namespace. Intersects with 'role' and 'mask' — it can only narrow what they already allow. A role bound to namespaces honours them with no argument here. 'default' is the namespace of every node that names none; a name no node uses answers with nothing."
1372                        }
1373                    },
1374                    "required": ["cypher"]
1375                }
1376            },
1377            {
1378                "name": "ingest_json",
1379                "description": "Fill the store from a batch — ingest a JSON array of objects as nodes of one label. Each object becomes a node; declare the rules that should link them with 'create_rule'.",
1380                "inputSchema": {
1381                    "type": "object",
1382                    "properties": {
1383                        "label": { "type": "string" },
1384                        "rows_json": {
1385                            "type": "string",
1386                            "description": "JSON text of an array of objects."
1387                        },
1388                        "key_field": { "type": "string" },
1389                        "auto_fk_suffix": { "type": "string" },
1390                        "edges": {
1391                            "type": "array",
1392                            "description": "Optional user edges [{edge_type, src, dst}]."
1393                        },
1394                        "namespace": {
1395                            "type": "string",
1396                            "description": "Namespace for every node this call creates. Omitted means the 'default' namespace. A row that carries its own 'ns' must name the same namespace. A namespace is set at insert and cannot be changed afterwards."
1397                        }
1398                    },
1399                    "required": ["label", "rows_json"]
1400                }
1401            },
1402            {
1403                "name": "create_rule",
1404                "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.",
1405                "inputSchema": {
1406                    "type": "object",
1407                    "properties": {
1408                        "name": { "type": "string" },
1409                        "src_label": { "type": "string" },
1410                        "dst_label": { "type": "string" },
1411                        "predicate": { "type": "object" },
1412                        "edge_type": { "type": "string" },
1413                        "weight_prop": {
1414                            "type": ["string", "null"],
1415                            "description": "Edge property that stores the score (default: weight)."
1416                        },
1417                        "max_edges": { "type": ["integer", "null"] },
1418                        "approximate": {
1419                            "type": "boolean",
1420                            "description": "Find candidates through the HNSW index instead of comparing every pair (default: false). Only for a VectorSimilar predicate; the edges are approximate, not exact."
1421                        },
1422                        "via_label": {
1423                            "type": "string",
1424                            "description": "Optional intermediate hop: the label of the via node. Give it together with via_edge. The predicate is then evaluated between the via node and the destination, and the derived edge still runs source to destination."
1425                        },
1426                        "via_edge": {
1427                            "type": "string",
1428                            "description": "Optional intermediate hop: the edge type followed from the source to reach a via node. Give it together with via_label."
1429                        },
1430                        "via_dir": {
1431                            "type": "string",
1432                            "enum": ["Out", "In"],
1433                            "description": "Direction to follow via_edge from the source (default: Out, source to via)."
1434                        },
1435                        "namespace": {
1436                            "type": "string",
1437                            "description": "Scope the rule to one namespace: it sees only that namespace's nodes — source, via hop and destination — so every edge it derives stays inside. Omitted means a global rule, which is the only kind that may derive an edge across a boundary."
1438                        }
1439                    },
1440                    "required": ["name", "src_label", "dst_label", "predicate", "edge_type"]
1441                }
1442            },
1443            {
1444                "name": "explain",
1445                "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.",
1446                "inputSchema": {
1447                    "type": "object",
1448                    "properties": {
1449                        "a": { "type": "string", "minLength": 1 },
1450                        "b": { "type": "string", "minLength": 1 }
1451                    },
1452                    "required": ["a", "b"]
1453                }
1454            },
1455            {
1456                "name": "stats",
1457                "description": "How big is this store — live node, edge and rule counts, plus `history_floor`, the oldest commit history still reaches (0 when nothing has been pruned). Pass 'role' or 'namespace' to also get `namespaces`, the namespaces that argument may see with a live-node count each; without either argument the roster is omitted entirely.",
1458                "inputSchema": {
1459                    "type": "object",
1460                    "properties": {
1461                        "role": {
1462                            "type": "string",
1463                            "description": "Report only the namespaces this role may see. The store-wide counts beside them are unchanged."
1464                        },
1465                        "namespace": {
1466                            "type": "string",
1467                            "description": "Report only this namespace. Intersects with 'role'."
1468                        }
1469                    }
1470                }
1471            },
1472            {
1473                "name": "node_info",
1474                "description": "What is K — its label and every property it holds.",
1475                "inputSchema": {
1476                    "type": "object",
1477                    "properties": {
1478                        "key": { "type": "string" }
1479                    },
1480                    "required": ["key"]
1481                }
1482            },
1483            {
1484                "name": "upsert_entity",
1485                "description": "Record what is now true about K — insert or update a node by key. If the key exists, updates the supplied properties atomically: every property is checked before any is written, so a refusal leaves the node unchanged. If not, creates a new node with the given label and properties. 'id' in 'props' is ignored on both paths: a created node stores 'id' as its key, and 'rename_node' is the only way to change it. Useful for agent memory: store or refresh an entity without checking existence first.",
1486                "inputSchema": {
1487                    "type": "object",
1488                    "properties": {
1489                        "key": { "type": "string", "description": "Unique node key." },
1490                        "label": { "type": "string", "description": "Node label (required when creating a new entity)." },
1491                        "props": {
1492                            "type": "object",
1493                            "description": "Properties to set. Values must be scalars (string, number, bool) or arrays of scalars."
1494                        },
1495                        "namespace": {
1496                            "type": "string",
1497                            "description": "Namespace for a node this call creates. Omitted means the 'default' namespace. On a node that already exists, naming the namespace it is in is a no-op and naming another one is refused — a namespace is set at insert and cannot be changed."
1498                        },
1499                        "aliases": {
1500                            "type": "array",
1501                            "items": { "type": "string" },
1502                            "description": "Other names this entity goes by, kept as written in 'alias_keys': with the identity preset, one equal to a provisional stub's key (exact, case-sensitive) links that stub. The store derives 'aliases' from the key and the name only. Do not set either in props."
1503                        }
1504                    },
1505                    "required": ["key", "props"]
1506                }
1507            },
1508            {
1509                "name": "find_similar",
1510                "description": "What is most like this — two modes: (1) Vector search — provide `vector` (and optionally `field`, `label`, `k`, `min`, `where`, `exact`) to find the k most similar nodes by cosine similarity using the HNSW index when available, brute-force otherwise. `where` is a property predicate (`{field, eq}` or `{field, in}`) and implies exact search. `exact` true skips HNSW. (2) Edge traversal — provide `key` (and optionally `edge_type`, `limit`) to return neighbors previously connected by a derived rule edge. Results from mode 2 come only from edges already derived by a VectorSimilar rule. Edge-traversal mode ignores `where` and `exact`. In both modes, the optional `mask` array limits visibility: hidden nodes never appear in results, and a hidden query key in edge mode behaves identically to a nonexistent key.",
1511                "inputSchema": {
1512                    "type": "object",
1513                    "properties": {
1514                        "vector": {
1515                            "type": "array",
1516                            "items": { "type": "number" },
1517                            "description": "Query embedding vector for vector-similarity search. When present, vector-search mode is used and `key` is ignored."
1518                        },
1519                        "field": { "type": "string", "description": "Property field holding the embedding vectors (default: embedding). Used in vector-search mode." },
1520                        "label": { "type": "string", "description": "Restrict search to nodes with this label. Empty string means all labels. Used in vector-search mode." },
1521                        "k": { "type": "integer", "description": "Maximum results to return in vector-search mode (default: 10)." },
1522                        "min": { "type": "number", "description": "Minimum cosine similarity threshold in vector-search mode (default: 0.8 on every surface; before 0.7 HTTP and the Python binding defaulted to 0.0). Pass 0.0 to keep low-similarity hits." },
1523                        "mask": {
1524                            "type": "array",
1525                            "items": { "type": "string" },
1526                            "description": "Optional node key allow-list for vector-search mode. When present, only nodes whose key appears in this list are eligible for results. Hidden nodes are excluded before k-truncation. The beam widens until it has k visible hits, then falls back to an exhaustive masked scan at the same cap an exact VectorSimilar rule uses, so the result is not short while more visible hits exist. Unknown keys are silently ignored."
1527                        },
1528                        "where": {
1529                            "type": "object",
1530                            "description": "Optional property predicate for vector-search mode, same shape as visible_where: {\"field\": \"...\", \"eq\": value} or {\"field\": \"...\", \"in\": [values]}. Implies exact search (skips HNSW). Invalid predicates are a tool error. Edge-traversal mode ignores this."
1531                        },
1532                        "exact": {
1533                            "type": "boolean",
1534                            "description": "When true, vector-search mode uses exact GEMM brute force and does not consult HNSW. Default false. Edge-traversal mode ignores this."
1535                        },
1536                        "key": { "type": "string", "description": "Source node key for edge-traversal mode." },
1537                        "edge_type": { "type": "string", "description": "Edge type to filter by in edge-traversal mode (default: SIMILAR)." },
1538                        "limit": { "type": "integer", "description": "Maximum neighbors to return in edge-traversal mode (default: 10)." }
1539                    }
1540                }
1541            },
1542            {
1543                "name": "pairwise_similar",
1544                "description": "Which of these are most like each other — exact cosine top-k among a caller key set. Self excluded. Never uses HNSW. Unknown keys, missing embeddings, zero-norm and wrong-dimension vectors are skipped. Duplicate keys collapse to first-seen order. Empty keys returns nothing. n above PAIRWISE_MAX_N is a tool error.",
1545                "inputSchema": {
1546                    "type": "object",
1547                    "properties": {
1548                        "keys": {
1549                            "type": "array",
1550                            "items": { "type": "string" },
1551                            "description": "Node keys to score against each other."
1552                        },
1553                        "field": { "type": "string", "description": "Property field holding the embedding vectors." },
1554                        "k": { "type": "integer", "description": "Maximum neighbors per key (default: 10)." },
1555                        "min": { "type": "number", "description": "Minimum cosine similarity threshold (default: 0.0)." }
1556                    },
1557                    "required": ["keys", "field"]
1558                }
1559            },
1560            {
1561                "name": "hybrid_search",
1562                "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.",
1563                "inputSchema": {
1564                    "type": "object",
1565                    "properties": {
1566                        "query_text": { "type": "string", "description": "Fulltext query string." },
1567                        "text_field": { "type": "string", "description": "Property field to search with fulltext." },
1568                        "vector": {
1569                            "type": "array",
1570                            "items": { "type": "number" },
1571                            "description": "Query embedding vector. Omit for text-only ranking."
1572                        },
1573                        "vector_field": { "type": "string", "description": "Property field holding embedding vectors (default: embedding)." },
1574                        "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." },
1575                        "k": { "type": "integer", "description": "Maximum results to return (default: 10)." }
1576                    },
1577                    "required": ["query_text", "text_field"]
1578                }
1579            },
1580            {
1581                "name": "node_history",
1582                "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.",
1583                "inputSchema": {
1584                    "type": "object",
1585                    "properties": {
1586                        "key": { "type": "string", "description": "Node key to look up." }
1587                    },
1588                    "required": ["key"]
1589                }
1590            },
1591            {
1592                "name": "edge_history",
1593                "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.",
1594                "inputSchema": {
1595                    "type": "object",
1596                    "properties": {
1597                        "a": { "type": "string", "minLength": 1, "description": "First node key." },
1598                        "b": { "type": "string", "minLength": 1, "description": "Second node key." }
1599                    },
1600                    "required": ["a", "b"]
1601                }
1602            },
1603            {
1604                "name": "was_linked",
1605                "description": "Were A and B linked on DATE (or at commit C) — whether an edge of `edge_type` existed between the two keys (either direction) at that point in the store's history. Pass the date directly; do not guess a commit index for it. Returns an error when the commit is outside the retained horizon (`horizon..total_commits`).",
1606                "inputSchema": {
1607                    "type": "object",
1608                    "properties": {
1609                        "a": { "type": "string", "minLength": 1, "description": "First node key." },
1610                        "b": { "type": "string", "minLength": 1, "description": "Second node key." },
1611                        "edge_type": { "type": "string", "minLength": 1, "description": "Edge type to check." },
1612                        "at_commit": {
1613                            "description": "When to check. Either an RFC 3339 date string — \"2026-06-19\", \"2026-06-19T12:00:00Z\", offsets accepted — which resolves to the last commit at or before that instant, or a 0-based WAL commit index.",
1614                            "anyOf": [
1615                                { "type": "string", "minLength": 1 },
1616                                { "type": "integer", "minimum": 0 }
1617                            ]
1618                        }
1619                    },
1620                    "required": ["a", "b", "edge_type", "at_commit"]
1621                }
1622            },
1623            {
1624                "name": "rename_node",
1625                "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.",
1626                "inputSchema": {
1627                    "type": "object",
1628                    "properties": {
1629                        "old_key": { "type": "string", "minLength": 1, "description": "Current node key." },
1630                        "new_key": { "type": "string", "minLength": 1, "description": "Desired new node key." }
1631                    },
1632                    "required": ["old_key", "new_key"]
1633                }
1634            }
1635    ]) else {
1636        unreachable!("the literal above is an array")
1637    };
1638    tools
1639}
1640
1641fn tool_ok(payload: Js) -> Js {
1642    json!({
1643        "content": [{ "type": "text", "text": payload.to_string() }]
1644    })
1645}
1646
1647/// A task tool's result: one text block, and no `structuredContent`.
1648///
1649/// The report used to ride along beside the digest, repeating it verbatim
1650/// under a `text` key. Nothing bound it — no task tool declares an
1651/// `outputSchema` — and it tripled the size of every reply, so a caller that
1652/// wants the numbers now asks for them with `json: true` and gets the report
1653/// *as* the text.
1654fn task_ok(text: &str) -> Js {
1655    json!({
1656        "content": [{ "type": "text", "text": text }]
1657    })
1658}
1659
1660fn tool_err(message: &str) -> Js {
1661    json!({
1662        "content": [{ "type": "text", "text": message }],
1663        "isError": true
1664    })
1665}
1666
1667fn write_result(writer: &mut impl Write, id: Option<Js>, result: Js) -> io::Result<()> {
1668    write_json(
1669        writer,
1670        &json!({
1671            "jsonrpc": "2.0",
1672            "id": id.unwrap_or(Js::Null),
1673            "result": result
1674        }),
1675    )
1676}
1677
1678fn write_error(
1679    writer: &mut impl Write,
1680    id: Option<Js>,
1681    code: i64,
1682    message: &str,
1683) -> io::Result<()> {
1684    write_json(
1685        writer,
1686        &json!({
1687            "jsonrpc": "2.0",
1688            "id": id.unwrap_or(Js::Null),
1689            "error": { "code": code, "message": message }
1690        }),
1691    )
1692}
1693
1694fn write_json(writer: &mut impl Write, value: &Js) -> io::Result<()> {
1695    let s = serde_json::to_string(value).map_err(io::Error::other)?;
1696    writeln!(writer, "{s}")?;
1697    writer.flush()
1698}
1699
1700// ---------------------------------------------------------------------------
1701// Tests: MCP tool round-trips via stdio
1702// ---------------------------------------------------------------------------
1703
1704#[cfg(test)]
1705mod tests {
1706    use super::*;
1707
1708    #[test]
1709    fn an_unwritable_directory_reaches_a_client_without_paths_or_uids() {
1710        let msg = graph_err_msg(GraphError::Unwritable {
1711            path: "/secret/dir/LOCK".into(),
1712            detail: "uid 65532 owner 0 /secret/dir".into(),
1713            read_only_filesystem: false,
1714        });
1715        assert_eq!(msg, GraphError::UNWRITABLE_CLIENT_MESSAGE);
1716        assert!(!msg.contains("secret") && !msg.contains("65532"));
1717    }
1718    use mushroomdb::{AutoFk, IngestOptions, Predicate, RuleDef, Value};
1719
1720    /// A unique temporary directory, removed on drop (backed by `tempfile`).
1721    fn tmp_dir() -> tempfile::TempDir {
1722        tempfile::TempDir::new().expect("temp dir")
1723    }
1724
1725    /// A SharedDb together with its temporary directory; the directory is
1726    /// removed on drop so every test path — pass or fail — cleans up.
1727    struct DemoDb {
1728        db: SharedDb,
1729        _dir: tempfile::TempDir,
1730    }
1731
1732    impl std::ops::Deref for DemoDb {
1733        type Target = SharedDb;
1734        fn deref(&self) -> &SharedDb {
1735            &self.db
1736        }
1737    }
1738
1739    /// Open a SharedDb with two Person nodes and one derived SIMILAR edge.
1740    fn demo_db() -> DemoDb {
1741        let dir = tempfile::TempDir::new().expect("temp dir");
1742        let db = SharedDb::open(dir.path()).expect("open");
1743        {
1744            let mut g = db.write();
1745            let opts = IngestOptions {
1746                key_field: "id".into(),
1747                auto_fk: AutoFk::Off,
1748            };
1749            // Two people with identical embeddings → will fire SIMILAR rule.
1750            let people: Vec<BTreeMap<String, Value>> = vec![
1751                [
1752                    ("id", Value::Str("alice".into())),
1753                    ("name", Value::Str("Alice".into())),
1754                    (
1755                        "emb",
1756                        Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1757                    ),
1758                ]
1759                .into_iter()
1760                .map(|(k, v)| (k.to_string(), v))
1761                .collect(),
1762                [
1763                    ("id", Value::Str("bob".into())),
1764                    ("name", Value::Str("Bob".into())),
1765                    (
1766                        "emb",
1767                        Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1768                    ),
1769                ]
1770                .into_iter()
1771                .map(|(k, v)| (k.to_string(), v))
1772                .collect(),
1773            ];
1774            g.ingest("Person", people, &opts).expect("ingest");
1775
1776            // Rule: VectorSimilar on emb → SIMILAR edge (cosine(ident,ident)=1.0 ≥ 0.9).
1777            g.create_rule(RuleDef {
1778                name: "sim_emb".into(),
1779                src_label: "Person".into(),
1780                dst_label: "Person".into(),
1781                predicate: Predicate::VectorSimilar {
1782                    field: "emb".into(),
1783                    min: 0.9,
1784                },
1785                edge_type: "SIMILAR".into(),
1786                weight_prop: Some("score".into()),
1787                max_edges: None,
1788                approximate: false,
1789                via_label: None,
1790                via_edge: None,
1791                via_dir: None,
1792                namespace: None,
1793            })
1794            .expect("rule");
1795        }
1796        DemoDb { db, _dir: dir }
1797    }
1798
1799    fn roundtrip(db: &SharedDb, request: &str) -> Js {
1800        roundtrip_with(db, false, request)
1801    }
1802
1803    fn roundtrip_with(db: &SharedDb, all_tools: bool, request: &str) -> Js {
1804        let input = format!("{request}\n");
1805        let mut output = Vec::new();
1806        run_mcp_stdio_with(db.clone(), None, all_tools, input.as_bytes(), &mut output)
1807            .expect("mcp");
1808        let s = std::str::from_utf8(&output).expect("utf8");
1809        serde_json::from_str(s.trim()).expect("json response")
1810    }
1811
1812    fn tool_call(db: &SharedDb, id: u64, tool: &str, args: Js) -> Js {
1813        let req = json!({
1814            "jsonrpc": "2.0",
1815            "id": id,
1816            "method": "tools/call",
1817            "params": { "name": tool, "arguments": args }
1818        });
1819        roundtrip(db, &req.to_string())
1820    }
1821
1822    /// Unwrap the `text` field from a successful tool response.
1823    fn tool_text(resp: &Js) -> Js {
1824        let text = resp["result"]["content"][0]["text"]
1825            .as_str()
1826            .expect("content[0].text");
1827        serde_json::from_str(text).expect("tool text is json")
1828    }
1829
1830    fn is_error(resp: &Js) -> bool {
1831        resp["result"]["isError"].as_bool().unwrap_or(false)
1832    }
1833
1834    fn tool_err_text(resp: &Js) -> String {
1835        resp["result"]["content"][0]["text"]
1836            .as_str()
1837            .unwrap_or("")
1838            .to_string()
1839    }
1840
1841    // --- existing tools ---
1842
1843    #[test]
1844    fn test_tools_list_includes_all_expected() {
1845        let db = demo_db();
1846        let resp = roundtrip_with(
1847            &db,
1848            true,
1849            r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
1850        );
1851        let tools = resp["result"]["tools"].as_array().expect("tools array");
1852        let names: Vec<&str> = tools
1853            .iter()
1854            .map(|t| t["name"].as_str().expect("name"))
1855            .collect();
1856        for expected in &[
1857            // The task tools, first and in order.
1858            "explain_association",
1859            "node_edges",
1860            "neighborhood",
1861            "edges_at",
1862            "what_if",
1863            "recall",
1864            "remember",
1865            "schema",
1866            "analyze",
1867            "suggest_rules",
1868            "forget",
1869            // The fourteen graph tools.
1870            "query",
1871            "ingest_json",
1872            "create_rule",
1873            "explain",
1874            "stats",
1875            "node_info",
1876            "upsert_entity",
1877            "find_similar",
1878            "pairwise_similar",
1879            "hybrid_search",
1880            "node_history",
1881            "edge_history",
1882            "was_linked",
1883            "rename_node",
1884        ] {
1885            assert!(names.contains(expected), "missing tool: {expected}");
1886        }
1887        assert_eq!(
1888            names.len(),
1889            25,
1890            "expected exactly 25 tools, got {}",
1891            names.len()
1892        );
1893        assert_eq!(
1894            &names[..11],
1895            [
1896                "explain_association",
1897                "node_edges",
1898                "neighborhood",
1899                "edges_at",
1900                "what_if",
1901                "recall",
1902                "remember",
1903                "schema",
1904                "analyze",
1905                "suggest_rules",
1906                "forget",
1907            ],
1908            "the task tools come first, in order"
1909        );
1910        assert_eq!(names[11], "query", "the graph tools follow them");
1911    }
1912
1913    /// Binding: the default listing is the association tools, in [`ASSOCIATION_TOOLS`]
1914    /// order, and nothing else.
1915    #[test]
1916    fn tools_list_defaults_to_the_association_tools_on_a_memory_store() {
1917        let db = demo_db();
1918        let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1919        let names: Vec<&str> = resp["result"]["tools"]
1920            .as_array()
1921            .expect("tools array")
1922            .iter()
1923            .map(|t| t["name"].as_str().expect("name"))
1924            .collect();
1925        assert_eq!(names, ASSOCIATION_TOOLS.to_vec());
1926    }
1927
1928    /// Binding: every argument `suggest_rules` hands back for `create_rule`
1929    /// is one `create_rule`'s advertised schema lists. A vector proposal over
1930    /// a large label carries `approximate: true`, which the tool accepts.
1931    #[test]
1932    fn create_rule_advertises_every_argument_a_suggestion_carries() {
1933        let def = RuleDef {
1934            name: "sim".into(),
1935            src_label: "Person".into(),
1936            dst_label: "Person".into(),
1937            predicate: Predicate::VectorSimilar {
1938                field: "emb".into(),
1939                min: 0.9,
1940            },
1941            edge_type: "SIMILAR".into(),
1942            weight_prop: Some("score".into()),
1943            max_edges: Some(8),
1944            approximate: true,
1945            via_label: None,
1946            via_edge: None,
1947            via_dir: None,
1948            namespace: None,
1949        };
1950        let args = mushroomdb::memory::suggest::create_rule_args(&def);
1951        assert_eq!(args["approximate"], json!(true), "{args}");
1952
1953        let tools = graph_tools();
1954        let schema = tools
1955            .iter()
1956            .find(|t| t["name"] == json!("create_rule"))
1957            .expect("create_rule is a graph tool");
1958        let advertised = schema["inputSchema"]["properties"]
1959            .as_object()
1960            .expect("properties");
1961        for arg in args.as_object().expect("an object").keys() {
1962            assert!(
1963                advertised.contains_key(arg),
1964                "create_rule's schema does not list {arg:?}, which a suggestion carries"
1965            );
1966        }
1967        assert_eq!(advertised["approximate"]["type"], json!("boolean"));
1968    }
1969
1970    /// Binding: `pairwise_similar` is advertised on the memory surface
1971    /// immediately after `find_similar`, and the listing is exactly `EXPECTED`.
1972    #[test]
1973    fn association_listing_includes_pairwise_similar_after_find_similar() {
1974        let db = demo_db();
1975        let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1976        let names: Vec<&str> = resp["result"]["tools"]
1977            .as_array()
1978            .expect("tools array")
1979            .iter()
1980            .map(|t| t["name"].as_str().expect("name"))
1981            .collect();
1982        const EXPECTED: [&str; 23] = [
1983            "query",
1984            "explain_association",
1985            "neighborhood",
1986            "node_info",
1987            "node_edges",
1988            "was_linked",
1989            "edges_at",
1990            "what_if",
1991            "node_history",
1992            "edge_history",
1993            "find_similar",
1994            "pairwise_similar",
1995            "hybrid_search",
1996            "remember",
1997            "recall",
1998            "upsert_entity",
1999            "ingest_json",
2000            "create_rule",
2001            "stats",
2002            "schema",
2003            "analyze",
2004            "suggest_rules",
2005            "forget",
2006        ];
2007        assert_eq!(names, EXPECTED.to_vec());
2008        assert_eq!(ASSOCIATION_TOOLS.as_slice(), EXPECTED.as_slice());
2009        let find = names
2010            .iter()
2011            .position(|&n| n == "find_similar")
2012            .expect("find_similar listed");
2013        assert_eq!(names[find + 1], "pairwise_similar");
2014    }
2015
2016    /// Binding: [`ASSOCIATION_TOOLS`] keeps the task tools an entity store can
2017    /// answer with — the notes it wrote and the notes it kept — and every name
2018    /// in it is served.
2019    #[test]
2020    fn the_association_surface_is_entity_tools_only() {
2021        for kept in ["remember", "recall", "explain_association"] {
2022            assert!(
2023                ASSOCIATION_TOOLS.contains(&kept),
2024                "{kept} answers on an entity graph and must be listed"
2025            );
2026        }
2027        let served: Vec<String> = crate::mcp_tasks::task_tools()
2028            .iter()
2029            .chain(graph_tools().iter())
2030            .filter_map(|t| t.get("name").and_then(Js::as_str))
2031            .map(str::to_string)
2032            .collect();
2033        for name in ASSOCIATION_TOOLS {
2034            assert!(
2035                served.iter().any(|s| s == name),
2036                "{name} is listed but not served"
2037            );
2038        }
2039    }
2040
2041    #[test]
2042    fn test_stats_returns_node_count() {
2043        let db = demo_db();
2044        let resp = tool_call(&db, 1, "stats", json!({}));
2045        assert!(!is_error(&resp));
2046        let result = tool_text(&resp);
2047        assert_eq!(result["nodes_live"], 2);
2048    }
2049
2050    /// Unscoped `stats` must not enumerate the store's namespaces. Asking with
2051    /// neither `role` nor `namespace` gets the store-wide counts with the
2052    /// roster key absent entirely — not an empty array, which would still
2053    /// confirm the roster exists and invite a guess at its size.
2054    #[test]
2055    fn mcp_stats_unscoped_omits_namespace_roster() {
2056        let _dir = tmp_dir();
2057        let db = SharedDb::open(_dir.path()).expect("open");
2058        {
2059            let mut g = db.write();
2060            g.insert_node(
2061                "Doc",
2062                "a",
2063                vec![("ns".into(), Value::Str("tenant-a".into()))],
2064            )
2065            .expect("insert a");
2066            g.insert_node(
2067                "Doc",
2068                "b",
2069                vec![("ns".into(), Value::Str("tenant-b".into()))],
2070            )
2071            .expect("insert b");
2072        }
2073
2074        let unscoped = tool_text(&tool_call(&db, 1, "stats", json!({})));
2075        assert!(
2076            unscoped.get("namespaces").is_none(),
2077            "unscoped stats must omit the roster entirely, not send an empty \
2078             array: {unscoped}"
2079        );
2080        assert_eq!(
2081            unscoped["nodes_live"], 2,
2082            "the store-wide counts beside the roster are unchanged"
2083        );
2084
2085        let scoped = tool_text(&tool_call(
2086            &db,
2087            2,
2088            "stats",
2089            json!({"namespace": "tenant-a"}),
2090        ));
2091        let names: Vec<&str> = scoped["namespaces"]
2092            .as_array()
2093            .expect("a scoped call still carries the roster it may see")
2094            .iter()
2095            .map(|n| n["name"].as_str().expect("name"))
2096            .collect();
2097        assert_eq!(
2098            names,
2099            ["tenant-a"],
2100            "a call that names a namespace sees that one and no other"
2101        );
2102    }
2103
2104    /// A rule whose corpus is too large to index in one commit must not come
2105    /// back as a bare "ok": the caller would go straight to querying edges that
2106    /// do not exist yet.
2107    #[test]
2108    fn create_rule_reports_a_build_it_could_not_finish() {
2109        let _dir = tmp_dir();
2110        let db = SharedDb::open(_dir.path()).expect("open");
2111        {
2112            let mut g = db.write();
2113            for i in 0..300usize {
2114                const D: usize = 32;
2115                let axis = (i / 10) % D;
2116                let mut xs = vec![0.0f64; D];
2117                xs[axis] = 1.0;
2118                xs[(axis + 1) % D] = (i % 10) as f64 * 0.001;
2119                g.insert_node(
2120                    "V",
2121                    &format!("v{i}"),
2122                    vec![(
2123                        "emb".into(),
2124                        Value::List(xs.into_iter().map(Value::Float).collect()),
2125                    )],
2126                )
2127                .expect("insert");
2128            }
2129            g.set_hnsw_build_batch(Some(64));
2130        }
2131        let args = json!({
2132            "name": "sim",
2133            "src_label": "V",
2134            "dst_label": "V",
2135            "predicate": {"VectorSimilar": {"field": "emb", "min": 0.9}},
2136            "edge_type": "SIM",
2137            "weight_prop": null,
2138            "max_edges": null,
2139            "approximate": true
2140        });
2141        let resp = tool_call(&db, 1, "create_rule", args);
2142        assert!(!is_error(&resp), "{resp}");
2143        let result = tool_text(&resp);
2144        assert_eq!(result["name"], json!("sim"));
2145        assert_eq!(result["building"], json!({"indexed": 64, "total": 300}));
2146        let note = result["note"].as_str().expect("a note explaining the wait");
2147        assert!(
2148            note.contains("derives no edges until it finishes") && note.contains("build-index"),
2149            "the note must say the edges are not there yet and how to finish: {note}"
2150        );
2151
2152        // `stats` carries the same progress.
2153        let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2154        let rule = stats["rules"]
2155            .as_array()
2156            .expect("rules")
2157            .iter()
2158            .find(|r| r["name"] == "sim")
2159            .expect("the rule is installed while it builds");
2160        assert_eq!(rule["edges"], json!(0));
2161        assert_eq!(
2162            rule["building"],
2163            json!({"rule": "sim", "indexed": 64, "total": 300})
2164        );
2165
2166        // Finished, the report is the plain one again.
2167        while !db.write().pump_index_build().expect("pump").is_empty() {}
2168        let stats = tool_text(&tool_call(&db, 3, "stats", json!({})));
2169        let rule = stats["rules"]
2170            .as_array()
2171            .expect("rules")
2172            .iter()
2173            .find(|r| r["name"] == "sim")
2174            .expect("rule");
2175        assert!(rule.get("building").is_none(), "{rule}");
2176        assert!(rule["edges"].as_u64().expect("edges") > 0);
2177    }
2178
2179    #[test]
2180    fn test_query_runs_cypher() {
2181        let db = demo_db();
2182        let resp = tool_call(
2183            &db,
2184            1,
2185            "query",
2186            json!({ "cypher": "MATCH (n:Person) RETURN n.name ORDER BY n.name" }),
2187        );
2188        assert!(!is_error(&resp));
2189        let result = tool_text(&resp);
2190        // columns + 2 rows
2191        assert_eq!(result["columns"], json!(["n.name"]));
2192        assert_eq!(result["rows"].as_array().map(|r| r.len()), Some(2));
2193    }
2194
2195    #[test]
2196    fn test_query_create_is_a_write() {
2197        let _dir = tmp_dir();
2198        let db = SharedDb::open(_dir.path()).expect("open");
2199        let resp = tool_call(
2200            &db,
2201            1,
2202            "query",
2203            json!({ "cypher": "CREATE (n:L {id: 'k'}) RETURN n" }),
2204        );
2205        assert!(
2206            !is_error(&resp),
2207            "CREATE via MCP query must succeed: {resp}"
2208        );
2209        let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2210        assert_eq!(stats["nodes_live"], 1);
2211    }
2212
2213    #[test]
2214    fn test_ingest_json_inserts_nodes() {
2215        let db = demo_db();
2216        let resp = tool_call(
2217            &db,
2218            1,
2219            "ingest_json",
2220            json!({
2221                "label": "Person",
2222                "rows_json": r#"[{"id":"carol","name":"Carol"}]"#,
2223                "key_field": "id"
2224            }),
2225        );
2226        assert!(!is_error(&resp));
2227        // Verify node visible via stats
2228        let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2229        assert_eq!(stats["nodes_live"], 3);
2230    }
2231
2232    #[test]
2233    fn test_node_info_returns_props() {
2234        let db = demo_db();
2235        let resp = tool_call(&db, 1, "node_info", json!({ "key": "alice" }));
2236        assert!(!is_error(&resp));
2237        let result = tool_text(&resp);
2238        assert_eq!(result["key"], "alice");
2239        assert_eq!(result["label"], "Person");
2240        assert_eq!(result["props"]["name"], "Alice");
2241    }
2242
2243    /// Binding: `node_edges` groups by edge type and names the rule and score
2244    /// behind each derived edge, in the report as in the digest.
2245    #[test]
2246    fn test_node_edges_returns_edges() {
2247        let db = demo_db();
2248        let resp = tool_call(
2249            &db,
2250            1,
2251            "node_edges",
2252            json!({ "key": "alice", "json": true }),
2253        );
2254        assert!(!is_error(&resp));
2255        let result = tool_text(&resp);
2256        assert_eq!(result["key"], "alice");
2257        let types = result["types"].as_array().expect("types");
2258        assert!(
2259            !types.is_empty(),
2260            "alice should have at least one edge type"
2261        );
2262        let similar = types
2263            .iter()
2264            .find(|t| t["edge_type"] == "SIMILAR")
2265            .expect("the rule's edge type");
2266        // A symmetric rule derives the edge both ways, and both are listed
2267        // with the direction that tells them apart.
2268        assert_eq!(similar["count"], json!(2));
2269        let edges = similar["edges"].as_array().expect("edges");
2270        let dirs: Vec<&str> = edges
2271            .iter()
2272            .map(|e| e["direction"].as_str().expect("direction"))
2273            .collect();
2274        assert!(dirs.contains(&"out") && dirs.contains(&"in"), "{similar}");
2275        for edge in edges {
2276            assert_eq!(edge["other"], json!("bob"));
2277            assert_eq!(edge["derived"], json!(true));
2278            assert_eq!(edge["rule"], json!("sim_emb"));
2279            assert_eq!(edge["score"], json!(1.0));
2280            assert!(
2281                edge["predicate"]
2282                    .as_str()
2283                    .unwrap_or("")
2284                    .contains("vector_similar"),
2285                "the predicate travels with the edge: {edge}"
2286            );
2287        }
2288    }
2289
2290    /// Binding: a depth-1 `neighborhood` is the same relationship listing, and
2291    /// anything deeper is still the traversal table.
2292    #[test]
2293    fn test_neighborhood_traverses_one_hop() {
2294        let db = demo_db();
2295        let resp = tool_call(
2296            &db,
2297            1,
2298            "neighborhood",
2299            json!({ "key": "alice", "depth": 1, "json": true }),
2300        );
2301        assert!(!is_error(&resp));
2302        let result = tool_text(&resp);
2303        assert_eq!(result["key"], "alice");
2304        assert!(result["types"].as_array().is_some(), "{result}");
2305
2306        let deep = tool_call(
2307            &db,
2308            2,
2309            "neighborhood",
2310            json!({ "key": "alice", "depth": 2 }),
2311        );
2312        assert!(!is_error(&deep));
2313        let table = tool_text(&deep);
2314        assert_eq!(table["columns"], json!(["key", "label", "depth"]));
2315        assert!(table["rows"].as_array().is_some());
2316    }
2317
2318    #[test]
2319    fn test_explain_returns_rule_info() {
2320        let db = demo_db();
2321        let resp = tool_call(&db, 1, "explain", json!({ "a": "alice", "b": "bob" }));
2322        assert!(!is_error(&resp));
2323        let result = tool_text(&resp);
2324        let arr = result.as_array().expect("explain returns array");
2325        assert!(!arr.is_empty(), "expected at least one explanation");
2326        assert_eq!(arr[0]["rule"], "sim_emb");
2327    }
2328
2329    #[test]
2330    fn test_create_rule_backfills() {
2331        let _dir = tmp_dir();
2332        let db = SharedDb::open(_dir.path()).expect("open");
2333        {
2334            let mut g = db.write();
2335            let opts = IngestOptions {
2336                key_field: "id".into(),
2337                auto_fk: AutoFk::Off,
2338            };
2339            let rows: Vec<BTreeMap<String, Value>> = vec![
2340                [
2341                    ("id", Value::Str("x".into())),
2342                    ("tag", Value::Str("a".into())),
2343                ]
2344                .into_iter()
2345                .map(|(k, v)| (k.to_string(), v))
2346                .collect(),
2347                [
2348                    ("id", Value::Str("y".into())),
2349                    ("tag", Value::Str("a".into())),
2350                ]
2351                .into_iter()
2352                .map(|(k, v)| (k.to_string(), v))
2353                .collect(),
2354            ];
2355            g.ingest("Item", rows, &opts).expect("ingest");
2356        }
2357        let resp = tool_call(
2358            &db,
2359            1,
2360            "create_rule",
2361            json!({
2362                "name": "same_tag",
2363                "src_label": "Item",
2364                "dst_label": "Item",
2365                "predicate": { "FieldEqual": { "field": "tag" } },
2366                "edge_type": "SAME_TAG"
2367            }),
2368        );
2369        assert!(!is_error(&resp));
2370        let result = tool_text(&resp);
2371        assert_eq!(result["ok"], true);
2372        // Derived edges should now exist.
2373        let edges_resp = tool_call(&db, 2, "node_edges", json!({ "key": "x", "json": true }));
2374        let edges_result = tool_text(&edges_resp);
2375        let types = edges_result["types"].as_array().expect("types");
2376        assert!(
2377            types.iter().any(|t| t["edge_type"] == "SAME_TAG"),
2378            "SAME_TAG edge not found after create_rule"
2379        );
2380    }
2381
2382    // --- new tools ---
2383
2384    #[test]
2385    fn test_upsert_entity_creates_new_node() {
2386        let db = demo_db();
2387        let resp = tool_call(
2388            &db,
2389            1,
2390            "upsert_entity",
2391            json!({
2392                "key": "carol",
2393                "label": "Person",
2394                "props": { "name": "Carol", "age": 30 }
2395            }),
2396        );
2397        assert!(!is_error(&resp));
2398        let result = tool_text(&resp);
2399        assert_eq!(result["ok"], true);
2400        assert_eq!(result["created"], true);
2401        assert_eq!(result["key"], "carol");
2402        // Verify node exists
2403        let info = tool_text(&tool_call(&db, 2, "node_info", json!({ "key": "carol" })));
2404        assert_eq!(info["props"]["name"], "Carol");
2405    }
2406
2407    #[test]
2408    fn upsert_entity_refuses_a_bad_tagged_value_naming_the_prop() {
2409        let db = demo_db();
2410        let before = db.read().node_count();
2411        for (payload, reason) in [
2412            (json!({"$date": "2023-02-29"}), "is not an ISO 8601 date"),
2413            (json!({"$binary": 7}), "payload must be a string"),
2414        ] {
2415            let resp = tool_call(
2416                &db,
2417                1,
2418                "upsert_entity",
2419                json!({"key": "carol", "label": "Person", "props": {"born": payload}}),
2420            );
2421            assert!(is_error(&resp));
2422            let err = tool_err_text(&resp);
2423            assert!(err.contains("prop born: $"), "{err}");
2424            assert!(err.contains(reason), "{err}");
2425        }
2426        assert_eq!(db.read().node_count(), before);
2427    }
2428
2429    #[test]
2430    fn test_upsert_entity_updates_existing_node() {
2431        let db = demo_db();
2432        let resp = tool_call(
2433            &db,
2434            1,
2435            "upsert_entity",
2436            json!({
2437                "key": "alice",
2438                "props": { "name": "Alice Updated" }
2439            }),
2440        );
2441        assert!(!is_error(&resp));
2442        let result = tool_text(&resp);
2443        assert_eq!(result["ok"], true);
2444        assert_eq!(result["created"], false);
2445        assert_eq!(result["updated_fields"], 1);
2446        // Verify prop changed
2447        let info = tool_text(&tool_call(&db, 2, "node_info", json!({ "key": "alice" })));
2448        assert_eq!(info["props"]["name"], "Alice Updated");
2449    }
2450
2451    #[test]
2452    fn test_upsert_entity_missing_label_on_create_is_error() {
2453        let db = demo_db();
2454        let resp = tool_call(
2455            &db,
2456            1,
2457            "upsert_entity",
2458            json!({ "key": "new-node", "props": { "x": 1 } }),
2459        );
2460        assert!(is_error(&resp), "should error without label for new node");
2461    }
2462
2463    #[test]
2464    fn test_pairwise_similar_excludes_self() {
2465        let db = demo_db();
2466        let resp = tool_call(
2467            &db,
2468            1,
2469            "pairwise_similar",
2470            json!({
2471                "keys": ["alice", "bob"],
2472                "field": "emb",
2473                "k": 10,
2474                "min": 0.0
2475            }),
2476        );
2477        assert!(
2478            !is_error(&resp),
2479            "pairwise_similar must not error: {resp:?}"
2480        );
2481        let result = tool_text(&resp);
2482        let results = result["results"].as_array().expect("results");
2483        assert_eq!(results.len(), 2);
2484        for row in results {
2485            let key = row["key"].as_str().expect("key");
2486            let neighbors = row["neighbors"].as_array().expect("neighbors");
2487            assert!(
2488                neighbors.iter().all(|n| n["key"].as_str() != Some(key)),
2489                "self must be excluded: {row}"
2490            );
2491            assert!(!neighbors.is_empty(), "alice/bob are identical: {row}");
2492        }
2493    }
2494
2495    #[test]
2496    fn test_find_similar_returns_similar_edges() {
2497        let db = demo_db();
2498        let resp = tool_call(
2499            &db,
2500            1,
2501            "find_similar",
2502            json!({ "key": "alice", "edge_type": "SIMILAR" }),
2503        );
2504        assert!(!is_error(&resp));
2505        let result = tool_text(&resp);
2506        assert_eq!(result["key"], "alice");
2507        assert_eq!(result["edge_type"], "SIMILAR");
2508        let similar = result["similar"].as_array().expect("similar array");
2509        assert!(!similar.is_empty(), "expected SIMILAR neighbors for alice");
2510        assert_eq!(similar[0]["neighbor_key"], "bob");
2511    }
2512
2513    #[test]
2514    fn test_find_similar_limit_respected() {
2515        let db = demo_db();
2516        let resp = tool_call(
2517            &db,
2518            1,
2519            "find_similar",
2520            json!({ "key": "alice", "edge_type": "SIMILAR", "limit": 0 }),
2521        );
2522        assert!(!is_error(&resp));
2523        let result = tool_text(&resp);
2524        let similar = result["similar"].as_array().expect("similar array");
2525        assert_eq!(similar.len(), 0);
2526    }
2527
2528    /// When `min` is omitted from a vector-mode find_similar call, the server
2529    /// must apply the spec default of 0.8.  A node whose cosine similarity to
2530    /// the query is 0.0 (orthogonal) must not appear in the results.
2531    #[test]
2532    fn test_find_similar_vector_default_min_is_0_8() {
2533        let _dir = tmp_dir();
2534        let db = SharedDb::open(_dir.path()).expect("open");
2535        {
2536            let mut g = db.write();
2537            // close: [1,0] → cosine 1.0 with query [1,0] (above 0.8)
2538            g.insert_node(
2539                "Item",
2540                "close",
2541                vec![(
2542                    "emb".into(),
2543                    Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2544                )],
2545            )
2546            .unwrap();
2547            // far: [0,1] → cosine 0.0 with query [1,0] (below 0.8, must be excluded)
2548            g.insert_node(
2549                "Item",
2550                "far",
2551                vec![(
2552                    "emb".into(),
2553                    Value::List(vec![Value::Float(0.0), Value::Float(1.0)]),
2554                )],
2555            )
2556            .unwrap();
2557            // mid: [0.6,0.8] → cosine 0.6 with query [1,0]: above the old HTTP
2558            // and Python default (0.0), below this one (0.8).
2559            g.insert_node(
2560                "Item",
2561                "mid",
2562                vec![(
2563                    "emb".into(),
2564                    Value::List(vec![Value::Float(0.6), Value::Float(0.8)]),
2565                )],
2566            )
2567            .unwrap();
2568        }
2569
2570        // No `min` in the request — must default to 0.8.
2571        let resp = tool_call(
2572            &db,
2573            1,
2574            "find_similar",
2575            json!({
2576                "vector": [1.0, 0.0],
2577                "field": "emb",
2578                "label": "Item",
2579                "k": 10
2580            }),
2581        );
2582        assert!(!is_error(&resp), "vector search must not error");
2583        let result = tool_text(&resp);
2584        let results = result["results"].as_array().expect("results array");
2585
2586        let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2587        assert_eq!(
2588            keys,
2589            vec!["close"],
2590            "only the hit at or above the default survives: mid (0.6) and far (0.0) do not"
2591        );
2592        assert_eq!(
2593            result["min"],
2594            json!(mushroomdb::FIND_SIMILAR_DEFAULT_MIN),
2595            "the reply echoes the floor it applied"
2596        );
2597
2598        // Naming it still reaches everything, in score order: the argument is
2599        // read, not replaced by the default.
2600        let resp = tool_call(
2601            &db,
2602            2,
2603            "find_similar",
2604            json!({
2605                "vector": [1.0, 0.0],
2606                "field": "emb",
2607                "label": "Item",
2608                "k": 10,
2609                "min": 0.0
2610            }),
2611        );
2612        assert!(!is_error(&resp), "vector search must not error");
2613        let result = tool_text(&resp);
2614        let results = result["results"].as_array().expect("results array");
2615        let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2616        assert_eq!(
2617            keys,
2618            vec!["close", "mid", "far"],
2619            "an explicit min of 0.0 keeps the hits the default drops"
2620        );
2621        assert_eq!(
2622            result["min"],
2623            json!(0.0),
2624            "the reply echoes the named floor"
2625        );
2626    }
2627
2628    /// `find_similar` with `mask` must exclude hidden node keys from results.
2629    #[test]
2630    fn test_find_similar_vector_mask_excludes_hidden() {
2631        let _dir = tmp_dir();
2632        let db = SharedDb::open(_dir.path()).expect("open");
2633        {
2634            let mut g = db.write();
2635            // visible: [1,0] — should appear in results.
2636            g.insert_node(
2637                "Item",
2638                "visible",
2639                vec![(
2640                    "emb".into(),
2641                    Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2642                )],
2643            )
2644            .unwrap();
2645            // hidden: [1,0] — same direction as query but must not appear.
2646            g.insert_node(
2647                "Item",
2648                "hidden",
2649                vec![(
2650                    "emb".into(),
2651                    Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2652                )],
2653            )
2654            .unwrap();
2655        }
2656
2657        let resp = tool_call(
2658            &db,
2659            1,
2660            "find_similar",
2661            json!({
2662                "vector": [1.0, 0.0],
2663                "field": "emb",
2664                "label": "Item",
2665                "k": 10,
2666                "min": 0.0,
2667                "mask": ["visible"]
2668            }),
2669        );
2670        assert!(!is_error(&resp), "masked vector search must not error");
2671        let result = tool_text(&resp);
2672        let results = result["results"].as_array().expect("results array");
2673
2674        let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2675        assert!(
2676            keys.contains(&"visible"),
2677            "visible node must appear in masked results"
2678        );
2679        assert!(
2680            !keys.contains(&"hidden"),
2681            "hidden node must be excluded by mask"
2682        );
2683    }
2684
2685    /// `find_similar` with `mask` — bad mask value returns a tool error.
2686    #[test]
2687    fn test_find_similar_vector_mask_bad_type_is_error() {
2688        let _dir = tmp_dir();
2689        let db = SharedDb::open(_dir.path()).expect("open");
2690        let resp = tool_call(
2691            &db,
2692            1,
2693            "find_similar",
2694            json!({
2695                "vector": [1.0, 0.0],
2696                "field": "emb",
2697                "k": 5,
2698                "mask": [42]
2699            }),
2700        );
2701        assert!(
2702            is_error(&resp),
2703            "non-string mask element must produce a tool error"
2704        );
2705    }
2706
2707    /// Ledger row 88: a `vector` with an element that is not a number was
2708    /// searched with the element dropped (`[1, "x", 0]` as `[1, 0]`); the same
2709    /// for `hybrid_search`'s `vector`, and `pairwise_similar` / `hybrid_search`
2710    /// replaced a wrongly typed `k` (and `min`) by a default.
2711    #[test]
2712    fn find_similar_drops_non_numeric_vector_elements_is_error() {
2713        let db = demo_db();
2714        for (tool, args, want) in [
2715            (
2716                "find_similar",
2717                json!({ "vector": [1, "x", 0], "field": "emb" }),
2718                "vector elements must all be numbers",
2719            ),
2720            (
2721                "hybrid_search",
2722                json!({
2723                    "query_text": "alice", "text_field": "name",
2724                    "vector": [1.0, null], "vector_field": "emb"
2725                }),
2726                "vector elements must all be numbers",
2727            ),
2728            (
2729                "find_similar",
2730                json!({ "vector": "1,0", "field": "emb" }),
2731                "vector must be an array of numbers",
2732            ),
2733            (
2734                "hybrid_search",
2735                json!({ "query_text": "alice", "text_field": "name", "vector": 5 }),
2736                "vector must be an array of numbers",
2737            ),
2738            (
2739                "hybrid_search",
2740                json!({ "query_text": "alice", "text_field": "name", "k": "3" }),
2741                "k must be a non-negative integer",
2742            ),
2743            (
2744                "pairwise_similar",
2745                json!({ "keys": ["alice", "bob"], "field": "emb", "k": "3" }),
2746                "k must be a non-negative integer",
2747            ),
2748            (
2749                "pairwise_similar",
2750                json!({ "keys": ["alice", "bob"], "field": "emb", "min": "0.5" }),
2751                "min must be a number",
2752            ),
2753        ] {
2754            let resp = tool_call(&db, 1, tool, args.clone());
2755            assert!(
2756                is_error(&resp),
2757                "{tool} {args} must be a tool error, got {resp}"
2758            );
2759            assert_eq!(tool_err_text(&resp), want, "for {tool} {args}");
2760        }
2761        // Absent or null keeps the default.
2762        let resp = tool_call(
2763            &db,
2764            2,
2765            "pairwise_similar",
2766            json!({ "keys": ["alice", "bob"], "field": "emb", "k": null }),
2767        );
2768        assert!(!is_error(&resp), "null takes the default: {resp}");
2769        assert_eq!(tool_text(&resp)["k"], json!(10));
2770    }
2771
2772    /// Ledger row 88: `create_rule` accepts `via_label`, `via_edge` and
2773    /// `via_dir`, so its advertised schema names them.
2774    #[test]
2775    fn create_rule_schema_advertises_the_via_fields() {
2776        let db = demo_db();
2777        let resp = roundtrip_with(
2778            &db,
2779            true,
2780            r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
2781        );
2782        let tools = resp["result"]["tools"].as_array().expect("tools array");
2783        let rule = tools
2784            .iter()
2785            .find(|t| t["name"] == "create_rule")
2786            .expect("create_rule");
2787        let props = &rule["inputSchema"]["properties"];
2788        for field in ["via_label", "via_edge", "via_dir"] {
2789            assert!(props.get(field).is_some(), "{field} missing: {props}");
2790        }
2791        assert_eq!(props["via_dir"]["enum"], json!(["Out", "In"]));
2792    }
2793
2794    /// A `min`, `k` or `limit` that is present and not the number it should be
2795    /// is a tool error, as `min` and `k` are over HTTP — never the default
2796    /// applied in silence. Absent or null still takes the default.
2797    #[test]
2798    fn test_find_similar_non_numeric_min_k_or_limit_is_error() {
2799        let db = demo_db();
2800        for (args, want) in [
2801            (
2802                json!({ "vector": [1.0, 0.0], "field": "emb", "min": "0.5" }),
2803                "min must be a number",
2804            ),
2805            (
2806                json!({ "vector": [1.0, 0.0], "field": "emb", "k": "3" }),
2807                "k must be a non-negative integer",
2808            ),
2809            (
2810                json!({ "vector": [1.0, 0.0], "field": "emb", "k": -1 }),
2811                "k must be a non-negative integer",
2812            ),
2813            (
2814                json!({ "key": "alice", "limit": "1" }),
2815                "limit must be a non-negative integer",
2816            ),
2817        ] {
2818            let resp = tool_call(&db, 1, "find_similar", args.clone());
2819            assert!(is_error(&resp), "{args} must be a tool error, got {resp}");
2820            assert_eq!(tool_err_text(&resp), want, "for {args}");
2821        }
2822
2823        // Null is absence: the defaults apply and the reply echoes them.
2824        let resp = tool_call(
2825            &db,
2826            2,
2827            "find_similar",
2828            json!({ "vector": [1.0, 0.0], "field": "emb", "k": null, "min": null }),
2829        );
2830        assert!(!is_error(&resp), "null takes the default: {resp}");
2831        let result = tool_text(&resp);
2832        assert_eq!(result["k"], json!(10));
2833        assert_eq!(result["min"], json!(mushroomdb::FIND_SIMILAR_DEFAULT_MIN));
2834        let resp = tool_call(
2835            &db,
2836            3,
2837            "find_similar",
2838            json!({ "key": "alice", "limit": null }),
2839        );
2840        assert!(!is_error(&resp), "a null limit takes the default: {resp}");
2841    }
2842
2843    /// Vector-mode `where` eq filters to matching nodes. Default `min` stays 0.8.
2844    #[test]
2845    fn test_find_similar_vector_where_eq() {
2846        let _dir = tmp_dir();
2847        let db = SharedDb::open(_dir.path()).expect("open");
2848        {
2849            let mut g = db.write();
2850            g.insert_node(
2851                "Document",
2852                "in-scope",
2853                vec![
2854                    (
2855                        "emb".into(),
2856                        Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2857                    ),
2858                    ("resource_scope_id".into(), Value::Str("a".into())),
2859                ],
2860            )
2861            .unwrap();
2862            g.insert_node(
2863                "Document",
2864                "out-scope",
2865                vec![
2866                    (
2867                        "emb".into(),
2868                        Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2869                    ),
2870                    ("resource_scope_id".into(), Value::Str("b".into())),
2871                ],
2872            )
2873            .unwrap();
2874        }
2875        let resp = tool_call(
2876            &db,
2877            1,
2878            "find_similar",
2879            json!({
2880                "vector": [1.0, 0.0],
2881                "field": "emb",
2882                "label": "Document",
2883                "k": 10,
2884                "min": 0.0,
2885                "where": { "field": "resource_scope_id", "eq": "a" }
2886            }),
2887        );
2888        assert!(!is_error(&resp), "where eq must not error: {resp:?}");
2889        let result = tool_text(&resp);
2890        let keys: Vec<&str> = result["results"]
2891            .as_array()
2892            .expect("results")
2893            .iter()
2894            .filter_map(|r| r["key"].as_str())
2895            .collect();
2896        assert_eq!(keys, vec!["in-scope"]);
2897    }
2898
2899    #[test]
2900    fn test_find_similar_vector_where_invalid_is_error() {
2901        let _dir = tmp_dir();
2902        let db = SharedDb::open(_dir.path()).expect("open");
2903        let resp = tool_call(
2904            &db,
2905            1,
2906            "find_similar",
2907            json!({
2908                "vector": [1.0, 0.0],
2909                "field": "emb",
2910                "where": { "field": "resource_scope_id", "eq": "a", "in": ["b"] }
2911            }),
2912        );
2913        assert!(is_error(&resp), "invalid where must be a tool error");
2914        let msg = format!("{resp:?}");
2915        assert!(
2916            msg.contains("where"),
2917            "tool error must name where, got {msg}"
2918        );
2919    }
2920
2921    /// Edge-traversal mode ignores `where` and `exact`.
2922    #[test]
2923    fn test_find_similar_edge_ignores_where_and_exact() {
2924        let db = demo_db();
2925        let resp = tool_call(
2926            &db,
2927            1,
2928            "find_similar",
2929            json!({
2930                "key": "alice",
2931                "edge_type": "SIMILAR",
2932                "where": { "field": "x", "eq": "y", "in": ["z"] },
2933                "exact": true
2934            }),
2935        );
2936        assert!(
2937            !is_error(&resp),
2938            "edge mode must ignore invalid where: {resp:?}"
2939        );
2940    }
2941
2942    /// Edge-traversal mode with `mask` must exclude hidden neighbors.
2943    #[test]
2944    fn test_find_similar_edge_mask_excludes_hidden_neighbor() {
2945        let _dir = tmp_dir();
2946        let db = SharedDb::open(_dir.path()).expect("open");
2947        {
2948            let mut g = db.write();
2949            g.insert_node("P", "alice", vec![]).unwrap();
2950            g.insert_node("P", "bob", vec![]).unwrap(); // visible
2951            g.insert_node("P", "carol", vec![]).unwrap(); // hidden
2952            g.insert_edge("KNOWS", "alice", "bob").unwrap();
2953            g.insert_edge("KNOWS", "alice", "carol").unwrap();
2954        }
2955        // Mask: alice and bob visible; carol hidden.
2956        let resp = tool_call(
2957            &db,
2958            1,
2959            "find_similar",
2960            json!({
2961                "key": "alice",
2962                "edge_type": "KNOWS",
2963                "mask": ["alice", "bob"]
2964            }),
2965        );
2966        assert!(!is_error(&resp), "masked edge search must not error");
2967        let result = tool_text(&resp);
2968        let similar = result["similar"].as_array().expect("similar array");
2969        let neighbors: Vec<&str> = similar
2970            .iter()
2971            .filter_map(|e| e["neighbor_key"].as_str())
2972            .collect();
2973        assert!(neighbors.contains(&"bob"), "bob (visible) must appear");
2974        assert!(
2975            !neighbors.contains(&"carol"),
2976            "carol (hidden) must be excluded"
2977        );
2978    }
2979
2980    /// Edge-traversal mode with `mask`: a hidden query key must not reveal
2981    /// its existence — response must be a tool error identical to a nonexistent key.
2982    #[test]
2983    fn test_find_similar_edge_mask_hidden_key_is_not_found() {
2984        let _dir = tmp_dir();
2985        let db = SharedDb::open(_dir.path()).expect("open");
2986        {
2987            let mut g = db.write();
2988            g.insert_node("P", "alice", vec![]).unwrap();
2989            g.insert_node("P", "bob", vec![]).unwrap();
2990        }
2991        // alice exists but is not in the mask — must look like not-found.
2992        let resp_masked = tool_call(
2993            &db,
2994            1,
2995            "find_similar",
2996            json!({ "key": "alice", "edge_type": "KNOWS", "mask": ["bob"] }),
2997        );
2998        // ghost never exists — use as the reference for "not found".
2999        let resp_ghost = tool_call(
3000            &db,
3001            2,
3002            "find_similar",
3003            json!({ "key": "ghost", "edge_type": "KNOWS" }),
3004        );
3005        assert!(
3006            is_error(&resp_masked),
3007            "hidden query key must produce a tool error"
3008        );
3009        assert!(
3010            is_error(&resp_ghost),
3011            "nonexistent key must produce a tool error"
3012        );
3013        // Both errors must carry the same shape (both are key-not-found).
3014        assert_eq!(
3015            tool_err_text(&resp_masked).contains("alice"),
3016            tool_err_text(&resp_ghost).contains("ghost"),
3017            "error messages should follow same not-found template"
3018        );
3019    }
3020
3021    /// Binding: `explain_association` now answers in prose, and the report
3022    /// behind it — what `json: true` returns — is still `explain`'s array,
3023    /// with one `evidence` object added per relationship and every other
3024    /// field unchanged.
3025    #[test]
3026    fn test_explain_association_same_as_explain() {
3027        let db = demo_db();
3028        let explain = tool_text(&tool_call(
3029            &db,
3030            1,
3031            "explain",
3032            json!({ "a": "alice", "b": "bob" }),
3033        ));
3034        let assoc = tool_text(&tool_call(
3035            &db,
3036            2,
3037            "explain_association",
3038            json!({ "a": "alice", "b": "bob", "json": true }),
3039        ));
3040        let explain: Vec<Js> = serde_json::from_value(explain).expect("explain array");
3041        let mut assoc: Vec<Js> = serde_json::from_value(assoc).expect("assoc array");
3042        for row in &mut assoc {
3043            let ev = row
3044                .as_object_mut()
3045                .expect("object")
3046                .remove("evidence")
3047                .expect("every derived edge carries its evidence");
3048            assert!(
3049                ev["similarity"].is_number(),
3050                "a vector_similar edge reports the cosine it scored: {ev}"
3051            );
3052        }
3053        assert_eq!(explain, assoc, "evidence is the only addition");
3054
3055        let prose = tool_call(
3056            &db,
3057            3,
3058            "explain_association",
3059            json!({ "a": "alice", "b": "bob" }),
3060        );
3061        let text = prose["result"]["content"][0]["text"]
3062            .as_str()
3063            .expect("text content");
3064        assert!(
3065            text.contains("mushroomdb explain — alice ↔ bob:"),
3066            "the default reply is the digest: {text}"
3067        );
3068    }
3069
3070    // ── history tools ──────────────────────────────────────────────────────────
3071
3072    /// `edge_history` must return the derived-edge lifecycle (Added event with
3073    /// rule attribution) and include the `total_commits` horizon field.
3074    #[test]
3075    fn test_edge_history_returns_derived_lifecycle_with_rule() {
3076        let db = demo_db(); // alice+bob + sim_emb rule → SIMILAR derived edge
3077        let resp = tool_call(&db, 1, "edge_history", json!({ "a": "alice", "b": "bob" }));
3078        assert!(!is_error(&resp), "edge_history must not error: {resp}");
3079        let result = tool_text(&resp);
3080
3081        // Must carry horizon metadata.
3082        let total = result["total_commits"].as_u64().expect("total_commits");
3083        assert!(total > 0, "total_commits must be > 0 after ingest + rule");
3084
3085        // Must have at least one event (the SIMILAR derived-edge addition).
3086        let events = result["events"].as_array().expect("events array");
3087        assert!(!events.is_empty(), "expected at least one edge event");
3088
3089        // At least one event must be Added with a non-null rule (derived edge).
3090        let derived_added = events
3091            .iter()
3092            .any(|ev| ev["event"].as_str() == Some("Added") && !ev["rule"].is_null());
3093        assert!(
3094            derived_added,
3095            "expected a derived Added event with rule attribution: {events:?}"
3096        );
3097    }
3098
3099    /// `was_linked` must return `true` for an edge that was active at the given commit,
3100    /// and the response must include the echo fields.
3101    #[test]
3102    fn test_was_linked_at_valid_commit() {
3103        let _dir = tmp_dir();
3104        let db = SharedDb::open(_dir.path()).expect("open");
3105        {
3106            let mut g = db.write();
3107            let opts = IngestOptions {
3108                key_field: "id".into(),
3109                auto_fk: AutoFk::Off,
3110            };
3111            let rows: Vec<BTreeMap<String, Value>> = vec![
3112                [("id", Value::Str("x".into()))]
3113                    .into_iter()
3114                    .map(|(k, v)| (k.to_string(), v))
3115                    .collect(),
3116                [("id", Value::Str("y".into()))]
3117                    .into_iter()
3118                    .map(|(k, v)| (k.to_string(), v))
3119                    .collect(),
3120            ];
3121            g.ingest("N", rows, &opts).expect("ingest");
3122            g.insert_edge("LINK", "x", "y").expect("edge");
3123        }
3124        // There are now at least 2 commits (ingest + edge). Check at the last one.
3125        let g = db.read();
3126        let total = g.wal_total_commits().expect("wal_total_commits");
3127        drop(g);
3128
3129        let resp = tool_call(
3130            &db,
3131            1,
3132            "was_linked",
3133            json!({ "a": "x", "b": "y", "edge_type": "LINK", "at_commit": total - 1 }),
3134        );
3135        assert!(!is_error(&resp), "was_linked must not error: {resp}");
3136        let result = tool_text(&resp);
3137        assert_eq!(result["linked"], true);
3138        assert_eq!(result["a"], "x");
3139        assert_eq!(result["edge_type"], "LINK");
3140    }
3141
3142    /// `was_linked` with an out-of-horizon commit must return a tool error (not
3143    /// a protocol error), and the error message must mention the commit range.
3144    #[test]
3145    fn test_was_linked_out_of_horizon_returns_tool_error() {
3146        let _dir = tmp_dir();
3147        let db = SharedDb::open(_dir.path()).expect("open");
3148        {
3149            let mut g = db.write();
3150            g.insert_node("N", "a", vec![]).expect("node a");
3151            g.insert_node("N", "b", vec![]).expect("node b");
3152        }
3153        // Commit 999 is well beyond the WAL.
3154        let resp = tool_call(
3155            &db,
3156            1,
3157            "was_linked",
3158            json!({ "a": "a", "b": "b", "edge_type": "X", "at_commit": 999 }),
3159        );
3160        // isError true = tool-level error (not a JSON-RPC protocol error).
3161        assert!(
3162            is_error(&resp),
3163            "out-of-range commit must be a tool error: {resp}"
3164        );
3165        let text = resp["result"]["content"][0]["text"].as_str().expect("text");
3166        assert!(
3167            text.contains("out of range") || text.contains("range"),
3168            "error must mention range: {text}"
3169        );
3170    }
3171
3172    /// `node_history` tool must return the node's WAL history and the
3173    /// `total_commits` horizon field.
3174    #[test]
3175    fn test_node_history_via_mcp() {
3176        let db = demo_db(); // alice + bob, with a SIMILAR rule
3177        let resp = tool_call(&db, 1, "node_history", json!({ "key": "alice" }));
3178        assert!(!is_error(&resp), "node_history must not error: {resp}");
3179        let result = tool_text(&resp);
3180
3181        assert_eq!(result["key"], "alice");
3182        let total = result["total_commits"].as_u64().expect("total_commits");
3183        assert!(total > 0, "total_commits must be > 0");
3184
3185        let history = result["history"].as_array().expect("history array");
3186        assert!(
3187            !history.is_empty(),
3188            "alice should have at least one history entry"
3189        );
3190
3191        // First event should be a NodeInserted.
3192        let first_change = &history[0]["change"];
3193        assert_eq!(first_change["type"], "NodeInserted");
3194        assert_eq!(first_change["label"], "Person");
3195    }
3196}