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            Some(v) => {
693                row.insert(field.clone(), v);
694            }
695            None => {
696                return CallOutcome::ToolErr(format!("prop {field} is not a supported value type"))
697            }
698        }
699    }
700    if let Some(ns) = namespace.as_deref() {
701        if let Err(e) = stamp_namespace_row(&mut row, ns) {
702            return CallOutcome::ToolErr(e);
703        }
704    }
705
706    // One write guard for the existence check and the write: the policy —
707    // label on create, no relabel, the namespace no-op, `id` from the key —
708    // is `core-api`'s, shared with the Python binding.
709    let outcome = {
710        let mut g = db.write();
711        match memory::remember::upsert_entity(&mut *g, key, label_opt, row, &aliases) {
712            Ok(o) => o,
713            Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
714        }
715    };
716    if outcome.created {
717        return CallOutcome::ToolOk(json!({
718            "ok": true,
719            "key": key,
720            "label": outcome.label,
721            "created": true
722        }));
723    }
724    let mut reply = json!({
725        "ok": true,
726        "key": key,
727        "label": outcome.label,
728        "created": false,
729        "updated_fields": outcome.updated_fields
730    });
731    // Absent when nothing was lost, so an ordinary update's reply is
732    // unchanged.
733    let lost = &outcome.same_as_lost;
734    if !lost.is_empty() {
735        reply["same_as_lost"] = crate::mcp_tasks::same_as_lost_json(lost);
736        reply["same_as_lost_total"] = json!(lost.len());
737        reply["same_as_lost_note"] = json!(format!(
738            "this write retracted {} SAME_AS link(s): {}",
739            lost.len(),
740            crate::mcp_tasks::SAME_AS_LOST_REMEDY
741        ));
742    }
743    CallOutcome::ToolOk(reply)
744}
745
746/// Return neighbors connected by a given edge type (default `"SIMILAR"`).
747///
748/// Results are read from edges already materialized by a derivation rule
749/// (e.g. a `VectorSimilar` rule). Without a matching rule the returned list
750/// is empty — no live cosine computation is performed here.
751/// Returns up to `limit` (default 10) neighbor entries.
752fn tool_find_similar(db: &SharedDb, args: &Js) -> CallOutcome {
753    // Parse the optional mask once — it applies to both vector and edge paths.
754    // An invalid mask value (non-array or non-string element) fails closed.
755    let mask_keys: Option<Vec<String>> = if let Some(mask_val) = args.get("mask") {
756        match mask_val.as_array() {
757            Some(arr) => {
758                let mut ks: Vec<String> = Vec::with_capacity(arr.len());
759                for v in arr {
760                    match v.as_str() {
761                        Some(s) => ks.push(s.to_string()),
762                        None => {
763                            return CallOutcome::ToolErr("mask must be an array of strings".into())
764                        }
765                    }
766                }
767                Some(ks)
768            }
769            None => return CallOutcome::ToolErr("mask must be an array of strings".into()),
770        }
771    } else {
772        None
773    };
774
775    // When a `vector` array is provided, use the HNSW / brute-force vector
776    // similarity path instead of looking up pre-derived edges.
777    if let Some(vec_js) = args.get("vector").and_then(Js::as_array) {
778        let q: Vec<f64> = vec_js.iter().filter_map(|v| v.as_f64()).collect();
779        if q.is_empty() {
780            return CallOutcome::ToolErr("vector must be a non-empty array of numbers".into());
781        }
782        let field = args
783            .get("field")
784            .and_then(Js::as_str)
785            .unwrap_or("embedding");
786        let label_str = args.get("label").and_then(Js::as_str).unwrap_or("");
787        let label = if label_str.is_empty() {
788            None
789        } else {
790            Some(label_str)
791        };
792        // Absent or null takes the default; anything else that is not the
793        // number asked for is refused, as HTTP refuses it.
794        let k = match args.get("k") {
795            None | Some(Js::Null) => 10usize,
796            Some(v) => match v.as_u64() {
797                Some(n) => n as usize,
798                None => return CallOutcome::ToolErr("k must be a non-negative integer".into()),
799            },
800        };
801        let min = match args.get("min") {
802            None | Some(Js::Null) => mushroomdb::FIND_SIMILAR_DEFAULT_MIN,
803            Some(v) => match v.as_f64() {
804                Some(n) => n,
805                None => return CallOutcome::ToolErr("min must be a number".into()),
806            },
807        };
808        let where_pred = match parse_where_arg(args) {
809            Ok(p) => p,
810            Err(e) => return CallOutcome::ToolErr(e),
811        };
812        let exact = match args.get("exact") {
813            None => false,
814            Some(v) => match v.as_bool() {
815                Some(b) => b,
816                None => return CallOutcome::ToolErr("exact must be a boolean".into()),
817            },
818        };
819        let exact = exact || where_pred.is_some();
820
821        let hits = {
822            let g = db.read();
823            let node_mask = mask_keys
824                .as_ref()
825                .map(|keys| NodeMask::from_keys(&*g, keys.iter().map(String::as_str)));
826            match g.find_similar_vector_filtered(
827                field,
828                label,
829                &q,
830                k,
831                min,
832                node_mask.as_ref(),
833                where_pred.as_ref(),
834                exact,
835            ) {
836                Ok(h) => h,
837                Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
838            }
839        };
840        let results: Vec<Js> = hits
841            .into_iter()
842            .map(|(key, score)| json!({ "key": key, "score": score }))
843            .collect();
844        return CallOutcome::ToolOk(json!({
845            "mode": "vector",
846            "field": field,
847            "label": label,
848            "k": k,
849            "min": min,
850            "results": results
851        }));
852    }
853
854    // Edge-traversal path: return neighbors connected by the given edge type.
855    let Some(key) = args.get("key").and_then(Js::as_str) else {
856        return CallOutcome::ToolErr("missing key (or provide vector for vector search)".into());
857    };
858    let edge_type = args
859        .get("edge_type")
860        .and_then(Js::as_str)
861        .unwrap_or("SIMILAR");
862    let limit = match args.get("limit") {
863        None | Some(Js::Null) => 10usize,
864        Some(v) => match v.as_u64() {
865            Some(n) => n as usize,
866            None => return CallOutcome::ToolErr("limit must be a non-negative integer".into()),
867        },
868    };
869
870    // When a mask is present, a hidden query key behaves identically to a
871    // nonexistent key — we do not confirm its existence.
872    if let Some(ref mask) = mask_keys {
873        let mask_set: std::collections::HashSet<&str> = mask.iter().map(String::as_str).collect();
874        if !mask_set.contains(key) {
875            return CallOutcome::ToolErr(graph_err_msg(GraphError::KeyNotFound {
876                key: key.into(),
877            }));
878        }
879        let out = {
880            let g = db.read();
881            g.node_edges(key)
882        };
883        return match out {
884            Ok(edges) => {
885                let similar: Vec<Js> = edges
886                    .iter()
887                    .filter(|e| e.edge_type == edge_type)
888                    .filter(|e| {
889                        // Keep only edges where the neighbor is also visible.
890                        let neighbor_key = if e.src_key == key {
891                            &e.dst_key
892                        } else {
893                            &e.src_key
894                        };
895                        mask_set.contains(neighbor_key.as_str())
896                    })
897                    .take(limit)
898                    .map(|e| {
899                        let neighbor_key = if e.src_key == key {
900                            &e.dst_key
901                        } else {
902                            &e.src_key
903                        };
904                        let direction = if e.src_key == key { "out" } else { "in" };
905                        json!({
906                            "neighbor_key": neighbor_key,
907                            "direction": direction,
908                            "edge_type": e.edge_type,
909                            "derived": e.derived,
910                        })
911                    })
912                    .collect();
913                CallOutcome::ToolOk(json!({
914                    "key": key,
915                    "edge_type": edge_type,
916                    "similar": similar
917                }))
918            }
919            Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
920        };
921    }
922
923    let out = {
924        let g = db.read();
925        g.node_edges(key)
926    };
927    match out {
928        Ok(edges) => {
929            let similar: Vec<Js> = edges
930                .iter()
931                .filter(|e| e.edge_type == edge_type)
932                .take(limit)
933                .map(|e| {
934                    let neighbor_key = if e.src_key == key {
935                        &e.dst_key
936                    } else {
937                        &e.src_key
938                    };
939                    let direction = if e.src_key == key { "out" } else { "in" };
940                    json!({
941                        "neighbor_key": neighbor_key,
942                        "direction": direction,
943                        "edge_type": e.edge_type,
944                        "derived": e.derived,
945                    })
946                })
947                .collect();
948            CallOutcome::ToolOk(json!({
949                "key": key,
950                "edge_type": edge_type,
951                "similar": similar
952            }))
953        }
954        Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
955    }
956}
957
958/// Exact cosine top-k among a caller key set. Self excluded. Never HNSW.
959fn tool_pairwise_similar(db: &SharedDb, args: &Js) -> CallOutcome {
960    let Some(keys_js) = args.get("keys").and_then(Js::as_array) else {
961        return CallOutcome::ToolErr("missing required field: keys".into());
962    };
963    let mut keys: Vec<String> = Vec::with_capacity(keys_js.len());
964    for v in keys_js {
965        match v.as_str() {
966            Some(s) => keys.push(s.to_string()),
967            None => return CallOutcome::ToolErr("keys must be an array of strings".into()),
968        }
969    }
970    let Some(field) = args.get("field").and_then(Js::as_str) else {
971        return CallOutcome::ToolErr("missing required field: field".into());
972    };
973    let k = args
974        .get("k")
975        .and_then(Js::as_u64)
976        .map(|n| n as usize)
977        .unwrap_or(10);
978    let min = args.get("min").and_then(Js::as_f64).unwrap_or(0.0);
979    let refs: Vec<&str> = keys.iter().map(String::as_str).collect();
980    let out = {
981        let g = db.read();
982        g.pairwise_similar(&refs, field, k, min)
983    };
984    match out {
985        Ok(pairs) => {
986            let results: Vec<Js> = pairs
987                .into_iter()
988                .map(|(key, neighbors)| {
989                    json!({
990                        "key": key,
991                        "neighbors": neighbors
992                            .into_iter()
993                            .map(|(n, score)| json!({ "key": n, "score": score }))
994                            .collect::<Vec<_>>(),
995                    })
996                })
997                .collect();
998            CallOutcome::ToolOk(json!({
999                "field": field,
1000                "k": k,
1001                "min": min,
1002                "results": results
1003            }))
1004        }
1005        Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1006    }
1007}
1008
1009fn tool_hybrid_search(db: &SharedDb, args: &Js) -> CallOutcome {
1010    let Some(query_text) = args.get("query_text").and_then(Js::as_str) else {
1011        return CallOutcome::ToolErr("missing required field: query_text".into());
1012    };
1013    let Some(text_field) = args.get("text_field").and_then(Js::as_str) else {
1014        return CallOutcome::ToolErr("missing required field: text_field".into());
1015    };
1016
1017    let vector_field = args
1018        .get("vector_field")
1019        .and_then(Js::as_str)
1020        .unwrap_or("embedding");
1021    let label = args.get("label").and_then(Js::as_str);
1022    let k = args
1023        .get("k")
1024        .and_then(Js::as_u64)
1025        .map(|n| n as usize)
1026        .unwrap_or(10);
1027
1028    let query_vec: Vec<f64> = args
1029        .get("vector")
1030        .and_then(Js::as_array)
1031        .map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect())
1032        .unwrap_or_default();
1033
1034    let hits = {
1035        let g = db.read();
1036        g.search_hybrid(text_field, query_text, vector_field, &query_vec, label, k)
1037    };
1038
1039    let results: Vec<Js> = hits
1040        .into_iter()
1041        .map(|(key, score)| json!({ "key": key, "score": score }))
1042        .collect();
1043
1044    CallOutcome::ToolOk(json!({
1045        "query_text": query_text,
1046        "text_field": text_field,
1047        "vector_field": vector_field,
1048        "label": label,
1049        "k": k,
1050        "results": results
1051    }))
1052}
1053
1054fn tool_node_history(db: &SharedDb, args: &Js) -> CallOutcome {
1055    let Some(key) = args.get("key").and_then(Js::as_str) else {
1056        return CallOutcome::ToolErr("missing key".into());
1057    };
1058    let g = db.read();
1059    let result = match g.node_history(key) {
1060        Ok(e) => e,
1061        Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
1062    };
1063    CallOutcome::ToolOk(node_history_json(key, &result))
1064}
1065
1066fn tool_edge_history(db: &SharedDb, args: &Js) -> CallOutcome {
1067    let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1068        return CallOutcome::ToolErr("missing a".into());
1069    };
1070    let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1071        return CallOutcome::ToolErr("missing b".into());
1072    };
1073    let result = {
1074        let g = db.read();
1075        g.edge_history(a, b)
1076    };
1077    match result {
1078        Ok(hr) => CallOutcome::ToolOk(edge_history_result_json(a, b, &hr)),
1079        Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1080    }
1081}
1082
1083fn tool_was_linked(db: &SharedDb, args: &Js) -> CallOutcome {
1084    let Some(a) = args.get("a").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1085        return CallOutcome::ToolErr("missing a".into());
1086    };
1087    let Some(b) = args.get("b").and_then(Js::as_str).filter(|s| !s.is_empty()) else {
1088        return CallOutcome::ToolErr("missing b".into());
1089    };
1090    let Some(edge_type) = args
1091        .get("edge_type")
1092        .and_then(Js::as_str)
1093        .filter(|s| !s.is_empty())
1094    else {
1095        return CallOutcome::ToolErr("missing edge_type".into());
1096    };
1097    // Like `edges_at`, `at_commit` takes a date as well as an index. A caller
1098    // asking "were they linked on 2026-06-19" should not have to find the
1099    // commit themselves.
1100    let at_commit = match args.get("at_commit") {
1101        Some(Js::String(date)) => match db.read().resolve_date(date) {
1102            Ok(c) => c,
1103            Err(e) => return CallOutcome::ToolErr(graph_err_msg(e)),
1104        },
1105        Some(v) => match v.as_u64() {
1106            Some(n) => n,
1107            None => {
1108                return CallOutcome::ToolErr(
1109                    "at_commit must be a non-negative commit index or an RFC 3339 date".into(),
1110                )
1111            }
1112        },
1113        None => return CallOutcome::ToolErr("missing or invalid at_commit".into()),
1114    };
1115    let result = {
1116        let g = db.read();
1117        g.was_linked(a, b, edge_type, at_commit)
1118    };
1119    match result {
1120        Ok(linked) => CallOutcome::ToolOk(json!({
1121            "a": a,
1122            "b": b,
1123            "edge_type": edge_type,
1124            "at_commit": at_commit,
1125            "linked": linked,
1126        })),
1127        Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1128    }
1129}
1130
1131fn tool_rename_node(db: &SharedDb, args: &Js) -> CallOutcome {
1132    let Some(old_key) = args.get("old_key").and_then(Js::as_str) else {
1133        return CallOutcome::ToolErr("missing old_key".into());
1134    };
1135    let Some(new_key) = args.get("new_key").and_then(Js::as_str) else {
1136        return CallOutcome::ToolErr("missing new_key".into());
1137    };
1138    let mut g = db.write();
1139    match g.rename_node(old_key, new_key) {
1140        Ok(()) => CallOutcome::ToolOk(json!({
1141            "ok": true,
1142            "old_key": old_key,
1143            "new_key": new_key,
1144        })),
1145        Err(e) => CallOutcome::ToolErr(graph_err_msg(e)),
1146    }
1147}
1148
1149fn parse_where_arg(args: &Js) -> std::result::Result<Option<PropPredicate>, String> {
1150    let Some(w) = args.get("where") else {
1151        return Ok(None);
1152    };
1153    if w.is_null() {
1154        return Ok(None);
1155    }
1156    let pred: PropPredicate =
1157        serde_json::from_value(w.clone()).map_err(|e| format!("where: {e}"))?;
1158    pred.validate_named("where")?;
1159    Ok(Some(pred))
1160}
1161
1162pub(crate) fn graph_err_msg(e: GraphError) -> String {
1163    match e {
1164        GraphError::QueryError { detail } | GraphError::IngestError { detail } => detail,
1165        other => other.to_string(),
1166    }
1167}
1168
1169fn initialize_result() -> Js {
1170    json!({
1171        "protocolVersion": "2024-11-05",
1172        "capabilities": { "tools": {} },
1173        "serverInfo": { "name": "mushroomdb", "version": env!("CARGO_PKG_VERSION") }
1174    })
1175}
1176
1177/// The prefix every graph tool's description carries.
1178///
1179/// A host that ranks tools by their description now has one signal that the
1180/// task tools are the ones to reach for first, and that everything
1181/// under this prefix is the lower-level surface beneath them.
1182const ADVANCED_PREFIX: &str = "Advanced: ";
1183
1184/// The tools every store advertises, in the order it lists them.
1185///
1186/// A store with no repository in it used to be handed the code door's own task
1187/// tools, which answered from a code graph there was none of. Those tools are
1188/// gone, and a store `ingest-git` built is an entity store like any other, so
1189/// it is served this same listing. These are
1190/// the questions an entity graph *can* answer: what is there (`query` — now
1191/// with a `role`), why two things are associated, what is around a node, what
1192/// it is and what it is joined to, whether a link held at a commit and when it
1193/// changed, what is like it, and what was written down about it.
1194///
1195/// Listing order is ranking: a host that defers schemas shows this list in
1196/// order, so the two questions this door exists for come first.
1197///
1198/// `edges_at` and `what_if` are the two the first association benchmark run
1199/// showed missing: a run asked what a node's relationships were at a past
1200/// commit and spent twenty to sixty-seven turns replaying `edge_history` for
1201/// it, and had no way at all to ask what a change would do. They sit after
1202/// `was_linked`, which is the narrowest form of the same time question.
1203///
1204/// [`tools_list`] decides what is *advertised*, never what is answered: the
1205/// two served tools this leaves out, `explain` and `rename_node`, stay callable.
1206///
1207/// `upsert_entity`, `ingest_json` and `create_rule` were added in 0.6.12, and
1208/// the reason is the shape of a first session. A new install opens on an empty
1209/// store; the skill's first instruction is to offer to fill it, naming
1210/// `ingest_json` for a batch and `upsert_entity` for one; and the README sells
1211/// `upsert_entity -> create_rule -> find_similar -> explain_association` as the
1212/// minimal workflow. Two of those four were served and not advertised — and a
1213/// host builds its tool set from `tools/list`, so "served" did not help. The
1214/// listing described a store you could question but never populate.
1215///
1216/// They sit after the readers deliberately. Listing order is ranking and the
1217/// questions remain the point; writing is what a session does once, at the
1218/// start, before it has anything to ask.
1219pub const ASSOCIATION_TOOLS: [&str; 23] = [
1220    "query",
1221    "explain_association",
1222    "neighborhood",
1223    "node_info",
1224    "node_edges",
1225    "was_linked",
1226    "edges_at",
1227    "what_if",
1228    "node_history",
1229    "edge_history",
1230    "find_similar",
1231    "pairwise_similar",
1232    "hybrid_search",
1233    "remember",
1234    "recall",
1235    "upsert_entity",
1236    "ingest_json",
1237    "create_rule",
1238    "stats",
1239    "schema",
1240    "analyze",
1241    "suggest_rules",
1242    "forget",
1243];
1244
1245/// The tools `tools/list` advertises: the task tools, then the
1246/// graph tools with their descriptions prefixed.
1247///
1248/// `all` false — the default — lists [`ASSOCIATION_TOOLS`], **in the order it
1249/// names them**. The order is the point. A host that defers tool schemas makes
1250/// a model search for them, and the list it searches is read top-down.
1251///
1252/// `all` true lists every served tool, task tools first and graph tools after,
1253/// which is what `mushroomdb mcp --all-tools` runs and what the published
1254/// server card documents.
1255///
1256/// Either way every tool stays callable: the flag decides what is advertised,
1257/// not what is served.
1258fn tools_list(all: bool) -> Js {
1259    let mut served: Vec<Js> = crate::mcp_tasks::task_tools();
1260    for mut tool in graph_tools() {
1261        if let Some(d) = tool.get("description").and_then(Js::as_str) {
1262            let prefixed = format!("{ADVANCED_PREFIX}{d}");
1263            tool["description"] = Js::String(prefixed);
1264        }
1265        served.push(tool);
1266    }
1267    if all {
1268        return json!({ "tools": served });
1269    }
1270    let mut tools: Vec<Js> = Vec::with_capacity(ASSOCIATION_TOOLS.len());
1271    for name in ASSOCIATION_TOOLS {
1272        let Some(tool) = served
1273            .iter()
1274            .find(|t| t.get("name").and_then(Js::as_str) == Some(name))
1275        else {
1276            debug_assert!(false, "ASSOCIATION_TOOLS lists {name}, which is not served");
1277            continue;
1278        };
1279        tools.push(tool.clone());
1280    }
1281    json!({ "tools": tools })
1282}
1283
1284/// The fourteen graph tools, in the order they have always been listed, with
1285/// their descriptions unprefixed. [`tools_list`] adds the prefix.
1286fn graph_tools() -> Vec<Js> {
1287    let Js::Array(tools) = json!([
1288            {
1289                "name": "query",
1290                "description": "Who may see this, and anything else one pattern can answer — run a Cypher query (read or write) against the graph. Pass 'role' to answer as one of the store's roles: only the nodes that role may see, writes refused. 'mask' is the same restriction written out as an explicit key allow-list. Pass 'as_of' to answer from a past commit; it composes with 'role' or with 'mask'. Pass 'namespace' to answer from one namespace only. Cypher dialect: MATCH/WHERE/RETURN, CREATE, MERGE, SET, DELETE, with $named parameters in 'params'. A node's key and label read as properties (n.key, n.label) or as key(n)/labels(n). One MATCH takes comma-separated patterns that share variables — MATCH (t)-[:A]->(c), (t)-[:B]->(c) is the intersection of both, and count(DISTINCT t) after WITH counts each t once. WHERE takes STARTS WITH, ENDS WITH, CONTAINS, IN, and a list subscript (n.location[0]) — which is null when the index is out of range, the property is not a list, or the index is not an integer, so a subscript never errors and never matches.",
1291                "inputSchema": {
1292                    "type": "object",
1293                    "properties": {
1294                        "cypher": { "type": "string", "description": "Cypher query text." },
1295                        "params": {
1296                            "type": "object",
1297                            "description": "Named JSON-scalar query parameters."
1298                        },
1299                        "mask": {
1300                            "type": "array",
1301                            "items": { "type": "string" },
1302                            "description": "Optional node key allow-list. When present, only these nodes are visible; write statements are rejected."
1303                        },
1304                        "role": {
1305                            "type": "string",
1306                            "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."
1307                        },
1308                        "as_of": {
1309                            "type": "integer",
1310                            "minimum": 0,
1311                            "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."
1312                        },
1313                        "namespace": {
1314                            "type": "string",
1315                            "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."
1316                        }
1317                    },
1318                    "required": ["cypher"]
1319                }
1320            },
1321            {
1322                "name": "ingest_json",
1323                "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'.",
1324                "inputSchema": {
1325                    "type": "object",
1326                    "properties": {
1327                        "label": { "type": "string" },
1328                        "rows_json": {
1329                            "type": "string",
1330                            "description": "JSON text of an array of objects."
1331                        },
1332                        "key_field": { "type": "string" },
1333                        "auto_fk_suffix": { "type": "string" },
1334                        "edges": {
1335                            "type": "array",
1336                            "description": "Optional user edges [{edge_type, src, dst}]."
1337                        },
1338                        "namespace": {
1339                            "type": "string",
1340                            "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."
1341                        }
1342                    },
1343                    "required": ["label", "rows_json"]
1344                }
1345            },
1346            {
1347                "name": "create_rule",
1348                "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.",
1349                "inputSchema": {
1350                    "type": "object",
1351                    "properties": {
1352                        "name": { "type": "string" },
1353                        "src_label": { "type": "string" },
1354                        "dst_label": { "type": "string" },
1355                        "predicate": { "type": "object" },
1356                        "edge_type": { "type": "string" },
1357                        "weight_prop": {
1358                            "type": ["string", "null"],
1359                            "description": "Edge property that stores the score (default: weight)."
1360                        },
1361                        "max_edges": { "type": ["integer", "null"] },
1362                        "approximate": {
1363                            "type": "boolean",
1364                            "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."
1365                        },
1366                        "namespace": {
1367                            "type": "string",
1368                            "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."
1369                        }
1370                    },
1371                    "required": ["name", "src_label", "dst_label", "predicate", "edge_type"]
1372                }
1373            },
1374            {
1375                "name": "explain",
1376                "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.",
1377                "inputSchema": {
1378                    "type": "object",
1379                    "properties": {
1380                        "a": { "type": "string", "minLength": 1 },
1381                        "b": { "type": "string", "minLength": 1 }
1382                    },
1383                    "required": ["a", "b"]
1384                }
1385            },
1386            {
1387                "name": "stats",
1388                "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.",
1389                "inputSchema": {
1390                    "type": "object",
1391                    "properties": {
1392                        "role": {
1393                            "type": "string",
1394                            "description": "Report only the namespaces this role may see. The store-wide counts beside them are unchanged."
1395                        },
1396                        "namespace": {
1397                            "type": "string",
1398                            "description": "Report only this namespace. Intersects with 'role'."
1399                        }
1400                    }
1401                }
1402            },
1403            {
1404                "name": "node_info",
1405                "description": "What is K — its label and every property it holds.",
1406                "inputSchema": {
1407                    "type": "object",
1408                    "properties": {
1409                        "key": { "type": "string" }
1410                    },
1411                    "required": ["key"]
1412                }
1413            },
1414            {
1415                "name": "upsert_entity",
1416                "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.",
1417                "inputSchema": {
1418                    "type": "object",
1419                    "properties": {
1420                        "key": { "type": "string", "description": "Unique node key." },
1421                        "label": { "type": "string", "description": "Node label (required when creating a new entity)." },
1422                        "props": {
1423                            "type": "object",
1424                            "description": "Properties to set. Values must be scalars (string, number, bool) or arrays of scalars."
1425                        },
1426                        "namespace": {
1427                            "type": "string",
1428                            "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."
1429                        },
1430                        "aliases": {
1431                            "type": "array",
1432                            "items": { "type": "string" },
1433                            "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."
1434                        }
1435                    },
1436                    "required": ["key", "props"]
1437                }
1438            },
1439            {
1440                "name": "find_similar",
1441                "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.",
1442                "inputSchema": {
1443                    "type": "object",
1444                    "properties": {
1445                        "vector": {
1446                            "type": "array",
1447                            "items": { "type": "number" },
1448                            "description": "Query embedding vector for vector-similarity search. When present, vector-search mode is used and `key` is ignored."
1449                        },
1450                        "field": { "type": "string", "description": "Property field holding the embedding vectors (default: embedding). Used in vector-search mode." },
1451                        "label": { "type": "string", "description": "Restrict search to nodes with this label. Empty string means all labels. Used in vector-search mode." },
1452                        "k": { "type": "integer", "description": "Maximum results to return in vector-search mode (default: 10)." },
1453                        "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." },
1454                        "mask": {
1455                            "type": "array",
1456                            "items": { "type": "string" },
1457                            "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."
1458                        },
1459                        "where": {
1460                            "type": "object",
1461                            "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."
1462                        },
1463                        "exact": {
1464                            "type": "boolean",
1465                            "description": "When true, vector-search mode uses exact GEMM brute force and does not consult HNSW. Default false. Edge-traversal mode ignores this."
1466                        },
1467                        "key": { "type": "string", "description": "Source node key for edge-traversal mode." },
1468                        "edge_type": { "type": "string", "description": "Edge type to filter by in edge-traversal mode (default: SIMILAR)." },
1469                        "limit": { "type": "integer", "description": "Maximum neighbors to return in edge-traversal mode (default: 10)." }
1470                    }
1471                }
1472            },
1473            {
1474                "name": "pairwise_similar",
1475                "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.",
1476                "inputSchema": {
1477                    "type": "object",
1478                    "properties": {
1479                        "keys": {
1480                            "type": "array",
1481                            "items": { "type": "string" },
1482                            "description": "Node keys to score against each other."
1483                        },
1484                        "field": { "type": "string", "description": "Property field holding the embedding vectors." },
1485                        "k": { "type": "integer", "description": "Maximum neighbors per key (default: 10)." },
1486                        "min": { "type": "number", "description": "Minimum cosine similarity threshold (default: 0.0)." }
1487                    },
1488                    "required": ["keys", "field"]
1489                }
1490            },
1491            {
1492                "name": "hybrid_search",
1493                "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.",
1494                "inputSchema": {
1495                    "type": "object",
1496                    "properties": {
1497                        "query_text": { "type": "string", "description": "Fulltext query string." },
1498                        "text_field": { "type": "string", "description": "Property field to search with fulltext." },
1499                        "vector": {
1500                            "type": "array",
1501                            "items": { "type": "number" },
1502                            "description": "Query embedding vector. Omit for text-only ranking."
1503                        },
1504                        "vector_field": { "type": "string", "description": "Property field holding embedding vectors (default: embedding)." },
1505                        "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." },
1506                        "k": { "type": "integer", "description": "Maximum results to return (default: 10)." }
1507                    },
1508                    "required": ["query_text", "text_field"]
1509                }
1510            },
1511            {
1512                "name": "node_history",
1513                "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.",
1514                "inputSchema": {
1515                    "type": "object",
1516                    "properties": {
1517                        "key": { "type": "string", "description": "Node key to look up." }
1518                    },
1519                    "required": ["key"]
1520                }
1521            },
1522            {
1523                "name": "edge_history",
1524                "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.",
1525                "inputSchema": {
1526                    "type": "object",
1527                    "properties": {
1528                        "a": { "type": "string", "minLength": 1, "description": "First node key." },
1529                        "b": { "type": "string", "minLength": 1, "description": "Second node key." }
1530                    },
1531                    "required": ["a", "b"]
1532                }
1533            },
1534            {
1535                "name": "was_linked",
1536                "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`).",
1537                "inputSchema": {
1538                    "type": "object",
1539                    "properties": {
1540                        "a": { "type": "string", "minLength": 1, "description": "First node key." },
1541                        "b": { "type": "string", "minLength": 1, "description": "Second node key." },
1542                        "edge_type": { "type": "string", "minLength": 1, "description": "Edge type to check." },
1543                        "at_commit": {
1544                            "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.",
1545                            "anyOf": [
1546                                { "type": "string", "minLength": 1 },
1547                                { "type": "integer", "minimum": 0 }
1548                            ]
1549                        }
1550                    },
1551                    "required": ["a", "b", "edge_type", "at_commit"]
1552                }
1553            },
1554            {
1555                "name": "rename_node",
1556                "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.",
1557                "inputSchema": {
1558                    "type": "object",
1559                    "properties": {
1560                        "old_key": { "type": "string", "minLength": 1, "description": "Current node key." },
1561                        "new_key": { "type": "string", "minLength": 1, "description": "Desired new node key." }
1562                    },
1563                    "required": ["old_key", "new_key"]
1564                }
1565            }
1566    ]) else {
1567        unreachable!("the literal above is an array")
1568    };
1569    tools
1570}
1571
1572fn tool_ok(payload: Js) -> Js {
1573    json!({
1574        "content": [{ "type": "text", "text": payload.to_string() }]
1575    })
1576}
1577
1578/// A task tool's result: one text block, and no `structuredContent`.
1579///
1580/// The report used to ride along beside the digest, repeating it verbatim
1581/// under a `text` key. Nothing bound it — no task tool declares an
1582/// `outputSchema` — and it tripled the size of every reply, so a caller that
1583/// wants the numbers now asks for them with `json: true` and gets the report
1584/// *as* the text.
1585fn task_ok(text: &str) -> Js {
1586    json!({
1587        "content": [{ "type": "text", "text": text }]
1588    })
1589}
1590
1591fn tool_err(message: &str) -> Js {
1592    json!({
1593        "content": [{ "type": "text", "text": message }],
1594        "isError": true
1595    })
1596}
1597
1598fn write_result(writer: &mut impl Write, id: Option<Js>, result: Js) -> io::Result<()> {
1599    write_json(
1600        writer,
1601        &json!({
1602            "jsonrpc": "2.0",
1603            "id": id.unwrap_or(Js::Null),
1604            "result": result
1605        }),
1606    )
1607}
1608
1609fn write_error(
1610    writer: &mut impl Write,
1611    id: Option<Js>,
1612    code: i64,
1613    message: &str,
1614) -> io::Result<()> {
1615    write_json(
1616        writer,
1617        &json!({
1618            "jsonrpc": "2.0",
1619            "id": id.unwrap_or(Js::Null),
1620            "error": { "code": code, "message": message }
1621        }),
1622    )
1623}
1624
1625fn write_json(writer: &mut impl Write, value: &Js) -> io::Result<()> {
1626    let s = serde_json::to_string(value).map_err(io::Error::other)?;
1627    writeln!(writer, "{s}")?;
1628    writer.flush()
1629}
1630
1631// ---------------------------------------------------------------------------
1632// Tests: MCP tool round-trips via stdio
1633// ---------------------------------------------------------------------------
1634
1635#[cfg(test)]
1636mod tests {
1637    use super::*;
1638    use mushroomdb::{AutoFk, IngestOptions, Predicate, RuleDef, Value};
1639    use std::path::PathBuf;
1640    use std::sync::atomic::{AtomicU64, Ordering};
1641
1642    fn tmp_dir() -> PathBuf {
1643        static SEQ: AtomicU64 = AtomicU64::new(0);
1644        let n = SEQ.fetch_add(1, Ordering::Relaxed);
1645        let d = std::env::temp_dir().join(format!("mcp-test-{}-{}", std::process::id(), n));
1646        // These stores are never cleaned up, so a process id the OS hands out
1647        // again lands on a previous run's data and every assertion about counts
1648        // fails. `tests/mcp.rs::tmp` already clears its path for this reason.
1649        let _ = std::fs::remove_dir_all(&d);
1650        d
1651    }
1652
1653    /// Open a SharedDb with two Person nodes and one derived SIMILAR edge.
1654    fn demo_db() -> SharedDb {
1655        let db = SharedDb::open(&tmp_dir()).expect("open");
1656        {
1657            let mut g = db.write();
1658            let opts = IngestOptions {
1659                key_field: "id".into(),
1660                auto_fk: AutoFk::Off,
1661            };
1662            // Two people with identical embeddings → will fire SIMILAR rule.
1663            let people: Vec<BTreeMap<String, Value>> = vec![
1664                [
1665                    ("id", Value::Str("alice".into())),
1666                    ("name", Value::Str("Alice".into())),
1667                    (
1668                        "emb",
1669                        Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1670                    ),
1671                ]
1672                .into_iter()
1673                .map(|(k, v)| (k.to_string(), v))
1674                .collect(),
1675                [
1676                    ("id", Value::Str("bob".into())),
1677                    ("name", Value::Str("Bob".into())),
1678                    (
1679                        "emb",
1680                        Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
1681                    ),
1682                ]
1683                .into_iter()
1684                .map(|(k, v)| (k.to_string(), v))
1685                .collect(),
1686            ];
1687            g.ingest("Person", people, &opts).expect("ingest");
1688
1689            // Rule: VectorSimilar on emb → SIMILAR edge (cosine(ident,ident)=1.0 ≥ 0.9).
1690            g.create_rule(RuleDef {
1691                name: "sim_emb".into(),
1692                src_label: "Person".into(),
1693                dst_label: "Person".into(),
1694                predicate: Predicate::VectorSimilar {
1695                    field: "emb".into(),
1696                    min: 0.9,
1697                },
1698                edge_type: "SIMILAR".into(),
1699                weight_prop: Some("score".into()),
1700                max_edges: None,
1701                approximate: false,
1702                via_label: None,
1703                via_edge: None,
1704                via_dir: None,
1705                namespace: None,
1706            })
1707            .expect("rule");
1708        }
1709        db
1710    }
1711
1712    fn roundtrip(db: &SharedDb, request: &str) -> Js {
1713        roundtrip_with(db, false, request)
1714    }
1715
1716    fn roundtrip_with(db: &SharedDb, all_tools: bool, request: &str) -> Js {
1717        let input = format!("{request}\n");
1718        let mut output = Vec::new();
1719        run_mcp_stdio_with(db.clone(), None, all_tools, input.as_bytes(), &mut output)
1720            .expect("mcp");
1721        let s = std::str::from_utf8(&output).expect("utf8");
1722        serde_json::from_str(s.trim()).expect("json response")
1723    }
1724
1725    fn tool_call(db: &SharedDb, id: u64, tool: &str, args: Js) -> Js {
1726        let req = json!({
1727            "jsonrpc": "2.0",
1728            "id": id,
1729            "method": "tools/call",
1730            "params": { "name": tool, "arguments": args }
1731        });
1732        roundtrip(db, &req.to_string())
1733    }
1734
1735    /// Unwrap the `text` field from a successful tool response.
1736    fn tool_text(resp: &Js) -> Js {
1737        let text = resp["result"]["content"][0]["text"]
1738            .as_str()
1739            .expect("content[0].text");
1740        serde_json::from_str(text).expect("tool text is json")
1741    }
1742
1743    fn is_error(resp: &Js) -> bool {
1744        resp["result"]["isError"].as_bool().unwrap_or(false)
1745    }
1746
1747    fn tool_err_text(resp: &Js) -> String {
1748        resp["result"]["content"][0]["text"]
1749            .as_str()
1750            .unwrap_or("")
1751            .to_string()
1752    }
1753
1754    // --- existing tools ---
1755
1756    #[test]
1757    fn test_tools_list_includes_all_expected() {
1758        let db = demo_db();
1759        let resp = roundtrip_with(
1760            &db,
1761            true,
1762            r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#,
1763        );
1764        let tools = resp["result"]["tools"].as_array().expect("tools array");
1765        let names: Vec<&str> = tools
1766            .iter()
1767            .map(|t| t["name"].as_str().expect("name"))
1768            .collect();
1769        for expected in &[
1770            // The task tools, first and in order.
1771            "explain_association",
1772            "node_edges",
1773            "neighborhood",
1774            "edges_at",
1775            "what_if",
1776            "recall",
1777            "remember",
1778            "schema",
1779            "analyze",
1780            "suggest_rules",
1781            "forget",
1782            // The fourteen graph tools.
1783            "query",
1784            "ingest_json",
1785            "create_rule",
1786            "explain",
1787            "stats",
1788            "node_info",
1789            "upsert_entity",
1790            "find_similar",
1791            "pairwise_similar",
1792            "hybrid_search",
1793            "node_history",
1794            "edge_history",
1795            "was_linked",
1796            "rename_node",
1797        ] {
1798            assert!(names.contains(expected), "missing tool: {expected}");
1799        }
1800        assert_eq!(
1801            names.len(),
1802            25,
1803            "expected exactly 25 tools, got {}",
1804            names.len()
1805        );
1806        assert_eq!(
1807            &names[..11],
1808            [
1809                "explain_association",
1810                "node_edges",
1811                "neighborhood",
1812                "edges_at",
1813                "what_if",
1814                "recall",
1815                "remember",
1816                "schema",
1817                "analyze",
1818                "suggest_rules",
1819                "forget",
1820            ],
1821            "the task tools come first, in order"
1822        );
1823        assert_eq!(names[11], "query", "the graph tools follow them");
1824    }
1825
1826    /// Binding: the default listing is the association tools, in [`ASSOCIATION_TOOLS`]
1827    /// order, and nothing else.
1828    #[test]
1829    fn tools_list_defaults_to_the_association_tools_on_a_memory_store() {
1830        let db = demo_db();
1831        let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1832        let names: Vec<&str> = resp["result"]["tools"]
1833            .as_array()
1834            .expect("tools array")
1835            .iter()
1836            .map(|t| t["name"].as_str().expect("name"))
1837            .collect();
1838        assert_eq!(names, ASSOCIATION_TOOLS.to_vec());
1839    }
1840
1841    /// Binding: every argument `suggest_rules` hands back for `create_rule`
1842    /// is one `create_rule`'s advertised schema lists. A vector proposal over
1843    /// a large label carries `approximate: true`, which the tool accepts.
1844    #[test]
1845    fn create_rule_advertises_every_argument_a_suggestion_carries() {
1846        let def = RuleDef {
1847            name: "sim".into(),
1848            src_label: "Person".into(),
1849            dst_label: "Person".into(),
1850            predicate: Predicate::VectorSimilar {
1851                field: "emb".into(),
1852                min: 0.9,
1853            },
1854            edge_type: "SIMILAR".into(),
1855            weight_prop: Some("score".into()),
1856            max_edges: Some(8),
1857            approximate: true,
1858            via_label: None,
1859            via_edge: None,
1860            via_dir: None,
1861            namespace: None,
1862        };
1863        let args = mushroomdb::memory::suggest::create_rule_args(&def);
1864        assert_eq!(args["approximate"], json!(true), "{args}");
1865
1866        let tools = graph_tools();
1867        let schema = tools
1868            .iter()
1869            .find(|t| t["name"] == json!("create_rule"))
1870            .expect("create_rule is a graph tool");
1871        let advertised = schema["inputSchema"]["properties"]
1872            .as_object()
1873            .expect("properties");
1874        for arg in args.as_object().expect("an object").keys() {
1875            assert!(
1876                advertised.contains_key(arg),
1877                "create_rule's schema does not list {arg:?}, which a suggestion carries"
1878            );
1879        }
1880        assert_eq!(advertised["approximate"]["type"], json!("boolean"));
1881    }
1882
1883    /// Binding: `pairwise_similar` is advertised on the memory surface
1884    /// immediately after `find_similar`, and the listing is exactly `EXPECTED`.
1885    #[test]
1886    fn association_listing_includes_pairwise_similar_after_find_similar() {
1887        let db = demo_db();
1888        let resp = roundtrip(&db, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#);
1889        let names: Vec<&str> = resp["result"]["tools"]
1890            .as_array()
1891            .expect("tools array")
1892            .iter()
1893            .map(|t| t["name"].as_str().expect("name"))
1894            .collect();
1895        const EXPECTED: [&str; 23] = [
1896            "query",
1897            "explain_association",
1898            "neighborhood",
1899            "node_info",
1900            "node_edges",
1901            "was_linked",
1902            "edges_at",
1903            "what_if",
1904            "node_history",
1905            "edge_history",
1906            "find_similar",
1907            "pairwise_similar",
1908            "hybrid_search",
1909            "remember",
1910            "recall",
1911            "upsert_entity",
1912            "ingest_json",
1913            "create_rule",
1914            "stats",
1915            "schema",
1916            "analyze",
1917            "suggest_rules",
1918            "forget",
1919        ];
1920        assert_eq!(names, EXPECTED.to_vec());
1921        assert_eq!(ASSOCIATION_TOOLS.as_slice(), EXPECTED.as_slice());
1922        let find = names
1923            .iter()
1924            .position(|&n| n == "find_similar")
1925            .expect("find_similar listed");
1926        assert_eq!(names[find + 1], "pairwise_similar");
1927    }
1928
1929    /// Binding: [`ASSOCIATION_TOOLS`] keeps the task tools an entity store can
1930    /// answer with — the notes it wrote and the notes it kept — and every name
1931    /// in it is served.
1932    #[test]
1933    fn the_association_surface_is_entity_tools_only() {
1934        for kept in ["remember", "recall", "explain_association"] {
1935            assert!(
1936                ASSOCIATION_TOOLS.contains(&kept),
1937                "{kept} answers on an entity graph and must be listed"
1938            );
1939        }
1940        let served: Vec<String> = crate::mcp_tasks::task_tools()
1941            .iter()
1942            .chain(graph_tools().iter())
1943            .filter_map(|t| t.get("name").and_then(Js::as_str))
1944            .map(str::to_string)
1945            .collect();
1946        for name in ASSOCIATION_TOOLS {
1947            assert!(
1948                served.iter().any(|s| s == name),
1949                "{name} is listed but not served"
1950            );
1951        }
1952    }
1953
1954    #[test]
1955    fn test_stats_returns_node_count() {
1956        let db = demo_db();
1957        let resp = tool_call(&db, 1, "stats", json!({}));
1958        assert!(!is_error(&resp));
1959        let result = tool_text(&resp);
1960        assert_eq!(result["nodes_live"], 2);
1961    }
1962
1963    /// Unscoped `stats` must not enumerate the store's namespaces. Asking with
1964    /// neither `role` nor `namespace` gets the store-wide counts with the
1965    /// roster key absent entirely — not an empty array, which would still
1966    /// confirm the roster exists and invite a guess at its size.
1967    #[test]
1968    fn mcp_stats_unscoped_omits_namespace_roster() {
1969        let db = SharedDb::open(&tmp_dir()).expect("open");
1970        {
1971            let mut g = db.write();
1972            g.insert_node(
1973                "Doc",
1974                "a",
1975                vec![("ns".into(), Value::Str("tenant-a".into()))],
1976            )
1977            .expect("insert a");
1978            g.insert_node(
1979                "Doc",
1980                "b",
1981                vec![("ns".into(), Value::Str("tenant-b".into()))],
1982            )
1983            .expect("insert b");
1984        }
1985
1986        let unscoped = tool_text(&tool_call(&db, 1, "stats", json!({})));
1987        assert!(
1988            unscoped.get("namespaces").is_none(),
1989            "unscoped stats must omit the roster entirely, not send an empty \
1990             array: {unscoped}"
1991        );
1992        assert_eq!(
1993            unscoped["nodes_live"], 2,
1994            "the store-wide counts beside the roster are unchanged"
1995        );
1996
1997        let scoped = tool_text(&tool_call(
1998            &db,
1999            2,
2000            "stats",
2001            json!({"namespace": "tenant-a"}),
2002        ));
2003        let names: Vec<&str> = scoped["namespaces"]
2004            .as_array()
2005            .expect("a scoped call still carries the roster it may see")
2006            .iter()
2007            .map(|n| n["name"].as_str().expect("name"))
2008            .collect();
2009        assert_eq!(
2010            names,
2011            ["tenant-a"],
2012            "a call that names a namespace sees that one and no other"
2013        );
2014    }
2015
2016    /// A rule whose corpus is too large to index in one commit must not come
2017    /// back as a bare "ok": the caller would go straight to querying edges that
2018    /// do not exist yet.
2019    #[test]
2020    fn create_rule_reports_a_build_it_could_not_finish() {
2021        let db = SharedDb::open(&tmp_dir()).expect("open");
2022        {
2023            let mut g = db.write();
2024            for i in 0..300usize {
2025                const D: usize = 32;
2026                let axis = (i / 10) % D;
2027                let mut xs = vec![0.0f64; D];
2028                xs[axis] = 1.0;
2029                xs[(axis + 1) % D] = (i % 10) as f64 * 0.001;
2030                g.insert_node(
2031                    "V",
2032                    &format!("v{i}"),
2033                    vec![(
2034                        "emb".into(),
2035                        Value::List(xs.into_iter().map(Value::Float).collect()),
2036                    )],
2037                )
2038                .expect("insert");
2039            }
2040            g.set_hnsw_build_batch(Some(64));
2041        }
2042        let args = json!({
2043            "name": "sim",
2044            "src_label": "V",
2045            "dst_label": "V",
2046            "predicate": {"VectorSimilar": {"field": "emb", "min": 0.9}},
2047            "edge_type": "SIM",
2048            "weight_prop": null,
2049            "max_edges": null,
2050            "approximate": true
2051        });
2052        let resp = tool_call(&db, 1, "create_rule", args);
2053        assert!(!is_error(&resp), "{resp}");
2054        let result = tool_text(&resp);
2055        assert_eq!(result["name"], json!("sim"));
2056        assert_eq!(result["building"], json!({"indexed": 64, "total": 300}));
2057        let note = result["note"].as_str().expect("a note explaining the wait");
2058        assert!(
2059            note.contains("derives no edges until it finishes") && note.contains("build-index"),
2060            "the note must say the edges are not there yet and how to finish: {note}"
2061        );
2062
2063        // `stats` carries the same progress.
2064        let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2065        let rule = stats["rules"]
2066            .as_array()
2067            .expect("rules")
2068            .iter()
2069            .find(|r| r["name"] == "sim")
2070            .expect("the rule is installed while it builds");
2071        assert_eq!(rule["edges"], json!(0));
2072        assert_eq!(
2073            rule["building"],
2074            json!({"rule": "sim", "indexed": 64, "total": 300})
2075        );
2076
2077        // Finished, the report is the plain one again.
2078        while !db.write().pump_index_build().expect("pump").is_empty() {}
2079        let stats = tool_text(&tool_call(&db, 3, "stats", json!({})));
2080        let rule = stats["rules"]
2081            .as_array()
2082            .expect("rules")
2083            .iter()
2084            .find(|r| r["name"] == "sim")
2085            .expect("rule");
2086        assert!(rule.get("building").is_none(), "{rule}");
2087        assert!(rule["edges"].as_u64().expect("edges") > 0);
2088    }
2089
2090    #[test]
2091    fn test_query_runs_cypher() {
2092        let db = demo_db();
2093        let resp = tool_call(
2094            &db,
2095            1,
2096            "query",
2097            json!({ "cypher": "MATCH (n:Person) RETURN n.name ORDER BY n.name" }),
2098        );
2099        assert!(!is_error(&resp));
2100        let result = tool_text(&resp);
2101        // columns + 2 rows
2102        assert_eq!(result["columns"], json!(["n.name"]));
2103        assert_eq!(result["rows"].as_array().map(|r| r.len()), Some(2));
2104    }
2105
2106    #[test]
2107    fn test_query_create_is_a_write() {
2108        let db = SharedDb::open(&tmp_dir()).expect("open");
2109        let resp = tool_call(
2110            &db,
2111            1,
2112            "query",
2113            json!({ "cypher": "CREATE (n:L {id: 'k'}) RETURN n" }),
2114        );
2115        assert!(
2116            !is_error(&resp),
2117            "CREATE via MCP query must succeed: {resp}"
2118        );
2119        let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2120        assert_eq!(stats["nodes_live"], 1);
2121    }
2122
2123    #[test]
2124    fn test_ingest_json_inserts_nodes() {
2125        let db = demo_db();
2126        let resp = tool_call(
2127            &db,
2128            1,
2129            "ingest_json",
2130            json!({
2131                "label": "Person",
2132                "rows_json": r#"[{"id":"carol","name":"Carol"}]"#,
2133                "key_field": "id"
2134            }),
2135        );
2136        assert!(!is_error(&resp));
2137        // Verify node visible via stats
2138        let stats = tool_text(&tool_call(&db, 2, "stats", json!({})));
2139        assert_eq!(stats["nodes_live"], 3);
2140    }
2141
2142    #[test]
2143    fn test_node_info_returns_props() {
2144        let db = demo_db();
2145        let resp = tool_call(&db, 1, "node_info", json!({ "key": "alice" }));
2146        assert!(!is_error(&resp));
2147        let result = tool_text(&resp);
2148        assert_eq!(result["key"], "alice");
2149        assert_eq!(result["label"], "Person");
2150        assert_eq!(result["props"]["name"], "Alice");
2151    }
2152
2153    /// Binding: `node_edges` groups by edge type and names the rule and score
2154    /// behind each derived edge, in the report as in the digest.
2155    #[test]
2156    fn test_node_edges_returns_edges() {
2157        let db = demo_db();
2158        let resp = tool_call(
2159            &db,
2160            1,
2161            "node_edges",
2162            json!({ "key": "alice", "json": true }),
2163        );
2164        assert!(!is_error(&resp));
2165        let result = tool_text(&resp);
2166        assert_eq!(result["key"], "alice");
2167        let types = result["types"].as_array().expect("types");
2168        assert!(
2169            !types.is_empty(),
2170            "alice should have at least one edge type"
2171        );
2172        let similar = types
2173            .iter()
2174            .find(|t| t["edge_type"] == "SIMILAR")
2175            .expect("the rule's edge type");
2176        // A symmetric rule derives the edge both ways, and both are listed
2177        // with the direction that tells them apart.
2178        assert_eq!(similar["count"], json!(2));
2179        let edges = similar["edges"].as_array().expect("edges");
2180        let dirs: Vec<&str> = edges
2181            .iter()
2182            .map(|e| e["direction"].as_str().expect("direction"))
2183            .collect();
2184        assert!(dirs.contains(&"out") && dirs.contains(&"in"), "{similar}");
2185        for edge in edges {
2186            assert_eq!(edge["other"], json!("bob"));
2187            assert_eq!(edge["derived"], json!(true));
2188            assert_eq!(edge["rule"], json!("sim_emb"));
2189            assert_eq!(edge["score"], json!(1.0));
2190            assert!(
2191                edge["predicate"]
2192                    .as_str()
2193                    .unwrap_or("")
2194                    .contains("vector_similar"),
2195                "the predicate travels with the edge: {edge}"
2196            );
2197        }
2198    }
2199
2200    /// Binding: a depth-1 `neighborhood` is the same relationship listing, and
2201    /// anything deeper is still the traversal table.
2202    #[test]
2203    fn test_neighborhood_traverses_one_hop() {
2204        let db = demo_db();
2205        let resp = tool_call(
2206            &db,
2207            1,
2208            "neighborhood",
2209            json!({ "key": "alice", "depth": 1, "json": true }),
2210        );
2211        assert!(!is_error(&resp));
2212        let result = tool_text(&resp);
2213        assert_eq!(result["key"], "alice");
2214        assert!(result["types"].as_array().is_some(), "{result}");
2215
2216        let deep = tool_call(
2217            &db,
2218            2,
2219            "neighborhood",
2220            json!({ "key": "alice", "depth": 2 }),
2221        );
2222        assert!(!is_error(&deep));
2223        let table = tool_text(&deep);
2224        assert_eq!(table["columns"], json!(["key", "label", "depth"]));
2225        assert!(table["rows"].as_array().is_some());
2226    }
2227
2228    #[test]
2229    fn test_explain_returns_rule_info() {
2230        let db = demo_db();
2231        let resp = tool_call(&db, 1, "explain", json!({ "a": "alice", "b": "bob" }));
2232        assert!(!is_error(&resp));
2233        let result = tool_text(&resp);
2234        let arr = result.as_array().expect("explain returns array");
2235        assert!(!arr.is_empty(), "expected at least one explanation");
2236        assert_eq!(arr[0]["rule"], "sim_emb");
2237    }
2238
2239    #[test]
2240    fn test_create_rule_backfills() {
2241        let db = SharedDb::open(&tmp_dir()).expect("open");
2242        {
2243            let mut g = db.write();
2244            let opts = IngestOptions {
2245                key_field: "id".into(),
2246                auto_fk: AutoFk::Off,
2247            };
2248            let rows: Vec<BTreeMap<String, Value>> = vec![
2249                [
2250                    ("id", Value::Str("x".into())),
2251                    ("tag", Value::Str("a".into())),
2252                ]
2253                .into_iter()
2254                .map(|(k, v)| (k.to_string(), v))
2255                .collect(),
2256                [
2257                    ("id", Value::Str("y".into())),
2258                    ("tag", Value::Str("a".into())),
2259                ]
2260                .into_iter()
2261                .map(|(k, v)| (k.to_string(), v))
2262                .collect(),
2263            ];
2264            g.ingest("Item", rows, &opts).expect("ingest");
2265        }
2266        let resp = tool_call(
2267            &db,
2268            1,
2269            "create_rule",
2270            json!({
2271                "name": "same_tag",
2272                "src_label": "Item",
2273                "dst_label": "Item",
2274                "predicate": { "FieldEqual": { "field": "tag" } },
2275                "edge_type": "SAME_TAG"
2276            }),
2277        );
2278        assert!(!is_error(&resp));
2279        let result = tool_text(&resp);
2280        assert_eq!(result["ok"], true);
2281        // Derived edges should now exist.
2282        let edges_resp = tool_call(&db, 2, "node_edges", json!({ "key": "x", "json": true }));
2283        let edges_result = tool_text(&edges_resp);
2284        let types = edges_result["types"].as_array().expect("types");
2285        assert!(
2286            types.iter().any(|t| t["edge_type"] == "SAME_TAG"),
2287            "SAME_TAG edge not found after create_rule"
2288        );
2289    }
2290
2291    // --- new tools ---
2292
2293    #[test]
2294    fn test_upsert_entity_creates_new_node() {
2295        let db = demo_db();
2296        let resp = tool_call(
2297            &db,
2298            1,
2299            "upsert_entity",
2300            json!({
2301                "key": "carol",
2302                "label": "Person",
2303                "props": { "name": "Carol", "age": 30 }
2304            }),
2305        );
2306        assert!(!is_error(&resp));
2307        let result = tool_text(&resp);
2308        assert_eq!(result["ok"], true);
2309        assert_eq!(result["created"], true);
2310        assert_eq!(result["key"], "carol");
2311        // Verify node exists
2312        let info = tool_text(&tool_call(&db, 2, "node_info", json!({ "key": "carol" })));
2313        assert_eq!(info["props"]["name"], "Carol");
2314    }
2315
2316    #[test]
2317    fn test_upsert_entity_updates_existing_node() {
2318        let db = demo_db();
2319        let resp = tool_call(
2320            &db,
2321            1,
2322            "upsert_entity",
2323            json!({
2324                "key": "alice",
2325                "props": { "name": "Alice Updated" }
2326            }),
2327        );
2328        assert!(!is_error(&resp));
2329        let result = tool_text(&resp);
2330        assert_eq!(result["ok"], true);
2331        assert_eq!(result["created"], false);
2332        assert_eq!(result["updated_fields"], 1);
2333        // Verify prop changed
2334        let info = tool_text(&tool_call(&db, 2, "node_info", json!({ "key": "alice" })));
2335        assert_eq!(info["props"]["name"], "Alice Updated");
2336    }
2337
2338    #[test]
2339    fn test_upsert_entity_missing_label_on_create_is_error() {
2340        let db = demo_db();
2341        let resp = tool_call(
2342            &db,
2343            1,
2344            "upsert_entity",
2345            json!({ "key": "new-node", "props": { "x": 1 } }),
2346        );
2347        assert!(is_error(&resp), "should error without label for new node");
2348    }
2349
2350    #[test]
2351    fn test_pairwise_similar_excludes_self() {
2352        let db = demo_db();
2353        let resp = tool_call(
2354            &db,
2355            1,
2356            "pairwise_similar",
2357            json!({
2358                "keys": ["alice", "bob"],
2359                "field": "emb",
2360                "k": 10,
2361                "min": 0.0
2362            }),
2363        );
2364        assert!(
2365            !is_error(&resp),
2366            "pairwise_similar must not error: {resp:?}"
2367        );
2368        let result = tool_text(&resp);
2369        let results = result["results"].as_array().expect("results");
2370        assert_eq!(results.len(), 2);
2371        for row in results {
2372            let key = row["key"].as_str().expect("key");
2373            let neighbors = row["neighbors"].as_array().expect("neighbors");
2374            assert!(
2375                neighbors.iter().all(|n| n["key"].as_str() != Some(key)),
2376                "self must be excluded: {row}"
2377            );
2378            assert!(!neighbors.is_empty(), "alice/bob are identical: {row}");
2379        }
2380    }
2381
2382    #[test]
2383    fn test_find_similar_returns_similar_edges() {
2384        let db = demo_db();
2385        let resp = tool_call(
2386            &db,
2387            1,
2388            "find_similar",
2389            json!({ "key": "alice", "edge_type": "SIMILAR" }),
2390        );
2391        assert!(!is_error(&resp));
2392        let result = tool_text(&resp);
2393        assert_eq!(result["key"], "alice");
2394        assert_eq!(result["edge_type"], "SIMILAR");
2395        let similar = result["similar"].as_array().expect("similar array");
2396        assert!(!similar.is_empty(), "expected SIMILAR neighbors for alice");
2397        assert_eq!(similar[0]["neighbor_key"], "bob");
2398    }
2399
2400    #[test]
2401    fn test_find_similar_limit_respected() {
2402        let db = demo_db();
2403        let resp = tool_call(
2404            &db,
2405            1,
2406            "find_similar",
2407            json!({ "key": "alice", "edge_type": "SIMILAR", "limit": 0 }),
2408        );
2409        assert!(!is_error(&resp));
2410        let result = tool_text(&resp);
2411        let similar = result["similar"].as_array().expect("similar array");
2412        assert_eq!(similar.len(), 0);
2413    }
2414
2415    /// When `min` is omitted from a vector-mode find_similar call, the server
2416    /// must apply the spec default of 0.8.  A node whose cosine similarity to
2417    /// the query is 0.0 (orthogonal) must not appear in the results.
2418    #[test]
2419    fn test_find_similar_vector_default_min_is_0_8() {
2420        let db = SharedDb::open(&tmp_dir()).expect("open");
2421        {
2422            let mut g = db.write();
2423            // close: [1,0] → cosine 1.0 with query [1,0] (above 0.8)
2424            g.insert_node(
2425                "Item",
2426                "close",
2427                vec![(
2428                    "emb".into(),
2429                    Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2430                )],
2431            )
2432            .unwrap();
2433            // far: [0,1] → cosine 0.0 with query [1,0] (below 0.8, must be excluded)
2434            g.insert_node(
2435                "Item",
2436                "far",
2437                vec![(
2438                    "emb".into(),
2439                    Value::List(vec![Value::Float(0.0), Value::Float(1.0)]),
2440                )],
2441            )
2442            .unwrap();
2443            // mid: [0.6,0.8] → cosine 0.6 with query [1,0]: above the old HTTP
2444            // and Python default (0.0), below this one (0.8).
2445            g.insert_node(
2446                "Item",
2447                "mid",
2448                vec![(
2449                    "emb".into(),
2450                    Value::List(vec![Value::Float(0.6), Value::Float(0.8)]),
2451                )],
2452            )
2453            .unwrap();
2454        }
2455
2456        // No `min` in the request — must default to 0.8.
2457        let resp = tool_call(
2458            &db,
2459            1,
2460            "find_similar",
2461            json!({
2462                "vector": [1.0, 0.0],
2463                "field": "emb",
2464                "label": "Item",
2465                "k": 10
2466            }),
2467        );
2468        assert!(!is_error(&resp), "vector search must not error");
2469        let result = tool_text(&resp);
2470        let results = result["results"].as_array().expect("results array");
2471
2472        let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2473        assert_eq!(
2474            keys,
2475            vec!["close"],
2476            "only the hit at or above the default survives: mid (0.6) and far (0.0) do not"
2477        );
2478        assert_eq!(
2479            result["min"],
2480            json!(mushroomdb::FIND_SIMILAR_DEFAULT_MIN),
2481            "the reply echoes the floor it applied"
2482        );
2483
2484        // Naming it still reaches everything, in score order: the argument is
2485        // read, not replaced by the default.
2486        let resp = tool_call(
2487            &db,
2488            2,
2489            "find_similar",
2490            json!({
2491                "vector": [1.0, 0.0],
2492                "field": "emb",
2493                "label": "Item",
2494                "k": 10,
2495                "min": 0.0
2496            }),
2497        );
2498        assert!(!is_error(&resp), "vector search must not error");
2499        let result = tool_text(&resp);
2500        let results = result["results"].as_array().expect("results array");
2501        let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2502        assert_eq!(
2503            keys,
2504            vec!["close", "mid", "far"],
2505            "an explicit min of 0.0 keeps the hits the default drops"
2506        );
2507        assert_eq!(
2508            result["min"],
2509            json!(0.0),
2510            "the reply echoes the named floor"
2511        );
2512    }
2513
2514    /// `find_similar` with `mask` must exclude hidden node keys from results.
2515    #[test]
2516    fn test_find_similar_vector_mask_excludes_hidden() {
2517        let db = SharedDb::open(&tmp_dir()).expect("open");
2518        {
2519            let mut g = db.write();
2520            // visible: [1,0] — should appear in results.
2521            g.insert_node(
2522                "Item",
2523                "visible",
2524                vec![(
2525                    "emb".into(),
2526                    Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2527                )],
2528            )
2529            .unwrap();
2530            // hidden: [1,0] — same direction as query but must not appear.
2531            g.insert_node(
2532                "Item",
2533                "hidden",
2534                vec![(
2535                    "emb".into(),
2536                    Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2537                )],
2538            )
2539            .unwrap();
2540        }
2541
2542        let resp = tool_call(
2543            &db,
2544            1,
2545            "find_similar",
2546            json!({
2547                "vector": [1.0, 0.0],
2548                "field": "emb",
2549                "label": "Item",
2550                "k": 10,
2551                "min": 0.0,
2552                "mask": ["visible"]
2553            }),
2554        );
2555        assert!(!is_error(&resp), "masked vector search must not error");
2556        let result = tool_text(&resp);
2557        let results = result["results"].as_array().expect("results array");
2558
2559        let keys: Vec<&str> = results.iter().filter_map(|r| r["key"].as_str()).collect();
2560        assert!(
2561            keys.contains(&"visible"),
2562            "visible node must appear in masked results"
2563        );
2564        assert!(
2565            !keys.contains(&"hidden"),
2566            "hidden node must be excluded by mask"
2567        );
2568    }
2569
2570    /// `find_similar` with `mask` — bad mask value returns a tool error.
2571    #[test]
2572    fn test_find_similar_vector_mask_bad_type_is_error() {
2573        let db = SharedDb::open(&tmp_dir()).expect("open");
2574        let resp = tool_call(
2575            &db,
2576            1,
2577            "find_similar",
2578            json!({
2579                "vector": [1.0, 0.0],
2580                "field": "emb",
2581                "k": 5,
2582                "mask": [42]
2583            }),
2584        );
2585        assert!(
2586            is_error(&resp),
2587            "non-string mask element must produce a tool error"
2588        );
2589    }
2590
2591    /// A `min`, `k` or `limit` that is present and not the number it should be
2592    /// is a tool error, as `min` and `k` are over HTTP — never the default
2593    /// applied in silence. Absent or null still takes the default.
2594    #[test]
2595    fn test_find_similar_non_numeric_min_k_or_limit_is_error() {
2596        let db = demo_db();
2597        for (args, want) in [
2598            (
2599                json!({ "vector": [1.0, 0.0], "field": "emb", "min": "0.5" }),
2600                "min must be a number",
2601            ),
2602            (
2603                json!({ "vector": [1.0, 0.0], "field": "emb", "k": "3" }),
2604                "k must be a non-negative integer",
2605            ),
2606            (
2607                json!({ "vector": [1.0, 0.0], "field": "emb", "k": -1 }),
2608                "k must be a non-negative integer",
2609            ),
2610            (
2611                json!({ "key": "alice", "limit": "1" }),
2612                "limit must be a non-negative integer",
2613            ),
2614        ] {
2615            let resp = tool_call(&db, 1, "find_similar", args.clone());
2616            assert!(is_error(&resp), "{args} must be a tool error, got {resp}");
2617            assert_eq!(tool_err_text(&resp), want, "for {args}");
2618        }
2619
2620        // Null is absence: the defaults apply and the reply echoes them.
2621        let resp = tool_call(
2622            &db,
2623            2,
2624            "find_similar",
2625            json!({ "vector": [1.0, 0.0], "field": "emb", "k": null, "min": null }),
2626        );
2627        assert!(!is_error(&resp), "null takes the default: {resp}");
2628        let result = tool_text(&resp);
2629        assert_eq!(result["k"], json!(10));
2630        assert_eq!(result["min"], json!(mushroomdb::FIND_SIMILAR_DEFAULT_MIN));
2631        let resp = tool_call(
2632            &db,
2633            3,
2634            "find_similar",
2635            json!({ "key": "alice", "limit": null }),
2636        );
2637        assert!(!is_error(&resp), "a null limit takes the default: {resp}");
2638    }
2639
2640    /// Vector-mode `where` eq filters to matching nodes. Default `min` stays 0.8.
2641    #[test]
2642    fn test_find_similar_vector_where_eq() {
2643        let db = SharedDb::open(&tmp_dir()).expect("open");
2644        {
2645            let mut g = db.write();
2646            g.insert_node(
2647                "Document",
2648                "in-scope",
2649                vec![
2650                    (
2651                        "emb".into(),
2652                        Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2653                    ),
2654                    ("resource_scope_id".into(), Value::Str("a".into())),
2655                ],
2656            )
2657            .unwrap();
2658            g.insert_node(
2659                "Document",
2660                "out-scope",
2661                vec![
2662                    (
2663                        "emb".into(),
2664                        Value::List(vec![Value::Float(1.0), Value::Float(0.0)]),
2665                    ),
2666                    ("resource_scope_id".into(), Value::Str("b".into())),
2667                ],
2668            )
2669            .unwrap();
2670        }
2671        let resp = tool_call(
2672            &db,
2673            1,
2674            "find_similar",
2675            json!({
2676                "vector": [1.0, 0.0],
2677                "field": "emb",
2678                "label": "Document",
2679                "k": 10,
2680                "min": 0.0,
2681                "where": { "field": "resource_scope_id", "eq": "a" }
2682            }),
2683        );
2684        assert!(!is_error(&resp), "where eq must not error: {resp:?}");
2685        let result = tool_text(&resp);
2686        let keys: Vec<&str> = result["results"]
2687            .as_array()
2688            .expect("results")
2689            .iter()
2690            .filter_map(|r| r["key"].as_str())
2691            .collect();
2692        assert_eq!(keys, vec!["in-scope"]);
2693    }
2694
2695    #[test]
2696    fn test_find_similar_vector_where_invalid_is_error() {
2697        let db = SharedDb::open(&tmp_dir()).expect("open");
2698        let resp = tool_call(
2699            &db,
2700            1,
2701            "find_similar",
2702            json!({
2703                "vector": [1.0, 0.0],
2704                "field": "emb",
2705                "where": { "field": "resource_scope_id", "eq": "a", "in": ["b"] }
2706            }),
2707        );
2708        assert!(is_error(&resp), "invalid where must be a tool error");
2709        let msg = format!("{resp:?}");
2710        assert!(
2711            msg.contains("where"),
2712            "tool error must name where, got {msg}"
2713        );
2714    }
2715
2716    /// Edge-traversal mode ignores `where` and `exact`.
2717    #[test]
2718    fn test_find_similar_edge_ignores_where_and_exact() {
2719        let db = demo_db();
2720        let resp = tool_call(
2721            &db,
2722            1,
2723            "find_similar",
2724            json!({
2725                "key": "alice",
2726                "edge_type": "SIMILAR",
2727                "where": { "field": "x", "eq": "y", "in": ["z"] },
2728                "exact": true
2729            }),
2730        );
2731        assert!(
2732            !is_error(&resp),
2733            "edge mode must ignore invalid where: {resp:?}"
2734        );
2735    }
2736
2737    /// Edge-traversal mode with `mask` must exclude hidden neighbors.
2738    #[test]
2739    fn test_find_similar_edge_mask_excludes_hidden_neighbor() {
2740        let db = SharedDb::open(&tmp_dir()).expect("open");
2741        {
2742            let mut g = db.write();
2743            g.insert_node("P", "alice", vec![]).unwrap();
2744            g.insert_node("P", "bob", vec![]).unwrap(); // visible
2745            g.insert_node("P", "carol", vec![]).unwrap(); // hidden
2746            g.insert_edge("KNOWS", "alice", "bob").unwrap();
2747            g.insert_edge("KNOWS", "alice", "carol").unwrap();
2748        }
2749        // Mask: alice and bob visible; carol hidden.
2750        let resp = tool_call(
2751            &db,
2752            1,
2753            "find_similar",
2754            json!({
2755                "key": "alice",
2756                "edge_type": "KNOWS",
2757                "mask": ["alice", "bob"]
2758            }),
2759        );
2760        assert!(!is_error(&resp), "masked edge search must not error");
2761        let result = tool_text(&resp);
2762        let similar = result["similar"].as_array().expect("similar array");
2763        let neighbors: Vec<&str> = similar
2764            .iter()
2765            .filter_map(|e| e["neighbor_key"].as_str())
2766            .collect();
2767        assert!(neighbors.contains(&"bob"), "bob (visible) must appear");
2768        assert!(
2769            !neighbors.contains(&"carol"),
2770            "carol (hidden) must be excluded"
2771        );
2772    }
2773
2774    /// Edge-traversal mode with `mask`: a hidden query key must not reveal
2775    /// its existence — response must be a tool error identical to a nonexistent key.
2776    #[test]
2777    fn test_find_similar_edge_mask_hidden_key_is_not_found() {
2778        let db = SharedDb::open(&tmp_dir()).expect("open");
2779        {
2780            let mut g = db.write();
2781            g.insert_node("P", "alice", vec![]).unwrap();
2782            g.insert_node("P", "bob", vec![]).unwrap();
2783        }
2784        // alice exists but is not in the mask — must look like not-found.
2785        let resp_masked = tool_call(
2786            &db,
2787            1,
2788            "find_similar",
2789            json!({ "key": "alice", "edge_type": "KNOWS", "mask": ["bob"] }),
2790        );
2791        // ghost never exists — use as the reference for "not found".
2792        let resp_ghost = tool_call(
2793            &db,
2794            2,
2795            "find_similar",
2796            json!({ "key": "ghost", "edge_type": "KNOWS" }),
2797        );
2798        assert!(
2799            is_error(&resp_masked),
2800            "hidden query key must produce a tool error"
2801        );
2802        assert!(
2803            is_error(&resp_ghost),
2804            "nonexistent key must produce a tool error"
2805        );
2806        // Both errors must carry the same shape (both are key-not-found).
2807        assert_eq!(
2808            tool_err_text(&resp_masked).contains("alice"),
2809            tool_err_text(&resp_ghost).contains("ghost"),
2810            "error messages should follow same not-found template"
2811        );
2812    }
2813
2814    /// Binding: `explain_association` now answers in prose, and the report
2815    /// behind it — what `json: true` returns — is still `explain`'s array,
2816    /// with one `evidence` object added per relationship and every other
2817    /// field unchanged.
2818    #[test]
2819    fn test_explain_association_same_as_explain() {
2820        let db = demo_db();
2821        let explain = tool_text(&tool_call(
2822            &db,
2823            1,
2824            "explain",
2825            json!({ "a": "alice", "b": "bob" }),
2826        ));
2827        let assoc = tool_text(&tool_call(
2828            &db,
2829            2,
2830            "explain_association",
2831            json!({ "a": "alice", "b": "bob", "json": true }),
2832        ));
2833        let explain: Vec<Js> = serde_json::from_value(explain).expect("explain array");
2834        let mut assoc: Vec<Js> = serde_json::from_value(assoc).expect("assoc array");
2835        for row in &mut assoc {
2836            let ev = row
2837                .as_object_mut()
2838                .expect("object")
2839                .remove("evidence")
2840                .expect("every derived edge carries its evidence");
2841            assert!(
2842                ev["similarity"].is_number(),
2843                "a vector_similar edge reports the cosine it scored: {ev}"
2844            );
2845        }
2846        assert_eq!(explain, assoc, "evidence is the only addition");
2847
2848        let prose = tool_call(
2849            &db,
2850            3,
2851            "explain_association",
2852            json!({ "a": "alice", "b": "bob" }),
2853        );
2854        let text = prose["result"]["content"][0]["text"]
2855            .as_str()
2856            .expect("text content");
2857        assert!(
2858            text.contains("mushroomdb explain — alice ↔ bob:"),
2859            "the default reply is the digest: {text}"
2860        );
2861    }
2862
2863    // ── history tools ──────────────────────────────────────────────────────────
2864
2865    /// `edge_history` must return the derived-edge lifecycle (Added event with
2866    /// rule attribution) and include the `total_commits` horizon field.
2867    #[test]
2868    fn test_edge_history_returns_derived_lifecycle_with_rule() {
2869        let db = demo_db(); // alice+bob + sim_emb rule → SIMILAR derived edge
2870        let resp = tool_call(&db, 1, "edge_history", json!({ "a": "alice", "b": "bob" }));
2871        assert!(!is_error(&resp), "edge_history must not error: {resp}");
2872        let result = tool_text(&resp);
2873
2874        // Must carry horizon metadata.
2875        let total = result["total_commits"].as_u64().expect("total_commits");
2876        assert!(total > 0, "total_commits must be > 0 after ingest + rule");
2877
2878        // Must have at least one event (the SIMILAR derived-edge addition).
2879        let events = result["events"].as_array().expect("events array");
2880        assert!(!events.is_empty(), "expected at least one edge event");
2881
2882        // At least one event must be Added with a non-null rule (derived edge).
2883        let derived_added = events
2884            .iter()
2885            .any(|ev| ev["event"].as_str() == Some("Added") && !ev["rule"].is_null());
2886        assert!(
2887            derived_added,
2888            "expected a derived Added event with rule attribution: {events:?}"
2889        );
2890    }
2891
2892    /// `was_linked` must return `true` for an edge that was active at the given commit,
2893    /// and the response must include the echo fields.
2894    #[test]
2895    fn test_was_linked_at_valid_commit() {
2896        let db = SharedDb::open(&tmp_dir()).expect("open");
2897        {
2898            let mut g = db.write();
2899            let opts = IngestOptions {
2900                key_field: "id".into(),
2901                auto_fk: AutoFk::Off,
2902            };
2903            let rows: Vec<BTreeMap<String, Value>> = vec![
2904                [("id", Value::Str("x".into()))]
2905                    .into_iter()
2906                    .map(|(k, v)| (k.to_string(), v))
2907                    .collect(),
2908                [("id", Value::Str("y".into()))]
2909                    .into_iter()
2910                    .map(|(k, v)| (k.to_string(), v))
2911                    .collect(),
2912            ];
2913            g.ingest("N", rows, &opts).expect("ingest");
2914            g.insert_edge("LINK", "x", "y").expect("edge");
2915        }
2916        // There are now at least 2 commits (ingest + edge). Check at the last one.
2917        let g = db.read();
2918        let total = g.wal_total_commits().expect("wal_total_commits");
2919        drop(g);
2920
2921        let resp = tool_call(
2922            &db,
2923            1,
2924            "was_linked",
2925            json!({ "a": "x", "b": "y", "edge_type": "LINK", "at_commit": total - 1 }),
2926        );
2927        assert!(!is_error(&resp), "was_linked must not error: {resp}");
2928        let result = tool_text(&resp);
2929        assert_eq!(result["linked"], true);
2930        assert_eq!(result["a"], "x");
2931        assert_eq!(result["edge_type"], "LINK");
2932    }
2933
2934    /// `was_linked` with an out-of-horizon commit must return a tool error (not
2935    /// a protocol error), and the error message must mention the commit range.
2936    #[test]
2937    fn test_was_linked_out_of_horizon_returns_tool_error() {
2938        let db = SharedDb::open(&tmp_dir()).expect("open");
2939        {
2940            let mut g = db.write();
2941            g.insert_node("N", "a", vec![]).expect("node a");
2942            g.insert_node("N", "b", vec![]).expect("node b");
2943        }
2944        // Commit 999 is well beyond the WAL.
2945        let resp = tool_call(
2946            &db,
2947            1,
2948            "was_linked",
2949            json!({ "a": "a", "b": "b", "edge_type": "X", "at_commit": 999 }),
2950        );
2951        // isError true = tool-level error (not a JSON-RPC protocol error).
2952        assert!(
2953            is_error(&resp),
2954            "out-of-range commit must be a tool error: {resp}"
2955        );
2956        let text = resp["result"]["content"][0]["text"].as_str().expect("text");
2957        assert!(
2958            text.contains("out of range") || text.contains("range"),
2959            "error must mention range: {text}"
2960        );
2961    }
2962
2963    /// `node_history` tool must return the node's WAL history and the
2964    /// `total_commits` horizon field.
2965    #[test]
2966    fn test_node_history_via_mcp() {
2967        let db = demo_db(); // alice + bob, with a SIMILAR rule
2968        let resp = tool_call(&db, 1, "node_history", json!({ "key": "alice" }));
2969        assert!(!is_error(&resp), "node_history must not error: {resp}");
2970        let result = tool_text(&resp);
2971
2972        assert_eq!(result["key"], "alice");
2973        let total = result["total_commits"].as_u64().expect("total_commits");
2974        assert!(total > 0, "total_commits must be > 0");
2975
2976        let history = result["history"].as_array().expect("history array");
2977        assert!(
2978            !history.is_empty(),
2979            "alice should have at least one history entry"
2980        );
2981
2982        // First event should be a NodeInserted.
2983        let first_change = &history[0]["change"];
2984        assert_eq!(first_change["type"], "NodeInserted");
2985        assert_eq!(first_change["label"], "Person");
2986    }
2987}