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