Skip to main content

mnemo_engine/
mcp.rs

1//! Hand-rolled Model Context Protocol (MCP) stdio server for MNemo.
2//!
3//! Exposes a `.mnemo` file as an MCP server so any MCP-compatible agent
4//! (Claude Desktop, Cursor, mcp-server, custom clients) can drive it as a
5//! tool. Framed as **JSON-RPC 2.0 with newline-delimited JSON** over
6//! stdin/stdout, per the MCP spec. Stderr is used for human-readable
7//! diagnostics only — never for protocol traffic.
8//!
9//! ## Why hand-rolled
10//!
11//! The official [`rmcp`](https://crates.io/crates/rmcp) SDK declares
12//! `edition = "2024"` on its workspace, which requires Rust 1.85+; our
13//! MSRV is 1.75 and locked (transitive deps pin us). `rmcp` also brings
14//! in tokio + async, a big architectural shift for a sync-only core.
15//! Hand-rolling stdio JSON-RPC is ~250 LoC and adds zero dependencies
16//! beyond `serde_json` (already a dep). See CHANGELOG's Phase 4 entry
17//! for the decision record.
18//!
19//! ## Protocol version
20//!
21//! We advertise MCP protocol `2025-06-18` on `initialize`. If a client
22//! requests a different version we echo their version back — the standard
23//! compatibility dance. The seven tools we expose are stable across
24//! recent protocol revisions.
25//!
26//! ## Tools
27//!
28//! - `about` — read-only briefing (manifest + onboarding memories)
29//! - `remember` — insert a memory (BYO vector; embedder integration deferred to Phase 3)
30//! - `recall` — vector recall (BYO query vector)
31//! - `forget` — delete by ULID
32//! - `list` — enumerate live memories
33//! - `snapshot_list` — enumerate PITR snapshots
34//! - `stats` — database statistics
35//!
36//! Every mutation calls `Mnemo::flush()` before returning success — MCP
37//! clients treat tool calls as atomic and expect on-disk durability.
38//!
39//! ## Passphrase
40//!
41//! Read from the `MNEMO_PASSPHRASE` environment variable only.
42//! `serve_stdio` refuses to start without it. There is no CLI-flag
43//! fallback here — that would leave the passphrase in shell history
44//! and process listings.
45
46use std::io::{BufRead, BufReader, Read, Write};
47use std::path::Path;
48
49use serde::{Deserialize, Serialize};
50use serde_json::{json, Map, Value};
51
52use crate::error::{MnemoError, Result};
53use crate::memory::{Memory, MemoryType, Metric, Scope};
54use crate::result_cache::{CachePutOpts, SemanticCachePutOpts, DEFAULT_SEMANTIC_THRESHOLD};
55use crate::store::{Mnemo, RecallRequest};
56
57/// MCP protocol version we advertise on `initialize`.
58const PROTOCOL_VERSION: &str = "2025-06-18";
59
60/// Environment variable that supplies the database passphrase.
61const PASSPHRASE_ENV: &str = "MNEMO_PASSPHRASE";
62
63/// Fuzz-target helper: parse a single JSON-RPC line as a `Request` and
64/// discard. Panic-free — malformed input returns `Err(String)`, valid
65/// requests return `Ok(())`. Exposed only via `crate::__fuzz`; not part
66/// of the stable public API.
67#[doc(hidden)]
68pub fn __fuzz_parse_request_line(line: &str) -> std::result::Result<(), String> {
69    serde_json::from_str::<Request>(line)
70        .map(|_| ())
71        .map_err(|e| e.to_string())
72}
73
74// --- JSON-RPC 2.0 wire types ---------------------------------------------
75
76/// Incoming JSON-RPC 2.0 request or notification (a notification is a
77/// request with no `id`).
78#[derive(Debug, Deserialize)]
79struct Request {
80    #[serde(default)]
81    jsonrpc: String,
82    #[serde(default)]
83    id: Option<Value>,
84    method: String,
85    #[serde(default)]
86    params: Value,
87}
88
89/// Outgoing JSON-RPC 2.0 response envelope.
90#[derive(Debug, Serialize)]
91struct Response {
92    jsonrpc: &'static str,
93    id: Value,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    result: Option<Value>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    error: Option<RpcError>,
98}
99
100/// JSON-RPC 2.0 error object.
101#[derive(Debug, Serialize)]
102struct RpcError {
103    code: i32,
104    message: String,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    data: Option<Value>,
107}
108
109impl RpcError {
110    fn new(code: i32, message: impl Into<String>) -> Self {
111        Self {
112            code,
113            message: message.into(),
114            data: None,
115        }
116    }
117}
118
119// Standard JSON-RPC error codes.
120const PARSE_ERROR: i32 = -32700;
121const INVALID_REQUEST: i32 = -32600;
122const METHOD_NOT_FOUND: i32 = -32601;
123const INVALID_PARAMS: i32 = -32602;
124const INTERNAL_ERROR: i32 = -32603;
125
126// --- Entry point ---------------------------------------------------------
127
128/// Run the MCP stdio server over the provided `.mnemo` file.
129///
130/// Blocks the calling thread — reads JSON-RPC requests line-by-line from
131/// stdin, dispatches to the tool implementations, writes responses to
132/// stdout, and logs diagnostics to stderr. Returns when stdin closes
133/// (typical MCP client shutdown) or on a fatal I/O error.
134///
135/// The passphrase is read from the `MNEMO_PASSPHRASE` environment
136/// variable. If unset, returns [`MnemoError::Invalid`] without opening
137/// the file.
138pub fn serve_stdio(path: &Path) -> Result<()> {
139    serve_stdio_with_embedder(path, None)
140}
141
142/// Variant that optionally registers an [`crate::EmbedCmd`] embedder on
143/// the handle at server start (Phase 3 PR 10b). The MCP `remember`,
144/// `recall`, `cache_put_semantic` and `cache_get_semantic` tools then
145/// accept a `text` param as an alternative to the explicit vector —
146/// the embedder runs once per request against the persistent child
147/// process. Pass `None` for the pre-Phase-3 vector-only behavior.
148///
149/// `embed_spec` tuple fields: `(command, name, normalize)`. See
150/// [`crate::EmbedCmd::new`] for the command format.
151pub fn serve_stdio_with_embedder(
152    path: &Path,
153    embed_spec: Option<(String, String, bool, std::time::Duration)>,
154) -> Result<()> {
155    let stdin = std::io::stdin();
156    let stdout = std::io::stdout();
157    serve_with_streams(path, stdin.lock(), stdout.lock(), embed_spec)
158}
159
160/// Run the MCP loop against explicit reader/writer streams. Extracted so
161/// integration tests can pipe scripted input without touching the real
162/// stdin/stdout. `embed_spec` is as in [`serve_stdio_with_embedder`].
163pub fn serve_with_streams<R: Read, W: Write>(
164    path: &Path,
165    reader: R,
166    mut writer: W,
167    embed_spec: Option<(String, String, bool, std::time::Duration)>,
168) -> Result<()> {
169    let passphrase = std::env::var(PASSPHRASE_ENV).map_err(|_| {
170        MnemoError::Invalid(format!(
171            "{PASSPHRASE_ENV} is required to serve — set it in the shell before starting"
172        ))
173    })?;
174
175    eprintln!(
176        "mnemo mcp: opening {} (protocol {PROTOCOL_VERSION})",
177        path.display()
178    );
179    let mut db = Mnemo::open(path, &passphrase)?;
180    if let Some((cmd, name, normalize, timeout)) = embed_spec {
181        let identity = crate::EmbedderIdentity {
182            name,
183            dimensions: db.dimensions(),
184            normalize,
185        };
186        let embedder = crate::EmbedCmd::new(&cmd, identity)?.with_timeout(timeout);
187        db.set_embedder(embedder)?;
188        eprintln!(
189            "mnemo mcp: embedder ready (text params accepted, timeout {}s)",
190            timeout.as_secs()
191        );
192    }
193    eprintln!("mnemo mcp: ready ({} live memories)", db.len());
194
195    let reader = BufReader::new(reader);
196    for line in reader.lines() {
197        let line = line.map_err(MnemoError::Io)?;
198        let trimmed = line.trim();
199        if trimmed.is_empty() {
200            continue;
201        }
202
203        let (id, out) = match serde_json::from_str::<Request>(trimmed) {
204            Ok(req) => {
205                let id = req.id.clone();
206                let is_notification = id.is_none();
207                let result = handle(&mut db, &req);
208                if is_notification {
209                    // Per JSON-RPC 2.0: notifications get no response.
210                    continue;
211                }
212                (id.unwrap_or(Value::Null), result)
213            }
214            Err(e) => (
215                Value::Null,
216                Err(RpcError::new(PARSE_ERROR, format!("parse error: {e}"))),
217            ),
218        };
219
220        let resp = match out {
221            Ok(value) => Response {
222                jsonrpc: "2.0",
223                id,
224                result: Some(value),
225                error: None,
226            },
227            Err(err) => Response {
228                jsonrpc: "2.0",
229                id,
230                result: None,
231                error: Some(err),
232            },
233        };
234
235        let line =
236            serde_json::to_string(&resp).map_err(|e| MnemoError::Serialize(e.to_string()))?;
237        writeln!(writer, "{line}").map_err(MnemoError::Io)?;
238        writer.flush().map_err(MnemoError::Io)?;
239    }
240    // Scoring v2 (v0.7.0) flipped `tool_recall` to `track_access(true)`:
241    // every recall bumps `accessed_at` / `access_count` in the catalog
242    // in memory (no per-call fsync). The previous `db.flush()` only
243    // fired on each mutating tool call's exit path, so a session that
244    // issued only `recall` / `about` / `stats` / `list` /
245    // `cache_get_semantic`-on-hit calls (read-shaped) would exit with
246    // every one of its access-stat bumps still in RAM, discarded on
247    // shutdown. Flush at shutdown so a read-only session persists its
248    // access stats too — the write is cheap (catalog page only; the
249    // record bodies aren't dirty) and guarantees the usage-saturation
250    // signal survives across sessions.
251    if let Err(e) = db.flush() {
252        eprintln!("mnemo mcp: flush on shutdown failed: {e}");
253    }
254    eprintln!("mnemo mcp: stdin closed, shutting down");
255    Ok(())
256}
257
258// --- Dispatch ------------------------------------------------------------
259
260/// Route a request to a method handler. Returns either the `result`
261/// payload for a JSON-RPC success or an [`RpcError`] for a failure.
262fn handle(db: &mut Mnemo, req: &Request) -> std::result::Result<Value, RpcError> {
263    if req.jsonrpc != "2.0" {
264        return Err(RpcError::new(
265            INVALID_REQUEST,
266            format!("expected jsonrpc \"2.0\", got {:?}", req.jsonrpc),
267        ));
268    }
269    match req.method.as_str() {
270        "initialize" => Ok(initialize_result(&req.params)),
271        "notifications/initialized" | "notifications/cancelled" => Ok(Value::Null),
272        "ping" => Ok(json!({})),
273        "tools/list" => Ok(tools_list()),
274        "tools/call" => tools_call(db, &req.params),
275        other => Err(RpcError::new(
276            METHOD_NOT_FOUND,
277            format!("method '{other}' not implemented"),
278        )),
279    }
280}
281
282/// Echo the client's requested protocol version if present; fall back to
283/// ours. Always advertise the same `serverInfo` and `capabilities` block.
284fn initialize_result(params: &Value) -> Value {
285    let client_version = params
286        .get("protocolVersion")
287        .and_then(|v| v.as_str())
288        .unwrap_or(PROTOCOL_VERSION)
289        .to_string();
290    json!({
291        "protocolVersion": client_version,
292        "capabilities": {
293            "tools": { "listChanged": false },
294        },
295        "serverInfo": {
296            "name": "mnemo",
297            "version": env!("CARGO_PKG_VERSION"),
298        },
299    })
300}
301
302// --- Tools registry ------------------------------------------------------
303
304/// The seven tools this server exposes. Kept as a hand-authored constant
305/// (rather than macro-derived) so the JSON schemas stay auditable —
306/// every field a client sees is visible right here.
307fn tools_list() -> Value {
308    json!({
309        "tools": [
310            {
311                "name": "about",
312                "description": "Read-only briefing for this .mnemo file: manifest scaffold plus any memories tagged as onboarding. Run this first to learn how the file expects to be used.",
313                "inputSchema": {
314                    "type": "object",
315                    "properties": {},
316                    "additionalProperties": false
317                }
318            },
319            {
320                "name": "remember",
321                "description": "Insert, overwrite, or UPSERT a memory. Pass EITHER `vector` (explicit embedding) OR `text` (embedded by the server if it was started with --embed-cmd / --embed-name). Phase 12.1: when `key` is set, this becomes a keyed upsert — if a live memory already holds this `(agent + key)` slot the prior record is marked `_mnemo.superseded_by` and a new one is inserted, and an identical re-write is a no-op. **PREFER `key` for any fact that replaces an earlier one** (versions, current status, user preferences, plan outcomes). Persists to disk before returning. Returns `{id, outcome}` where `outcome` is `added`, `superseded`, `unchanged`, or (no key) `inserted`.",
322                "inputSchema": {
323                    "type": "object",
324                    "required": ["content", "memory_type"],
325                    "properties": {
326                        "content": { "type": "string" },
327                        "memory_type": { "type": "string", "enum": ["episodic", "semantic", "procedural", "working"] },
328                        "vector": { "type": "array", "items": { "type": "number" }, "description": "Explicit embedding; mutually exclusive with `text`." },
329                        "text": { "type": "string", "description": "Content to embed via the server's registered embedder; mutually exclusive with `vector`. Requires the server to have been started with --embed-cmd." },
330                        "importance": { "type": "number", "minimum": 0, "maximum": 1 },
331                        "agent_id": { "type": "string" },
332                        "session_id": { "type": "string" },
333                        "ttl_secs": { "type": "integer" },
334                        "shared": { "type": "boolean" },
335                        "metadata": { "type": "object" },
336                        "key": { "type": "string", "description": "Phase 12.1 upsert slot name. A later `remember` with the same `key` + agent scope supersedes the prior record; an identical re-write is a no-op. Use this for any fact that replaces an earlier one." }
337                    },
338                    "additionalProperties": false
339                }
340            },
341            {
342                "name": "update",
343                "description": "Edit a memory in place (Phase 12.1). Preserves the ULID, `created_at` and access stats. Any of `content` (needs `vector` to avoid embedding drift), `text` (re-embeds via the server), `importance`, `metadata`, `ttl_secs` can change. An identical patch returns `{id, outcome: 'unchanged'}` without writing. Prefer `remember` with `key` for facts that replace earlier ones; use `update` for in-place corrections to one specific record.",
344                "inputSchema": {
345                    "type": "object",
346                    "required": ["id"],
347                    "properties": {
348                        "id": { "type": "string", "description": "ULID of the memory to update." },
349                        "content": { "type": "string", "description": "New content. Requires a matching `vector` (use `text` to re-embed instead)." },
350                        "vector": { "type": "array", "items": { "type": "number" } },
351                        "text": { "type": "string", "description": "New content; the server embeds it through its registered embedder. Mutually exclusive with `content` + `vector`." },
352                        "importance": { "type": "number", "minimum": 0, "maximum": 1 },
353                        "metadata": { "type": "object", "description": "Replace the user-visible metadata. Reserved `_mnemo.*` keys on the record are preserved." },
354                        "ttl_secs": { "type": "integer", "description": "Set the TTL to this many seconds. Use `clear_ttl: true` to drop any TTL." },
355                        "clear_ttl": { "type": "boolean", "description": "Clear any TTL on the record." }
356                    },
357                    "additionalProperties": false
358                }
359            },
360            {
361                "name": "history",
362                "description": "Walk the full chain for a keyed slot (Phase 12.1), newest first. Entry 0 is the live record (or empty if the slot is empty); subsequent entries are the superseded history. Slot resolution matches keyed `remember`: with no `agent` and `shared=false`, walks the default agent's private slot (v0.8.1 bugfix — pre-v0.8.1 this walked the shared slot by accident and returned empty for the common case). Pass `shared: true` for the shared slot; `agent` + `shared=true` is rejected.",
363                "inputSchema": {
364                    "type": "object",
365                    "required": ["key"],
366                    "properties": {
367                        "key": { "type": "string" },
368                        "agent": { "type": "string", "description": "Query the private slot for this agent. Mutually exclusive with `shared`." },
369                        "shared": { "type": "boolean", "default": false, "description": "Query the shared slot (one per file). Mutually exclusive with `agent`." }
370                    },
371                    "additionalProperties": false
372                }
373            },
374            {
375                "name": "recall",
376                "description": "Score every live memory (or the ANN-narrowed candidate set) against a query and return the top-k. Pass EITHER `query` (explicit vector) OR `text` (embedded by the server; requires --embed-cmd at serve start). Phase 12.6: add a `filter` object to restrict candidates before scoring (`session_id` plus per-metadata-key conditions: equality, `$in`, `$exists`, `$gte`/`$lte`). Phase 12.1: superseded records never appear. Phase 12.8: set `max_bytes: N` to cap the total UTF-8 bytes of returned `content` fields; the response is then shaped as `{hits: [...], truncated: bool}` so the caller can tell whether the budget cut hits. Without `max_bytes`, the response is a bare JSON array of hits (unchanged).",
377                "inputSchema": {
378                    "type": "object",
379                    "properties": {
380                        "query": { "type": "array", "items": { "type": "number" }, "description": "Explicit query vector; mutually exclusive with `text`." },
381                        "text": { "type": "string", "description": "Query text; embedded by the server's registered embedder. Mutually exclusive with `query`." },
382                        "top_k": { "type": "integer", "default": 10 },
383                        "types": { "type": "array", "items": { "type": "string" } },
384                        "agent": { "type": "string" },
385                        "metric": { "type": "string", "enum": ["cosine", "l2", "dot"], "default": "cosine" },
386                        "max_bytes": { "type": "integer", "minimum": 1, "description": "Phase 12.8 budget: stop adding hits once the UTF-8 bytes of their `content` fields would exceed N. Always returns at least the top hit. When set, the response is `{hits, truncated}` instead of a bare array." },
387                        "filter": {
388                            "type": "object",
389                            "description": "Phase 12.6 pre-scoring filter. All conditions AND together. Shape: `{\"session_id\": \"...\", \"metadata\": {key: value | {\"$in\": [...]} | {\"$exists\": true} | {\"$gte\": n, \"$lte\": n}}}`.",
390                            "properties": {
391                                "session_id": { "type": "string" },
392                                "metadata": { "type": "object" }
393                            },
394                            "additionalProperties": false
395                        }
396                    },
397                    "additionalProperties": false
398                }
399            },
400            {
401                "name": "forget",
402                "description": "Soft-delete a memory by ULID. Space is reclaimed on the next `mnemo compact`. Persists to disk before returning.",
403                "inputSchema": {
404                    "type": "object",
405                    "required": ["id"],
406                    "properties": {
407                        "id": { "type": "string", "description": "ULID of the memory to delete" }
408                    },
409                    "additionalProperties": false
410                }
411            },
412            {
413                "name": "list",
414                "description": "Return every live memory as JSON. Sorted by creation time, oldest first. Vectors are omitted by default to keep payloads small.",
415                "inputSchema": {
416                    "type": "object",
417                    "properties": {
418                        "include_vectors": { "type": "boolean", "default": false }
419                    },
420                    "additionalProperties": false
421                }
422            },
423            {
424                "name": "snapshot_list",
425                "description": "List every restorable snapshot (one per committed flush). Each entry gives the transaction ID, creation timestamp, and memory count.",
426                "inputSchema": {
427                    "type": "object",
428                    "properties": {},
429                    "additionalProperties": false
430                }
431            },
432            {
433                "name": "stats",
434                "description": "Summary statistics: memory count, deleted count, agents present, snapshot count, page cache occupancy.",
435                "inputSchema": {
436                    "type": "object",
437                    "properties": {},
438                    "additionalProperties": false
439                }
440            },
441            {
442                "name": "cache_put",
443                "description": "Insert or overwrite an exact-key cache entry. Value is a UTF-8 string; use `content_type` to label the payload. Persists to disk before returning. Namespace and key are caller-chosen; the key is SHA-256 hashed by the engine.",
444                "inputSchema": {
445                    "type": "object",
446                    "required": ["namespace", "key", "value"],
447                    "properties": {
448                        "namespace": { "type": "string" },
449                        "key": { "type": "string" },
450                        "value": { "type": "string" },
451                        "content_type": { "type": "string", "default": "text" },
452                        "ttl_secs": { "type": "integer", "minimum": 0 }
453                    },
454                    "additionalProperties": false
455                }
456            },
457            {
458                "name": "cache_get",
459                "description": "Look up an exact-key cache entry. Returns `{value, content_type, hit}` on a hit, `{hit: false}` on miss (missing or TTL-expired). Bumps access stats catalog-only.",
460                "inputSchema": {
461                    "type": "object",
462                    "required": ["namespace", "key"],
463                    "properties": {
464                        "namespace": { "type": "string" },
465                        "key": { "type": "string" }
466                    },
467                    "additionalProperties": false
468                }
469            },
470            {
471                "name": "cache_stats",
472                "description": "Cache statistics for a namespace (omit for the whole cache). Returns entries, bytes, hits, misses, hit_rate, evictions.",
473                "inputSchema": {
474                    "type": "object",
475                    "properties": {
476                        "namespace": { "type": "string" }
477                    },
478                    "additionalProperties": false
479                }
480            },
481            {
482                "name": "cache_put_semantic",
483                "description": "Insert an entry with an embedding vector for semantic recall (Phase 10.2). Pass EITHER `vector` + `model` (explicit) OR `text` (embedded by the server; `model` defaults to the server embedder's identity name). Later cache_get_semantic calls under a different `model` won't see this entry (a different-model hit would be a semantics bug).",
484                "inputSchema": {
485                    "type": "object",
486                    "required": ["namespace", "key", "value"],
487                    "properties": {
488                        "namespace": { "type": "string" },
489                        "key": { "type": "string" },
490                        "vector": { "type": "array", "items": { "type": "number" }, "description": "Explicit embedding; mutually exclusive with `text`. Requires `model`." },
491                        "text": { "type": "string", "description": "Content to embed via the server's registered embedder; mutually exclusive with `vector`." },
492                        "value": { "type": "string" },
493                        "model": { "type": "string", "description": "Required with `vector`; optional with `text` (defaults to the embedder's identity name)." },
494                        "content_type": { "type": "string", "default": "text" },
495                        "ttl_secs": { "type": "integer", "minimum": 0 }
496                    },
497                    "additionalProperties": false
498                }
499            },
500            {
501                "name": "cache_get_semantic",
502                "description": "Semantic recall over the cache: top-1 cosine over live vectored entries in the namespace whose `model` matches. Pass EITHER `query` + `model` OR `text` (which embeds via the server and defaults the model to the embedder's identity). Returns `{hit: true, value, similarity, ...}` iff similarity >= threshold, else `{hit: false}`. Threshold defaults to 0.97.",
503                "inputSchema": {
504                    "type": "object",
505                    "required": ["namespace"],
506                    "properties": {
507                        "namespace": { "type": "string" },
508                        "query": { "type": "array", "items": { "type": "number" }, "description": "Explicit query vector; mutually exclusive with `text`. Requires `model`." },
509                        "text": { "type": "string", "description": "Query text; embedded by the server. Mutually exclusive with `query`." },
510                        "model": { "type": "string", "description": "Required with `query`; optional with `text`." },
511                        "threshold": { "type": "number", "minimum": -1.0, "maximum": 1.0 }
512                    },
513                    "additionalProperties": false
514                }
515            }
516        ]
517    })
518}
519
520// --- Tool dispatch -------------------------------------------------------
521
522/// The MCP `tools/call` method: dispatches on the caller-supplied `name`
523/// and returns the tool's `content` array wrapped in an MCP result.
524fn tools_call(db: &mut Mnemo, params: &Value) -> std::result::Result<Value, RpcError> {
525    let name = params
526        .get("name")
527        .and_then(|v| v.as_str())
528        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "missing tool name"))?;
529    let empty = json!({});
530    let args = params.get("arguments").unwrap_or(&empty);
531
532    let text = match name {
533        "about" => tool_about(db)?,
534        "remember" => tool_remember(db, args)?,
535        "update" => tool_update(db, args)?,
536        "history" => tool_history(db, args)?,
537        "recall" => tool_recall(db, args)?,
538        "forget" => tool_forget(db, args)?,
539        "list" => tool_list(db, args)?,
540        "snapshot_list" => tool_snapshot_list(db)?,
541        "stats" => tool_stats(db)?,
542        "cache_put" => tool_cache_put(db, args)?,
543        "cache_get" => tool_cache_get(db, args)?,
544        "cache_stats" => tool_cache_stats(db, args)?,
545        "cache_put_semantic" => tool_cache_put_semantic(db, args)?,
546        "cache_get_semantic" => tool_cache_get_semantic(db, args)?,
547        other => {
548            return Err(RpcError::new(
549                METHOD_NOT_FOUND,
550                format!("unknown tool '{other}'"),
551            ))
552        }
553    };
554
555    Ok(json!({
556        "content": [ { "type": "text", "text": text } ],
557        "isError": false,
558    }))
559}
560
561/// Convert a [`MnemoError`] to an [`RpcError`]. Every error becomes an
562/// `INTERNAL_ERROR` with the human-readable message — MCP clients
563/// display `error.message` verbatim to the user.
564/// Resolve a text argument into a vector via the server's registered
565/// embedder (Phase 3 PR 10b). Called by the four MCP tools that
566/// accept `text` as an alternative to the explicit vector: `remember`,
567/// `recall`, `cache_put_semantic`, `cache_get_semantic`. If no
568/// embedder was configured at server start (`mnemo serve --mcp
569/// --embed-cmd ...`) the error message tells the caller how to turn
570/// it on.
571fn embed_text_via_server(
572    db: &mut Mnemo,
573    tool: &str,
574    text: &str,
575) -> std::result::Result<Vec<f32>, RpcError> {
576    let embedder = db.embedder().ok_or_else(|| {
577        RpcError::new(
578            INVALID_PARAMS,
579            format!(
580                "{tool}: 'text' requires an embedder on the server. \
581                 Start with: mnemo serve --mcp <file> --embed-cmd '<cmd>' --embed-name <name>"
582            ),
583        )
584    })?;
585    let mut vs = embedder.embed(&[text]).map_err(mnemo_err)?;
586    vs.pop().ok_or_else(|| {
587        RpcError::new(
588            INTERNAL_ERROR,
589            format!("{tool}: embedder returned no vector for the input text"),
590        )
591    })
592}
593
594fn mnemo_err(e: MnemoError) -> RpcError {
595    RpcError::new(INTERNAL_ERROR, e.to_string())
596}
597
598fn tool_about(db: &mut Mnemo) -> std::result::Result<String, RpcError> {
599    let mems = db.about().map_err(mnemo_err)?;
600    let arr: Vec<Value> = mems.iter().map(memory_to_json).collect();
601    Ok(serde_json::to_string_pretty(&arr).unwrap_or_else(|_| "[]".into()))
602}
603
604fn tool_remember(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
605    let content = args
606        .get("content")
607        .and_then(|v| v.as_str())
608        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "remember: missing 'content'"))?;
609    let mtype_str = args
610        .get("memory_type")
611        .and_then(|v| v.as_str())
612        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "remember: missing 'memory_type'"))?;
613    let mtype = MemoryType::parse(mtype_str).ok_or_else(|| {
614        RpcError::new(
615            INVALID_PARAMS,
616            format!("remember: unknown memory_type '{mtype_str}'"),
617        )
618    })?;
619    // Accept either `vector` (explicit) or `text` (embedded via the
620    // server's registered embedder, Phase 3). Exactly one required.
621    let vector = match (args.get("vector"), args.get("text")) {
622        (Some(_), Some(_)) => {
623            return Err(RpcError::new(
624                INVALID_PARAMS,
625                "remember: pass EITHER 'vector' or 'text', not both",
626            ));
627        }
628        (Some(v), None) => v
629            .as_array()
630            .ok_or_else(|| {
631                RpcError::new(INVALID_PARAMS, "remember: 'vector' must be a JSON array")
632            })?
633            .iter()
634            .map(|v| v.as_f64().map(|f| f as f32))
635            .collect::<Option<Vec<f32>>>()
636            .ok_or_else(|| {
637                RpcError::new(
638                    INVALID_PARAMS,
639                    "remember: 'vector' must be an array of numbers",
640                )
641            })?,
642        (None, Some(t)) => {
643            let text = t.as_str().ok_or_else(|| {
644                RpcError::new(INVALID_PARAMS, "remember: 'text' must be a string")
645            })?;
646            embed_text_via_server(db, "remember", text)?
647        }
648        (None, None) => {
649            return Err(RpcError::new(
650                INVALID_PARAMS,
651                "remember: missing 'vector' or 'text'",
652            ))
653        }
654    };
655
656    let mut mem = Memory::new(content, mtype, vector);
657    if let Some(agent) = args.get("agent_id").and_then(|v| v.as_str()) {
658        mem = mem.with_agent(agent);
659    }
660    if let Some(sess) = args.get("session_id").and_then(|v| v.as_str()) {
661        mem = mem.with_session(sess);
662    }
663    if let Some(imp) = args.get("importance").and_then(|v| v.as_f64()) {
664        mem = mem.with_importance(imp as f32);
665    }
666    if let Some(ttl) = args.get("ttl_secs").and_then(|v| v.as_i64()) {
667        mem = mem.with_ttl(ttl);
668    }
669    if let Some(shared) = args.get("shared").and_then(|v| v.as_bool()) {
670        if shared {
671            mem = mem.with_scope(Scope::Shared);
672        }
673    }
674    if let Some(meta) = args.get("metadata").and_then(|v| v.as_object()) {
675        for (k, v) in meta {
676            mem.metadata.insert(k.clone(), v.clone());
677        }
678    }
679
680    // Phase 12.1: when `key` is set, take the keyed-upsert path
681    // (supersede / no-op / add); otherwise keep the pre-12.1
682    // single-id return shape (now carrying `outcome: "inserted"` so
683    // callers can distinguish it from the keyed outcomes).
684    match args.get("key").and_then(|v| v.as_str()) {
685        None => {
686            let id = db.remember(mem).map_err(mnemo_err)?;
687            db.flush().map_err(mnemo_err)?;
688            Ok(json!({ "id": id.to_string(), "outcome": "inserted" }).to_string())
689        }
690        Some(k) => {
691            let (id, outcome) = db.remember_keyed(mem, k).map_err(mnemo_err)?;
692            db.flush().map_err(mnemo_err)?;
693            let outcome_str = match outcome {
694                crate::WriteOutcome::Added => "added",
695                crate::WriteOutcome::Superseded => "superseded",
696                crate::WriteOutcome::Unchanged => "unchanged",
697                crate::WriteOutcome::Updated => "superseded",
698            };
699            Ok(json!({ "id": id.to_string(), "outcome": outcome_str }).to_string())
700        }
701    }
702}
703
704fn tool_update(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
705    use crate::Ulid;
706    use crate::{MemoryPatch, WriteOutcome};
707    let id_str = args
708        .get("id")
709        .and_then(|v| v.as_str())
710        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "update: missing 'id'"))?;
711    let id = Ulid::from_string(id_str)
712        .map_err(|_| RpcError::new(INVALID_PARAMS, format!("update: invalid ULID '{id_str}'")))?;
713    // Mutually exclusive: `text` (server re-embeds) vs `content`+`vector`
714    // (explicit). Either path produces `(new_content, new_vector)` for
715    // the engine; both unset means no text/vector change.
716    let has_text = args.get("text").is_some();
717    let has_content = args.get("content").is_some();
718    let has_vector = args.get("vector").is_some();
719    if has_text && (has_content || has_vector) {
720        return Err(RpcError::new(
721            INVALID_PARAMS,
722            "update: pass EITHER 'text' or 'content'+'vector', not both",
723        ));
724    }
725    let mut patch = MemoryPatch::default();
726    if has_content {
727        let c = args
728            .get("content")
729            .and_then(|v| v.as_str())
730            .ok_or_else(|| RpcError::new(INVALID_PARAMS, "update: 'content' must be a string"))?;
731        patch.content = Some(c.to_string());
732    }
733    if has_vector {
734        let v = args
735            .get("vector")
736            .and_then(|v| v.as_array())
737            .ok_or_else(|| RpcError::new(INVALID_PARAMS, "update: 'vector' must be a JSON array"))?
738            .iter()
739            .map(|v| v.as_f64().map(|f| f as f32))
740            .collect::<Option<Vec<f32>>>()
741            .ok_or_else(|| {
742                RpcError::new(
743                    INVALID_PARAMS,
744                    "update: 'vector' must be an array of numbers",
745                )
746            })?;
747        patch.vector = Some(v);
748    }
749    if let Some(imp) = args.get("importance").and_then(|v| v.as_f64()) {
750        patch.importance = Some(imp as f32);
751    }
752    if let Some(meta) = args.get("metadata").and_then(|v| v.as_object()) {
753        patch.metadata = Some(meta.clone());
754    }
755    let clear_ttl = args
756        .get("clear_ttl")
757        .and_then(|v| v.as_bool())
758        .unwrap_or(false);
759    if clear_ttl {
760        patch.ttl_secs = Some(None);
761    } else if let Some(ttl) = args.get("ttl_secs").and_then(|v| v.as_i64()) {
762        patch.ttl_secs = Some(Some(ttl));
763    }
764
765    let outcome = if has_text {
766        let t = args
767            .get("text")
768            .and_then(|v| v.as_str())
769            .ok_or_else(|| RpcError::new(INVALID_PARAMS, "update: 'text' must be a string"))?;
770        db.update_text(&id, t, patch).map_err(mnemo_err)?
771    } else {
772        db.update(&id, patch).map_err(mnemo_err)?
773    };
774    db.flush().map_err(mnemo_err)?;
775    let outcome_str = match outcome {
776        WriteOutcome::Updated => "updated",
777        WriteOutcome::Unchanged => "unchanged",
778        WriteOutcome::Added => "updated",
779        WriteOutcome::Superseded => "updated",
780    };
781    Ok(json!({ "id": id.to_string(), "outcome": outcome_str }).to_string())
782}
783
784fn tool_history(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
785    use crate::KeyScope;
786    let key = args
787        .get("key")
788        .and_then(|v| v.as_str())
789        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "history: missing 'key'"))?;
790    let agent = args.get("agent").and_then(|v| v.as_str());
791    let shared = args
792        .get("shared")
793        .and_then(|v| v.as_bool())
794        .unwrap_or(false);
795    if shared && agent.is_some() {
796        return Err(RpcError::new(
797            INVALID_PARAMS,
798            "history: 'agent' and 'shared' are mutually exclusive",
799        ));
800    }
801    // v0.8.1 bugfix (lane A): match keyed `remember` slot resolution.
802    let scope = if shared {
803        KeyScope::Shared
804    } else {
805        match agent {
806            Some(a) => KeyScope::Private(a.to_string()),
807            None => KeyScope::private_default(),
808        }
809    };
810    let chain = db.history_in(key, scope).map_err(mnemo_err)?;
811    let out: Vec<Value> = chain
812        .iter()
813        .enumerate()
814        .map(|(i, m)| {
815            let mut v = memory_to_json(m);
816            if let Some(obj) = v.as_object_mut() {
817                obj.insert(
818                    "state".into(),
819                    Value::String(if i == 0 {
820                        "live".into()
821                    } else {
822                        "superseded".into()
823                    }),
824                );
825            }
826            v
827        })
828        .collect();
829    Ok(serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".into()))
830}
831
832fn tool_recall(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
833    // Accept either `query` (explicit) or `text` (embedded).
834    let query = match (args.get("query"), args.get("text")) {
835        (Some(_), Some(_)) => {
836            return Err(RpcError::new(
837                INVALID_PARAMS,
838                "recall: pass EITHER 'query' or 'text', not both",
839            ))
840        }
841        (Some(v), None) => v
842            .as_array()
843            .ok_or_else(|| RpcError::new(INVALID_PARAMS, "recall: 'query' must be a JSON array"))?
844            .iter()
845            .map(|v| v.as_f64().map(|f| f as f32))
846            .collect::<Option<Vec<f32>>>()
847            .ok_or_else(|| {
848                RpcError::new(
849                    INVALID_PARAMS,
850                    "recall: 'query' must be an array of numbers",
851                )
852            })?,
853        (None, Some(t)) => {
854            let text = t
855                .as_str()
856                .ok_or_else(|| RpcError::new(INVALID_PARAMS, "recall: 'text' must be a string"))?;
857            embed_text_via_server(db, "recall", text)?
858        }
859        (None, None) => {
860            return Err(RpcError::new(
861                INVALID_PARAMS,
862                "recall: missing 'query' or 'text'",
863            ))
864        }
865    };
866    let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
867    let metric = match args
868        .get("metric")
869        .and_then(|v| v.as_str())
870        .unwrap_or("cosine")
871    {
872        "cosine" => Metric::Cosine,
873        "l2" => Metric::L2,
874        "dot" => Metric::Dot,
875        other => {
876            return Err(RpcError::new(
877                INVALID_PARAMS,
878                format!("recall: unknown metric '{other}'"),
879            ))
880        }
881    };
882    // Scoring v2 (v0.7.0) flipped the default: MCP recalls DO track
883    // access, so an agent's own retrievals contribute to the usage
884    // saturation term on later queries. The bump happens in-memory
885    // (`accessed_at` / `access_count` + `dirty_catalog`), and persists
886    // through the next flush — recall itself does not fsync, so there
887    // is no per-tool-call write-amplification cost. Access stats reach
888    // disk on either (a) the next mutating tool call's exit path (every
889    // one of `remember` / `forget` / `cache_put_semantic` /
890    // `cache_get_semantic`-on-miss ends in `db.flush()`), or (b) the
891    // server's own shutdown flush when stdin closes (see
892    // `serve_with_streams`), so a read-only session that issued only
893    // recalls still persists its stats.
894    let mut req = RecallRequest::new(query)
895        .top_k(top_k)
896        .metric(metric)
897        .track_access(true);
898    if let Some(types) = args.get("types").and_then(|v| v.as_array()) {
899        let parsed: Vec<MemoryType> = types
900            .iter()
901            .filter_map(|t| t.as_str().and_then(MemoryType::parse))
902            .collect();
903        if !parsed.is_empty() {
904            req = req.types(parsed);
905        }
906    }
907    if let Some(agent) = args.get("agent").and_then(|v| v.as_str()) {
908        req = req.agent(agent.to_string());
909    }
910    // Phase 12.6: filter object. Shape:
911    //   { "session_id": "...",
912    //     "metadata": { "k": v,
913    //                   "k2": {"$in": [v1, v2]},
914    //                   "k3": {"$exists": true},
915    //                   "k4": {"$gte": 5, "$lte": 10} } }
916    if let Some(filter_obj) = args.get("filter").and_then(|v| v.as_object()) {
917        req = req.filter(parse_mcp_filter(filter_obj)?);
918    }
919    // Phase 12.8: `max_bytes` opts into the budgeted response shape.
920    let has_max_bytes = args.get("max_bytes").is_some();
921    if let Some(n) = args.get("max_bytes").and_then(|v| v.as_u64()) {
922        req = req.max_bytes(n as usize);
923    }
924
925    let resp = db.recall_budgeted(&req).map_err(mnemo_err)?;
926    let out: Vec<Value> = resp
927        .hits
928        .into_iter()
929        .map(|h| {
930            let mut obj = memory_to_json(&h.memory);
931            if let Some(map) = obj.as_object_mut() {
932                map.insert("score".into(), json!(h.score));
933                map.insert("similarity".into(), json!(h.similarity));
934            }
935            obj
936        })
937        .collect();
938    if has_max_bytes {
939        // Phase 12.8 wrapped shape so callers can tell whether the
940        // budget cut anything.
941        let wrapped = json!({
942            "hits": out,
943            "truncated": resp.truncated,
944        });
945        Ok(serde_json::to_string_pretty(&wrapped).unwrap_or_else(|_| "{}".into()))
946    } else {
947        Ok(serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".into()))
948    }
949}
950
951/// Phase 12.6: parse an MCP `filter` object into a [`crate::Filter`].
952///
953/// Values under `metadata.<key>`:
954/// * plain scalar / array / string → [`crate::FilterTerm::Eq`]
955/// * `{"$exists": true}` → [`crate::FilterTerm::Exists`]
956/// * `{"$in": [...]}` → [`crate::FilterTerm::In`]
957/// * `{"$gte": n, "$lte": n}` → [`crate::FilterTerm::Range`] (either bound
958///   optional)
959/// * `{"$exists": false}` is rejected (use the absence of a term instead)
960fn parse_mcp_filter(
961    obj: &serde_json::Map<String, Value>,
962) -> std::result::Result<crate::Filter, RpcError> {
963    let mut f = crate::Filter::default();
964    if let Some(sid) = obj.get("session_id").and_then(|v| v.as_str()) {
965        f = f.with_session(sid.to_string());
966    }
967    if let Some(meta) = obj.get("metadata").and_then(|v| v.as_object()) {
968        for (k, v) in meta {
969            match v {
970                // Nested operator object.
971                Value::Object(ops) => {
972                    for (op_key, op_val) in ops {
973                        match op_key.as_str() {
974                            "$exists" => match op_val.as_bool() {
975                                Some(true) => {
976                                    f = f.metadata_exists(k.clone());
977                                }
978                                Some(false) => {
979                                    return Err(RpcError::new(
980                                        INVALID_PARAMS,
981                                        format!(
982                                            "filter.metadata.{k}.$exists=false isn't supported; \
983                                             drop the filter term instead"
984                                        ),
985                                    ));
986                                }
987                                None => {
988                                    return Err(RpcError::new(
989                                        INVALID_PARAMS,
990                                        format!("filter.metadata.{k}.$exists must be a boolean"),
991                                    ));
992                                }
993                            },
994                            "$in" => {
995                                let arr = op_val.as_array().ok_or_else(|| {
996                                    RpcError::new(
997                                        INVALID_PARAMS,
998                                        format!("filter.metadata.{k}.$in must be an array"),
999                                    )
1000                                })?;
1001                                f = f.metadata_in(k.clone(), arr.clone());
1002                            }
1003                            "$gte" | "$lte" => {
1004                                let mut min = None;
1005                                let mut max = None;
1006                                for (bound, bound_val) in ops {
1007                                    let n = bound_val.as_f64().ok_or_else(|| {
1008                                        RpcError::new(
1009                                            INVALID_PARAMS,
1010                                            format!("filter.metadata.{k}.{bound} must be a number"),
1011                                        )
1012                                    })?;
1013                                    match bound.as_str() {
1014                                        "$gte" => min = Some(n),
1015                                        "$lte" => max = Some(n),
1016                                        other => {
1017                                            return Err(RpcError::new(
1018                                                INVALID_PARAMS,
1019                                                format!(
1020                                                    "filter.metadata.{k}: unsupported range \
1021                                                     operator '{other}' (want $gte, $lte)"
1022                                                ),
1023                                            ));
1024                                        }
1025                                    }
1026                                }
1027                                f = f.metadata_range(k.clone(), min, max);
1028                                break; // All operator entries consumed in one pass.
1029                            }
1030                            other => {
1031                                return Err(RpcError::new(
1032                                    INVALID_PARAMS,
1033                                    format!(
1034                                        "filter.metadata.{k}: unknown operator '{other}' \
1035                                         (want $exists, $in, $gte, $lte)"
1036                                    ),
1037                                ));
1038                            }
1039                        }
1040                    }
1041                }
1042                // Plain scalar / array / string → equality.
1043                other => {
1044                    f = f.metadata_eq(k.clone(), other.clone());
1045                }
1046            }
1047        }
1048    }
1049    Ok(f)
1050}
1051
1052fn tool_forget(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1053    let id_str = args
1054        .get("id")
1055        .and_then(|v| v.as_str())
1056        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "forget: missing 'id'"))?;
1057    let ulid = crate::Ulid::from_string(id_str)
1058        .map_err(|_| RpcError::new(INVALID_PARAMS, format!("forget: invalid ULID '{id_str}'")))?;
1059    db.delete(&ulid).map_err(mnemo_err)?;
1060    db.flush().map_err(mnemo_err)?;
1061    Ok(json!({ "deleted": id_str }).to_string())
1062}
1063
1064fn tool_list(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1065    let include_vectors = args
1066        .get("include_vectors")
1067        .and_then(|v| v.as_bool())
1068        .unwrap_or(false);
1069    let mems = db.memories().map_err(mnemo_err)?;
1070    let out: Vec<Value> = mems
1071        .iter()
1072        .map(|m| {
1073            let mut v = memory_to_json(m);
1074            if !include_vectors {
1075                if let Some(o) = v.as_object_mut() {
1076                    o.remove("vector");
1077                }
1078            }
1079            v
1080        })
1081        .collect();
1082    Ok(serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".into()))
1083}
1084
1085fn tool_snapshot_list(db: &mut Mnemo) -> std::result::Result<String, RpcError> {
1086    let snaps = db.snapshots();
1087    let out: Vec<Value> = snaps
1088        .into_iter()
1089        .map(|s| {
1090            json!({
1091                "txn_id": s.txn_id,
1092                "created_at": s.created_at,
1093                "memory_count": s.memory_count,
1094            })
1095        })
1096        .collect();
1097    Ok(serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".into()))
1098}
1099
1100fn tool_stats(db: &mut Mnemo) -> std::result::Result<String, RpcError> {
1101    let s = db.stats().map_err(mnemo_err)?;
1102    let (cache_pages, cache_capacity) = db.page_cache_stats();
1103    let snapshot_count = db.snapshots().len();
1104    // Field names mirror `Stats` in `store.rs` verbatim so MCP clients
1105    // that reference the Rust doc for the same DB see the same shape.
1106    Ok(json!({
1107        "memories": s.memories,
1108        "deleted": s.deleted,
1109        "dimensions": s.dimensions,
1110        "file_bytes": s.file_bytes,
1111        "agents": s.agents,
1112        "encrypted": s.encrypted,
1113        "created_at": s.created_at,
1114        "wal_pages": s.wal_pages,
1115        "index": s.index,
1116        "snapshot_count": snapshot_count,
1117        "cache_pages": cache_pages,
1118        "cache_capacity": cache_capacity,
1119    })
1120    .to_string())
1121}
1122
1123// --- Result-cache tool handlers (Phase 10.4) -----------------------------
1124
1125fn tool_cache_put(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1126    let ns = args
1127        .get("namespace")
1128        .and_then(|v| v.as_str())
1129        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put: missing 'namespace'"))?;
1130    let key = args
1131        .get("key")
1132        .and_then(|v| v.as_str())
1133        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put: missing 'key'"))?;
1134    let value = args
1135        .get("value")
1136        .and_then(|v| v.as_str())
1137        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put: missing 'value'"))?;
1138    let content_type = args
1139        .get("content_type")
1140        .and_then(|v| v.as_str())
1141        .unwrap_or("text")
1142        .to_string();
1143    let ttl_secs = args.get("ttl_secs").and_then(|v| v.as_u64());
1144    let opts = CachePutOpts {
1145        content_type,
1146        ttl_secs,
1147        cost_hint_ms: None,
1148    };
1149    db.cache_put(ns, key, value.as_bytes(), opts)
1150        .map_err(mnemo_err)?;
1151    db.flush().map_err(mnemo_err)?;
1152    Ok(json!({ "cached": true, "namespace": ns, "key": key, "bytes": value.len() }).to_string())
1153}
1154
1155fn tool_cache_get(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1156    let ns = args
1157        .get("namespace")
1158        .and_then(|v| v.as_str())
1159        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_get: missing 'namespace'"))?;
1160    let key = args
1161        .get("key")
1162        .and_then(|v| v.as_str())
1163        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_get: missing 'key'"))?;
1164    match db.cache_get(ns, key).map_err(mnemo_err)? {
1165        Some(hit) => Ok(json!({
1166            "hit": true,
1167            "value": String::from_utf8_lossy(&hit.value),
1168            "content_type": hit.content_type,
1169            "created_at": hit.created_at,
1170            "accessed_at": hit.accessed_at,
1171            "access_count": hit.access_count,
1172        })
1173        .to_string()),
1174        None => Ok(json!({ "hit": false }).to_string()),
1175    }
1176}
1177
1178fn tool_cache_stats(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1179    let ns = args.get("namespace").and_then(|v| v.as_str());
1180    let s = db.cache_stats(ns);
1181    Ok(json!({
1182        "namespace": ns,
1183        "entries": s.entries,
1184        "bytes": s.bytes,
1185        "hits": s.hits,
1186        "misses": s.misses,
1187        "hit_rate": s.hit_rate,
1188        "evictions": s.evictions,
1189    })
1190    .to_string())
1191}
1192
1193fn tool_cache_put_semantic(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1194    let ns = args
1195        .get("namespace")
1196        .and_then(|v| v.as_str())
1197        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put_semantic: missing 'namespace'"))?;
1198    let key = args
1199        .get("key")
1200        .and_then(|v| v.as_str())
1201        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put_semantic: missing 'key'"))?;
1202    let value = args
1203        .get("value")
1204        .and_then(|v| v.as_str())
1205        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put_semantic: missing 'value'"))?;
1206    // When `text` is used, derive the model name from the server's
1207    // registered embedder (identity). Explicit `model` wins otherwise.
1208    let (vector, model) = match (args.get("vector"), args.get("text")) {
1209        (Some(_), Some(_)) => {
1210            return Err(RpcError::new(
1211                INVALID_PARAMS,
1212                "cache_put_semantic: pass EITHER 'vector' or 'text', not both",
1213            ));
1214        }
1215        (Some(v), None) => {
1216            let vec = v
1217                .as_array()
1218                .ok_or_else(|| {
1219                    RpcError::new(
1220                        INVALID_PARAMS,
1221                        "cache_put_semantic: 'vector' must be a JSON array",
1222                    )
1223                })?
1224                .iter()
1225                .map(|v| v.as_f64().map(|f| f as f32))
1226                .collect::<Option<Vec<f32>>>()
1227                .ok_or_else(|| {
1228                    RpcError::new(
1229                        INVALID_PARAMS,
1230                        "cache_put_semantic: 'vector' must be an array of numbers",
1231                    )
1232                })?;
1233            let model = args
1234                .get("model")
1235                .and_then(|v| v.as_str())
1236                .ok_or_else(|| {
1237                    RpcError::new(INVALID_PARAMS, "cache_put_semantic: missing 'model'")
1238                })?
1239                .to_string();
1240            (vec, model)
1241        }
1242        (None, Some(t)) => {
1243            let text = t.as_str().ok_or_else(|| {
1244                RpcError::new(
1245                    INVALID_PARAMS,
1246                    "cache_put_semantic: 'text' must be a string",
1247                )
1248            })?;
1249            let vec = embed_text_via_server(db, "cache_put_semantic", text)?;
1250            // Allow explicit model override; else derive from embedder.
1251            let model = match args.get("model").and_then(|v| v.as_str()) {
1252                Some(m) => m.to_string(),
1253                None => {
1254                    db.embedder()
1255                        .expect("embed_text_via_server would have errored otherwise")
1256                        .identity()
1257                        .name
1258                }
1259            };
1260            (vec, model)
1261        }
1262        (None, None) => {
1263            return Err(RpcError::new(
1264                INVALID_PARAMS,
1265                "cache_put_semantic: missing 'vector' or 'text'",
1266            ))
1267        }
1268    };
1269    let content_type = args
1270        .get("content_type")
1271        .and_then(|v| v.as_str())
1272        .unwrap_or("text")
1273        .to_string();
1274    let ttl_secs = args.get("ttl_secs").and_then(|v| v.as_u64());
1275    let opts = SemanticCachePutOpts {
1276        content_type,
1277        ttl_secs,
1278        cost_hint_ms: None,
1279        model,
1280    };
1281    db.cache_put_semantic(ns, key, vector, value.as_bytes(), opts)
1282        .map_err(mnemo_err)?;
1283    db.flush().map_err(mnemo_err)?;
1284    Ok(json!({ "cached": true, "namespace": ns, "key": key, "bytes": value.len() }).to_string())
1285}
1286
1287fn tool_cache_get_semantic(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1288    let ns = args
1289        .get("namespace")
1290        .and_then(|v| v.as_str())
1291        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_get_semantic: missing 'namespace'"))?;
1292    // When `text` is used, derive the model name from the server's
1293    // registered embedder (identity). Explicit `model` wins otherwise.
1294    let (query, model_owned) = match (args.get("query"), args.get("text")) {
1295        (Some(_), Some(_)) => {
1296            return Err(RpcError::new(
1297                INVALID_PARAMS,
1298                "cache_get_semantic: pass EITHER 'query' or 'text', not both",
1299            ));
1300        }
1301        (Some(q), None) => {
1302            let vec = q
1303                .as_array()
1304                .ok_or_else(|| {
1305                    RpcError::new(
1306                        INVALID_PARAMS,
1307                        "cache_get_semantic: 'query' must be a JSON array",
1308                    )
1309                })?
1310                .iter()
1311                .map(|v| v.as_f64().map(|f| f as f32))
1312                .collect::<Option<Vec<f32>>>()
1313                .ok_or_else(|| {
1314                    RpcError::new(
1315                        INVALID_PARAMS,
1316                        "cache_get_semantic: 'query' must be an array of numbers",
1317                    )
1318                })?;
1319            let model = args
1320                .get("model")
1321                .and_then(|v| v.as_str())
1322                .ok_or_else(|| {
1323                    RpcError::new(INVALID_PARAMS, "cache_get_semantic: missing 'model'")
1324                })?
1325                .to_string();
1326            (vec, model)
1327        }
1328        (None, Some(t)) => {
1329            let text = t.as_str().ok_or_else(|| {
1330                RpcError::new(
1331                    INVALID_PARAMS,
1332                    "cache_get_semantic: 'text' must be a string",
1333                )
1334            })?;
1335            let vec = embed_text_via_server(db, "cache_get_semantic", text)?;
1336            let model = match args.get("model").and_then(|v| v.as_str()) {
1337                Some(m) => m.to_string(),
1338                None => {
1339                    db.embedder()
1340                        .expect("embed_text_via_server would have errored otherwise")
1341                        .identity()
1342                        .name
1343                }
1344            };
1345            (vec, model)
1346        }
1347        (None, None) => {
1348            return Err(RpcError::new(
1349                INVALID_PARAMS,
1350                "cache_get_semantic: missing 'query' or 'text'",
1351            ))
1352        }
1353    };
1354    let model = model_owned.as_str();
1355    let threshold = args
1356        .get("threshold")
1357        .and_then(|v| v.as_f64())
1358        .map(|f| f as f32)
1359        .unwrap_or(DEFAULT_SEMANTIC_THRESHOLD);
1360    match db
1361        .cache_get_semantic(ns, &query, threshold, model)
1362        .map_err(mnemo_err)?
1363    {
1364        Some((hit, similarity)) => Ok(json!({
1365            "hit": true,
1366            "similarity": similarity,
1367            "value": String::from_utf8_lossy(&hit.value),
1368            "content_type": hit.content_type,
1369            "created_at": hit.created_at,
1370            "accessed_at": hit.accessed_at,
1371            "access_count": hit.access_count,
1372        })
1373        .to_string()),
1374        None => Ok(json!({ "hit": false }).to_string()),
1375    }
1376}
1377
1378/// Serialize a `Memory` into the JSON shape MCP tool responses use.
1379/// Serde on `Memory` produces the canonical shape; we mirror what the
1380/// CLI's `memory_to_json` does but without the CLI-only vector-stripping
1381/// switch (that lives in the individual tools that care).
1382fn memory_to_json(m: &Memory) -> Value {
1383    serde_json::to_value(m).unwrap_or_else(|_| {
1384        let mut map = Map::new();
1385        map.insert("id".into(), json!(m.id.to_string()));
1386        Value::Object(map)
1387    })
1388}