Skip to main content

server/
mcp.rs

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