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