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