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": "essence",
362                "description": "Phase 12.7b: edit or render the essence layer of a thread (or the project layer). ONE tool for every op — set `op` to `add` / `append` / `update` / `resolve` / `remove` / `render`. **Call it when the goal, a decision, an open problem, the next step or the user's way of working changes, and append a timeline entry at each milestone.** Section items (`intent`, `conventions`, `decisions`, `state`, `open`, `next`, `lessons`) are ≤ 280 bytes and have an `open → resolved` lifecycle; `append` adds an append-only `timeline` entry (≤ 600 bytes). Thread-scoped (`thread`) or project-scoped (`project: true`). `render` returns the deterministic markdown view, budgeted at `budget` bytes (default 6144).",
363                "inputSchema": {
364                    "type": "object",
365                    "required": ["op"],
366                    "properties": {
367                        "op": { "type": "string", "enum": ["add", "append", "update", "resolve", "remove", "render"] },
368                        "thread": { "type": "string", "description": "Thread id. Omit with `project: true` for the project layer." },
369                        "project": { "type": "boolean", "default": false, "description": "Project-scoped item (shows in every thread's render). Mutually exclusive with `thread`." },
370                        "section": { "type": "string", "enum": ["intent", "conventions", "decisions", "state", "open", "next", "lessons"], "description": "Required for `op: add`. Timeline uses `op: append`, not add." },
371                        "id": { "type": "string", "description": "ULID of the section item. Required for update / resolve / remove." },
372                        "text": { "type": "string", "description": "Required for add / append / update." },
373                        "refs": { "type": "array", "items": { "type": "string" }, "description": "Optional free-form reference strings (turn ids, memory ids, commits, paths)." },
374                        "budget": { "type": "integer", "default": 6144, "description": "Byte budget for `op: render`." }
375                    },
376                    "additionalProperties": false
377                }
378            },
379            {
380                "name": "thread_start",
381                "description": "Phase 12.7c: start a new thread that continues from a previous one. `essence {op: render, thread: <new>}` then pulls the prior thread's open items, `next` and last-N timeline entries ahead of the new thread's own essence — the 'new thread, same experience' path. Returns `{id}` (the link-record ULID).",
382                "inputSchema": {
383                    "type": "object",
384                    "required": ["new", "from"],
385                    "properties": {
386                        "new": { "type": "string", "description": "New thread id." },
387                        "from": { "type": "string", "description": "Previous thread id to continue from." }
388                    },
389                    "additionalProperties": false
390                }
391            },
392            {
393                "name": "commit_deltas",
394                "description": "Phase 12.7c: atomic batch write — facts (12.1), essence ops (12.7b), and thread archive turns (12.7a) in one call, with validate-everything-before-staging and a single `flush()` on success. Use when a session end wants to persist a burst of updates without making N round trips. `turn_policy` defaults to `archive`; `expire_after` takes a seconds TTL; `drop` discards turns. Returns a counts summary.",
395                "inputSchema": {
396                    "type": "object",
397                    "properties": {
398                        "facts": {
399                            "type": "array",
400                            "items": {
401                                "type": "object",
402                                "required": ["content", "memory_type"],
403                                "properties": {
404                                    "content": { "type": "string" },
405                                    "memory_type": { "type": "string", "enum": ["episodic", "semantic", "procedural", "working"] },
406                                    "vector": { "type": "array", "items": { "type": "number" } },
407                                    "importance": { "type": "number", "minimum": 0, "maximum": 1 },
408                                    "agent_id": { "type": "string" },
409                                    "session_id": { "type": "string" },
410                                    "shared": { "type": "boolean" },
411                                    "metadata": { "type": "object" },
412                                    "key": { "type": "string" }
413                                }
414                            }
415                        },
416                        "essence_ops": {
417                            "type": "array",
418                            "items": {
419                                "type": "object",
420                                "required": ["kind"],
421                                "properties": {
422                                    "kind": { "type": "string", "enum": ["add", "append_timeline", "update", "resolve", "remove"] },
423                                    "thread": { "type": "string" },
424                                    "project": { "type": "boolean" },
425                                    "section": { "type": "string" },
426                                    "id": { "type": "string" },
427                                    "text": { "type": "string" },
428                                    "refs": { "type": "array", "items": { "type": "string" } }
429                                }
430                            }
431                        },
432                        "turns": {
433                            "type": "array",
434                            "items": {
435                                "type": "object",
436                                "required": ["turn_id", "role", "content"],
437                                "properties": {
438                                    "turn_id": { "type": "string" },
439                                    "role": { "type": "string" },
440                                    "content": { "type": "string" },
441                                    "timestamp": { "type": "integer" }
442                                }
443                            }
444                        },
445                        "thread_id": { "type": "string", "description": "Required when `turns` is non-empty." },
446                        "turn_policy": { "type": "string", "enum": ["archive", "expire_after", "drop"], "default": "archive" },
447                        "turn_policy_ttl": { "type": "integer", "description": "TTL in seconds; required when `turn_policy = 'expire_after'`." }
448                    },
449                    "additionalProperties": false
450                }
451            },
452            {
453                "name": "thread_ingest",
454                "description": "Phase 12.7a: ingest a transcript file into a thread's archive layer. Idempotent per `turn_id` — re-running on a longer transcript appends only the new turns. Requires an embedder on the server (`--embed-cmd` at serve start) because every turn is embedded. Returns `{added_records, skipped_existing_turns, chunked_turns, truncated_turns}`.",
455                "inputSchema": {
456                    "type": "object",
457                    "required": ["thread", "transcript_path"],
458                    "properties": {
459                        "thread": { "type": "string", "description": "Thread id to append under." },
460                        "transcript_path": { "type": "string", "description": "Local path to the transcript file." },
461                        "format": { "type": "string", "enum": ["jsonl", "claude-code-jsonl"], "default": "jsonl" }
462                    },
463                    "additionalProperties": false
464                }
465            },
466            {
467                "name": "history",
468                "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.",
469                "inputSchema": {
470                    "type": "object",
471                    "required": ["key"],
472                    "properties": {
473                        "key": { "type": "string" },
474                        "agent": { "type": "string", "description": "Query the private slot for this agent. Mutually exclusive with `shared`." },
475                        "shared": { "type": "boolean", "default": false, "description": "Query the shared slot (one per file). Mutually exclusive with `agent`." }
476                    },
477                    "additionalProperties": false
478                }
479            },
480            {
481                "name": "recall",
482                "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).",
483                "inputSchema": {
484                    "type": "object",
485                    "properties": {
486                        "query": { "type": "array", "items": { "type": "number" }, "description": "Explicit query vector; mutually exclusive with `text`." },
487                        "text": { "type": "string", "description": "Query text; embedded by the server's registered embedder. Mutually exclusive with `query`." },
488                        "top_k": { "type": "integer", "default": 10 },
489                        "types": { "type": "array", "items": { "type": "string" } },
490                        "agent": { "type": "string" },
491                        "metric": { "type": "string", "enum": ["cosine", "l2", "dot"], "default": "cosine" },
492                        "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." },
493                        "filter": {
494                            "type": "object",
495                            "description": "Phase 12.6 pre-scoring filter. All conditions AND together. Shape: `{\"session_id\": \"...\", \"metadata\": {key: value | {\"$in\": [...]} | {\"$exists\": true} | {\"$gte\": n, \"$lte\": n}}}`.",
496                            "properties": {
497                                "session_id": { "type": "string" },
498                                "metadata": { "type": "object" }
499                            },
500                            "additionalProperties": false
501                        }
502                    },
503                    "additionalProperties": false
504                }
505            },
506            {
507                "name": "forget",
508                "description": "Soft-delete a memory by ULID. Space is reclaimed on the next `mnemo compact`. Persists to disk before returning.",
509                "inputSchema": {
510                    "type": "object",
511                    "required": ["id"],
512                    "properties": {
513                        "id": { "type": "string", "description": "ULID of the memory to delete" }
514                    },
515                    "additionalProperties": false
516                }
517            },
518            {
519                "name": "list",
520                "description": "Return every live memory as JSON. Sorted by creation time, oldest first. Vectors are omitted by default to keep payloads small.",
521                "inputSchema": {
522                    "type": "object",
523                    "properties": {
524                        "include_vectors": { "type": "boolean", "default": false }
525                    },
526                    "additionalProperties": false
527                }
528            },
529            {
530                "name": "snapshot_list",
531                "description": "List every restorable snapshot (one per committed flush). Each entry gives the transaction ID, creation timestamp, and memory count.",
532                "inputSchema": {
533                    "type": "object",
534                    "properties": {},
535                    "additionalProperties": false
536                }
537            },
538            {
539                "name": "stats",
540                "description": "Summary statistics: memory count, deleted count, agents present, snapshot count, page cache occupancy.",
541                "inputSchema": {
542                    "type": "object",
543                    "properties": {},
544                    "additionalProperties": false
545                }
546            },
547            {
548                "name": "cache_put",
549                "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.",
550                "inputSchema": {
551                    "type": "object",
552                    "required": ["namespace", "key", "value"],
553                    "properties": {
554                        "namespace": { "type": "string" },
555                        "key": { "type": "string" },
556                        "value": { "type": "string" },
557                        "content_type": { "type": "string", "default": "text" },
558                        "ttl_secs": { "type": "integer", "minimum": 0 }
559                    },
560                    "additionalProperties": false
561                }
562            },
563            {
564                "name": "cache_get",
565                "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.",
566                "inputSchema": {
567                    "type": "object",
568                    "required": ["namespace", "key"],
569                    "properties": {
570                        "namespace": { "type": "string" },
571                        "key": { "type": "string" }
572                    },
573                    "additionalProperties": false
574                }
575            },
576            {
577                "name": "cache_stats",
578                "description": "Cache statistics for a namespace (omit for the whole cache). Returns entries, bytes, hits, misses, hit_rate, evictions.",
579                "inputSchema": {
580                    "type": "object",
581                    "properties": {
582                        "namespace": { "type": "string" }
583                    },
584                    "additionalProperties": false
585                }
586            },
587            {
588                "name": "cache_put_semantic",
589                "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).",
590                "inputSchema": {
591                    "type": "object",
592                    "required": ["namespace", "key", "value"],
593                    "properties": {
594                        "namespace": { "type": "string" },
595                        "key": { "type": "string" },
596                        "vector": { "type": "array", "items": { "type": "number" }, "description": "Explicit embedding; mutually exclusive with `text`. Requires `model`." },
597                        "text": { "type": "string", "description": "Content to embed via the server's registered embedder; mutually exclusive with `vector`." },
598                        "value": { "type": "string" },
599                        "model": { "type": "string", "description": "Required with `vector`; optional with `text` (defaults to the embedder's identity name)." },
600                        "content_type": { "type": "string", "default": "text" },
601                        "ttl_secs": { "type": "integer", "minimum": 0 }
602                    },
603                    "additionalProperties": false
604                }
605            },
606            {
607                "name": "cache_get_semantic",
608                "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.",
609                "inputSchema": {
610                    "type": "object",
611                    "required": ["namespace"],
612                    "properties": {
613                        "namespace": { "type": "string" },
614                        "query": { "type": "array", "items": { "type": "number" }, "description": "Explicit query vector; mutually exclusive with `text`. Requires `model`." },
615                        "text": { "type": "string", "description": "Query text; embedded by the server. Mutually exclusive with `query`." },
616                        "model": { "type": "string", "description": "Required with `query`; optional with `text`." },
617                        "threshold": { "type": "number", "minimum": -1.0, "maximum": 1.0 }
618                    },
619                    "additionalProperties": false
620                }
621            }
622        ]
623    })
624}
625
626// --- Tool dispatch -------------------------------------------------------
627
628/// The MCP `tools/call` method: dispatches on the caller-supplied `name`
629/// and returns the tool's `content` array wrapped in an MCP result.
630fn tools_call(db: &mut Mnemo, params: &Value) -> std::result::Result<Value, RpcError> {
631    let name = params
632        .get("name")
633        .and_then(|v| v.as_str())
634        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "missing tool name"))?;
635    let empty = json!({});
636    let args = params.get("arguments").unwrap_or(&empty);
637
638    let text = match name {
639        "about" => tool_about(db)?,
640        "remember" => tool_remember(db, args)?,
641        "update" => tool_update(db, args)?,
642        "history" => tool_history(db, args)?,
643        "thread_ingest" => tool_thread_ingest(db, args)?,
644        "thread_start" => tool_thread_start(db, args)?,
645        "commit_deltas" => tool_commit_deltas(db, args)?,
646        "essence" => tool_essence(db, args)?,
647        "recall" => tool_recall(db, args)?,
648        "forget" => tool_forget(db, args)?,
649        "list" => tool_list(db, args)?,
650        "snapshot_list" => tool_snapshot_list(db)?,
651        "stats" => tool_stats(db)?,
652        "cache_put" => tool_cache_put(db, args)?,
653        "cache_get" => tool_cache_get(db, args)?,
654        "cache_stats" => tool_cache_stats(db, args)?,
655        "cache_put_semantic" => tool_cache_put_semantic(db, args)?,
656        "cache_get_semantic" => tool_cache_get_semantic(db, args)?,
657        other => {
658            return Err(RpcError::new(
659                METHOD_NOT_FOUND,
660                format!("unknown tool '{other}'"),
661            ))
662        }
663    };
664
665    Ok(json!({
666        "content": [ { "type": "text", "text": text } ],
667        "isError": false,
668    }))
669}
670
671/// Convert a [`MnemoError`] to an [`RpcError`]. Every error becomes an
672/// `INTERNAL_ERROR` with the human-readable message — MCP clients
673/// display `error.message` verbatim to the user.
674/// Resolve a text argument into a vector via the server's registered
675/// embedder (Phase 3 PR 10b). Called by the four MCP tools that
676/// accept `text` as an alternative to the explicit vector: `remember`,
677/// `recall`, `cache_put_semantic`, `cache_get_semantic`. If no
678/// embedder was configured at server start (`mnemo serve --mcp
679/// --embed-cmd ...`) the error message tells the caller how to turn
680/// it on.
681fn embed_text_via_server(
682    db: &mut Mnemo,
683    tool: &str,
684    text: &str,
685) -> std::result::Result<Vec<f32>, RpcError> {
686    let embedder = db.embedder().ok_or_else(|| {
687        RpcError::new(
688            INVALID_PARAMS,
689            format!(
690                "{tool}: 'text' requires an embedder on the server. \
691                 Start with: mnemo serve --mcp <file> --embed-cmd '<cmd>' --embed-name <name>"
692            ),
693        )
694    })?;
695    let mut vs = embedder.embed(&[text]).map_err(mnemo_err)?;
696    vs.pop().ok_or_else(|| {
697        RpcError::new(
698            INTERNAL_ERROR,
699            format!("{tool}: embedder returned no vector for the input text"),
700        )
701    })
702}
703
704fn mnemo_err(e: MnemoError) -> RpcError {
705    RpcError::new(INTERNAL_ERROR, e.to_string())
706}
707
708fn tool_about(db: &mut Mnemo) -> std::result::Result<String, RpcError> {
709    let mems = db.about().map_err(mnemo_err)?;
710    let arr: Vec<Value> = mems.iter().map(memory_to_json).collect();
711    Ok(serde_json::to_string_pretty(&arr).unwrap_or_else(|_| "[]".into()))
712}
713
714fn tool_remember(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
715    let content = args
716        .get("content")
717        .and_then(|v| v.as_str())
718        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "remember: missing 'content'"))?;
719    let mtype_str = args
720        .get("memory_type")
721        .and_then(|v| v.as_str())
722        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "remember: missing 'memory_type'"))?;
723    let mtype = MemoryType::parse(mtype_str).ok_or_else(|| {
724        RpcError::new(
725            INVALID_PARAMS,
726            format!("remember: unknown memory_type '{mtype_str}'"),
727        )
728    })?;
729    // Accept either `vector` (explicit) or `text` (embedded via the
730    // server's registered embedder, Phase 3). Exactly one required.
731    let vector = match (args.get("vector"), args.get("text")) {
732        (Some(_), Some(_)) => {
733            return Err(RpcError::new(
734                INVALID_PARAMS,
735                "remember: pass EITHER 'vector' or 'text', not both",
736            ));
737        }
738        (Some(v), None) => v
739            .as_array()
740            .ok_or_else(|| {
741                RpcError::new(INVALID_PARAMS, "remember: 'vector' must be a JSON array")
742            })?
743            .iter()
744            .map(|v| v.as_f64().map(|f| f as f32))
745            .collect::<Option<Vec<f32>>>()
746            .ok_or_else(|| {
747                RpcError::new(
748                    INVALID_PARAMS,
749                    "remember: 'vector' must be an array of numbers",
750                )
751            })?,
752        (None, Some(t)) => {
753            let text = t.as_str().ok_or_else(|| {
754                RpcError::new(INVALID_PARAMS, "remember: 'text' must be a string")
755            })?;
756            embed_text_via_server(db, "remember", text)?
757        }
758        (None, None) => {
759            return Err(RpcError::new(
760                INVALID_PARAMS,
761                "remember: missing 'vector' or 'text'",
762            ))
763        }
764    };
765
766    let mut mem = Memory::new(content, mtype, vector);
767    if let Some(agent) = args.get("agent_id").and_then(|v| v.as_str()) {
768        mem = mem.with_agent(agent);
769    }
770    if let Some(sess) = args.get("session_id").and_then(|v| v.as_str()) {
771        mem = mem.with_session(sess);
772    }
773    if let Some(imp) = args.get("importance").and_then(|v| v.as_f64()) {
774        mem = mem.with_importance(imp as f32);
775    }
776    if let Some(ttl) = args.get("ttl_secs").and_then(|v| v.as_i64()) {
777        mem = mem.with_ttl(ttl);
778    }
779    if let Some(shared) = args.get("shared").and_then(|v| v.as_bool()) {
780        if shared {
781            mem = mem.with_scope(Scope::Shared);
782        }
783    }
784    if let Some(meta) = args.get("metadata").and_then(|v| v.as_object()) {
785        for (k, v) in meta {
786            mem.metadata.insert(k.clone(), v.clone());
787        }
788    }
789
790    // Phase 12.1: when `key` is set, take the keyed-upsert path
791    // (supersede / no-op / add); otherwise keep the pre-12.1
792    // single-id return shape (now carrying `outcome: "inserted"` so
793    // callers can distinguish it from the keyed outcomes).
794    match args.get("key").and_then(|v| v.as_str()) {
795        None => {
796            let id = db.remember(mem).map_err(mnemo_err)?;
797            db.flush().map_err(mnemo_err)?;
798            Ok(json!({ "id": id.to_string(), "outcome": "inserted" }).to_string())
799        }
800        Some(k) => {
801            let (id, outcome) = db.remember_keyed(mem, k).map_err(mnemo_err)?;
802            db.flush().map_err(mnemo_err)?;
803            let outcome_str = match outcome {
804                crate::WriteOutcome::Added => "added",
805                crate::WriteOutcome::Superseded => "superseded",
806                crate::WriteOutcome::Unchanged => "unchanged",
807                crate::WriteOutcome::Updated => "superseded",
808            };
809            Ok(json!({ "id": id.to_string(), "outcome": outcome_str }).to_string())
810        }
811    }
812}
813
814fn tool_update(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
815    use crate::Ulid;
816    use crate::{MemoryPatch, WriteOutcome};
817    let id_str = args
818        .get("id")
819        .and_then(|v| v.as_str())
820        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "update: missing 'id'"))?;
821    let id = Ulid::from_string(id_str)
822        .map_err(|_| RpcError::new(INVALID_PARAMS, format!("update: invalid ULID '{id_str}'")))?;
823    // Mutually exclusive: `text` (server re-embeds) vs `content`+`vector`
824    // (explicit). Either path produces `(new_content, new_vector)` for
825    // the engine; both unset means no text/vector change.
826    let has_text = args.get("text").is_some();
827    let has_content = args.get("content").is_some();
828    let has_vector = args.get("vector").is_some();
829    if has_text && (has_content || has_vector) {
830        return Err(RpcError::new(
831            INVALID_PARAMS,
832            "update: pass EITHER 'text' or 'content'+'vector', not both",
833        ));
834    }
835    let mut patch = MemoryPatch::default();
836    if has_content {
837        let c = args
838            .get("content")
839            .and_then(|v| v.as_str())
840            .ok_or_else(|| RpcError::new(INVALID_PARAMS, "update: 'content' must be a string"))?;
841        patch.content = Some(c.to_string());
842    }
843    if has_vector {
844        let v = args
845            .get("vector")
846            .and_then(|v| v.as_array())
847            .ok_or_else(|| RpcError::new(INVALID_PARAMS, "update: 'vector' must be a JSON array"))?
848            .iter()
849            .map(|v| v.as_f64().map(|f| f as f32))
850            .collect::<Option<Vec<f32>>>()
851            .ok_or_else(|| {
852                RpcError::new(
853                    INVALID_PARAMS,
854                    "update: 'vector' must be an array of numbers",
855                )
856            })?;
857        patch.vector = Some(v);
858    }
859    if let Some(imp) = args.get("importance").and_then(|v| v.as_f64()) {
860        patch.importance = Some(imp as f32);
861    }
862    if let Some(meta) = args.get("metadata").and_then(|v| v.as_object()) {
863        patch.metadata = Some(meta.clone());
864    }
865    let clear_ttl = args
866        .get("clear_ttl")
867        .and_then(|v| v.as_bool())
868        .unwrap_or(false);
869    if clear_ttl {
870        patch.ttl_secs = Some(None);
871    } else if let Some(ttl) = args.get("ttl_secs").and_then(|v| v.as_i64()) {
872        patch.ttl_secs = Some(Some(ttl));
873    }
874
875    let outcome = if has_text {
876        let t = args
877            .get("text")
878            .and_then(|v| v.as_str())
879            .ok_or_else(|| RpcError::new(INVALID_PARAMS, "update: 'text' must be a string"))?;
880        db.update_text(&id, t, patch).map_err(mnemo_err)?
881    } else {
882        db.update(&id, patch).map_err(mnemo_err)?
883    };
884    db.flush().map_err(mnemo_err)?;
885    let outcome_str = match outcome {
886        WriteOutcome::Updated => "updated",
887        WriteOutcome::Unchanged => "unchanged",
888        WriteOutcome::Added => "updated",
889        WriteOutcome::Superseded => "updated",
890    };
891    Ok(json!({ "id": id.to_string(), "outcome": outcome_str }).to_string())
892}
893
894fn tool_thread_start(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
895    let new = args
896        .get("new")
897        .and_then(|v| v.as_str())
898        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "thread_start: missing 'new'"))?;
899    let from = args
900        .get("from")
901        .and_then(|v| v.as_str())
902        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "thread_start: missing 'from'"))?;
903    let id = db.thread_start(new, from).map_err(mnemo_err)?;
904    db.flush().map_err(mnemo_err)?;
905    Ok(json!({ "id": id.to_string() }).to_string())
906}
907
908fn tool_commit_deltas(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
909    use crate::{
910        CommitBatch, EssenceOp, EssenceOpKind, EssenceScope, EssenceSection, KeyedMemory,
911        ThreadTurn, TurnPolicy, Ulid,
912    };
913    let mut batch = CommitBatch::default();
914
915    // facts
916    if let Some(arr) = args.get("facts").and_then(|v| v.as_array()) {
917        for (i, f) in arr.iter().enumerate() {
918            let obj = f.as_object().ok_or_else(|| {
919                RpcError::new(
920                    INVALID_PARAMS,
921                    format!("commit_deltas: facts[{i}] must be an object"),
922                )
923            })?;
924            let content = obj.get("content").and_then(|v| v.as_str()).ok_or_else(|| {
925                RpcError::new(
926                    INVALID_PARAMS,
927                    format!("commit_deltas: facts[{i}] missing 'content'"),
928                )
929            })?;
930            let mtype_str = obj
931                .get("memory_type")
932                .and_then(|v| v.as_str())
933                .ok_or_else(|| {
934                    RpcError::new(
935                        INVALID_PARAMS,
936                        format!("commit_deltas: facts[{i}] missing 'memory_type'"),
937                    )
938                })?;
939            let mtype = MemoryType::parse(mtype_str).ok_or_else(|| {
940                RpcError::new(
941                    INVALID_PARAMS,
942                    format!("commit_deltas: facts[{i}] unknown memory_type '{mtype_str}'"),
943                )
944            })?;
945            let vector = obj
946                .get("vector")
947                .and_then(|v| v.as_array())
948                .ok_or_else(|| {
949                    RpcError::new(
950                        INVALID_PARAMS,
951                        format!("commit_deltas: facts[{i}] missing 'vector' (MCP carries no server-side embedder for batch)"),
952                    )
953                })?
954                .iter()
955                .map(|v| v.as_f64().map(|f| f as f32))
956                .collect::<Option<Vec<f32>>>()
957                .ok_or_else(|| {
958                    RpcError::new(
959                        INVALID_PARAMS,
960                        format!("commit_deltas: facts[{i}] vector must be numbers"),
961                    )
962                })?;
963            let mut mem = Memory::new(content, mtype, vector);
964            if let Some(a) = obj.get("agent_id").and_then(|v| v.as_str()) {
965                mem = mem.with_agent(a);
966            }
967            if let Some(s) = obj.get("session_id").and_then(|v| v.as_str()) {
968                mem = mem.with_session(s);
969            }
970            if let Some(imp) = obj.get("importance").and_then(|v| v.as_f64()) {
971                mem = mem.with_importance(imp as f32);
972            }
973            if let Some(shared) = obj.get("shared").and_then(|v| v.as_bool()) {
974                if shared {
975                    mem = mem.with_scope(Scope::Shared);
976                }
977            }
978            if let Some(meta) = obj.get("metadata").and_then(|v| v.as_object()) {
979                for (k, v) in meta {
980                    mem.metadata.insert(k.clone(), v.clone());
981                }
982            }
983            let key = obj.get("key").and_then(|v| v.as_str()).map(String::from);
984            batch.facts.push(KeyedMemory { memory: mem, key });
985        }
986    }
987
988    // essence_ops
989    if let Some(arr) = args.get("essence_ops").and_then(|v| v.as_array()) {
990        for (i, o) in arr.iter().enumerate() {
991            let obj = o.as_object().ok_or_else(|| {
992                RpcError::new(
993                    INVALID_PARAMS,
994                    format!("commit_deltas: essence_ops[{i}] must be an object"),
995                )
996            })?;
997            let kind_str = obj.get("kind").and_then(|v| v.as_str()).ok_or_else(|| {
998                RpcError::new(
999                    INVALID_PARAMS,
1000                    format!("commit_deltas: essence_ops[{i}] missing 'kind'"),
1001                )
1002            })?;
1003            let kind = match kind_str {
1004                "add" => EssenceOpKind::Add,
1005                "append_timeline" => EssenceOpKind::AppendTimeline,
1006                "update" => EssenceOpKind::Update,
1007                "resolve" => EssenceOpKind::Resolve,
1008                "remove" => EssenceOpKind::Remove,
1009                other => {
1010                    return Err(RpcError::new(
1011                        INVALID_PARAMS,
1012                        format!("commit_deltas: essence_ops[{i}] unknown kind '{other}'"),
1013                    ));
1014                }
1015            };
1016            let thread = obj.get("thread").and_then(|v| v.as_str()).map(String::from);
1017            let project = obj
1018                .get("project")
1019                .and_then(|v| v.as_bool())
1020                .unwrap_or(false);
1021            let scope = match (thread, project) {
1022                (Some(t), false) => Some(EssenceScope::Thread(t)),
1023                (None, true) => Some(EssenceScope::Project),
1024                (None, false) => None,
1025                (Some(_), true) => {
1026                    return Err(RpcError::new(
1027                        INVALID_PARAMS,
1028                        format!(
1029                            "commit_deltas: essence_ops[{i}] can't set both 'thread' and 'project'"
1030                        ),
1031                    ));
1032                }
1033            };
1034            let section = obj
1035                .get("section")
1036                .and_then(|v| v.as_str())
1037                .and_then(EssenceSection::parse);
1038            let id = obj
1039                .get("id")
1040                .and_then(|v| v.as_str())
1041                .map(Ulid::from_string)
1042                .transpose()
1043                .map_err(|_| {
1044                    RpcError::new(
1045                        INVALID_PARAMS,
1046                        format!("commit_deltas: essence_ops[{i}] 'id' is not a valid ULID"),
1047                    )
1048                })?;
1049            let text = obj.get("text").and_then(|v| v.as_str()).map(String::from);
1050            let refs = obj.get("refs").and_then(|v| v.as_array()).map(|a| {
1051                a.iter()
1052                    .filter_map(|x| x.as_str().map(String::from))
1053                    .collect()
1054            });
1055            batch.essence_ops.push(EssenceOp {
1056                kind,
1057                scope,
1058                section,
1059                id,
1060                text,
1061                refs,
1062            });
1063        }
1064    }
1065
1066    // turns
1067    if let Some(arr) = args.get("turns").and_then(|v| v.as_array()) {
1068        for (i, t) in arr.iter().enumerate() {
1069            let obj = t.as_object().ok_or_else(|| {
1070                RpcError::new(
1071                    INVALID_PARAMS,
1072                    format!("commit_deltas: turns[{i}] must be an object"),
1073                )
1074            })?;
1075            let turn_id = obj
1076                .get("turn_id")
1077                .and_then(|v| v.as_str())
1078                .ok_or_else(|| {
1079                    RpcError::new(
1080                        INVALID_PARAMS,
1081                        format!("commit_deltas: turns[{i}] missing 'turn_id'"),
1082                    )
1083                })?
1084                .to_string();
1085            let role = obj
1086                .get("role")
1087                .and_then(|v| v.as_str())
1088                .ok_or_else(|| {
1089                    RpcError::new(
1090                        INVALID_PARAMS,
1091                        format!("commit_deltas: turns[{i}] missing 'role'"),
1092                    )
1093                })?
1094                .to_string();
1095            let content = obj
1096                .get("content")
1097                .and_then(|v| v.as_str())
1098                .ok_or_else(|| {
1099                    RpcError::new(
1100                        INVALID_PARAMS,
1101                        format!("commit_deltas: turns[{i}] missing 'content'"),
1102                    )
1103                })?
1104                .to_string();
1105            let timestamp = obj.get("timestamp").and_then(|v| v.as_i64());
1106            batch.turns.push(ThreadTurn {
1107                turn_id,
1108                role,
1109                content,
1110                timestamp,
1111            });
1112        }
1113    }
1114    batch.thread_id = args
1115        .get("thread_id")
1116        .and_then(|v| v.as_str())
1117        .map(String::from);
1118    let policy_str = args
1119        .get("turn_policy")
1120        .and_then(|v| v.as_str())
1121        .unwrap_or("archive");
1122    batch.turn_policy = match policy_str {
1123        "archive" => TurnPolicy::Archive,
1124        "drop" => TurnPolicy::Drop,
1125        "expire_after" => {
1126            let ttl = args
1127                .get("turn_policy_ttl")
1128                .and_then(|v| v.as_i64())
1129                .ok_or_else(|| {
1130                    RpcError::new(
1131                        INVALID_PARAMS,
1132                        "commit_deltas: turn_policy='expire_after' requires 'turn_policy_ttl' (seconds)",
1133                    )
1134                })?;
1135            TurnPolicy::ExpireAfter(ttl)
1136        }
1137        other => {
1138            return Err(RpcError::new(
1139                INVALID_PARAMS,
1140                format!("commit_deltas: unknown turn_policy '{other}'"),
1141            ));
1142        }
1143    };
1144
1145    let report = db.commit_deltas(batch).map_err(mnemo_err)?;
1146    Ok(json!({
1147        "facts_added": report.facts_added,
1148        "facts_superseded": report.facts_superseded,
1149        "facts_unchanged": report.facts_unchanged,
1150        "essence_ops_applied": report.essence_ops_applied,
1151        "essence_ops_unchanged": report.essence_ops_unchanged,
1152        "turns_added": report.turns_added,
1153        "turns_skipped": report.turns_skipped,
1154        "turns_truncated": report.turns_truncated,
1155        "turns_chunked": report.turns_chunked,
1156    })
1157    .to_string())
1158}
1159
1160fn tool_essence(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1161    use crate::{EssenceScope, EssenceSection, Ulid, WriteOutcome};
1162    let op = args
1163        .get("op")
1164        .and_then(|v| v.as_str())
1165        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "essence: missing 'op'"))?;
1166    let thread = args
1167        .get("thread")
1168        .and_then(|v| v.as_str())
1169        .map(String::from);
1170    let project = args
1171        .get("project")
1172        .and_then(|v| v.as_bool())
1173        .unwrap_or(false);
1174    let scope_from = |t: Option<String>, p: bool| -> std::result::Result<EssenceScope, RpcError> {
1175        match (t, p) {
1176            (Some(s), false) => Ok(EssenceScope::Thread(s)),
1177            (None, true) => Ok(EssenceScope::Project),
1178            (None, false) => Err(RpcError::new(
1179                INVALID_PARAMS,
1180                "essence: pass either `thread` or `project: true`",
1181            )),
1182            (Some(_), true) => Err(RpcError::new(
1183                INVALID_PARAMS,
1184                "essence: `thread` and `project` are mutually exclusive",
1185            )),
1186        }
1187    };
1188    let refs: Option<Vec<String>> = args.get("refs").and_then(|v| v.as_array()).map(|arr| {
1189        arr.iter()
1190            .filter_map(|x| x.as_str().map(String::from))
1191            .collect()
1192    });
1193    let text = args.get("text").and_then(|v| v.as_str()).map(String::from);
1194    let id_str = args.get("id").and_then(|v| v.as_str());
1195    let outcome_to_str = |o: WriteOutcome| -> &'static str {
1196        match o {
1197            WriteOutcome::Updated => "updated",
1198            WriteOutcome::Unchanged => "unchanged",
1199            WriteOutcome::Added => "updated",
1200            WriteOutcome::Superseded => "updated",
1201        }
1202    };
1203    match op {
1204        "add" => {
1205            let section_str = args
1206                .get("section")
1207                .and_then(|v| v.as_str())
1208                .ok_or_else(|| RpcError::new(INVALID_PARAMS, "essence add: missing 'section'"))?;
1209            let section = EssenceSection::parse(section_str).ok_or_else(|| {
1210                RpcError::new(
1211                    INVALID_PARAMS,
1212                    format!("essence add: unknown section '{section_str}'"),
1213                )
1214            })?;
1215            if section == EssenceSection::Timeline {
1216                return Err(RpcError::new(
1217                    INVALID_PARAMS,
1218                    "essence add: timeline uses op: append, not add",
1219                ));
1220            }
1221            let text =
1222                text.ok_or_else(|| RpcError::new(INVALID_PARAMS, "essence add: missing 'text'"))?;
1223            let scope = scope_from(thread, project)?;
1224            let id = db
1225                .essence_add(scope, section, &text, refs)
1226                .map_err(mnemo_err)?;
1227            db.flush().map_err(mnemo_err)?;
1228            Ok(json!({ "id": id.to_string(), "outcome": "added" }).to_string())
1229        }
1230        "append" => {
1231            let text = text
1232                .ok_or_else(|| RpcError::new(INVALID_PARAMS, "essence append: missing 'text'"))?;
1233            let scope = scope_from(thread, project)?;
1234            let id = db
1235                .essence_append_timeline(scope, &text, refs)
1236                .map_err(mnemo_err)?;
1237            db.flush().map_err(mnemo_err)?;
1238            Ok(json!({ "id": id.to_string(), "outcome": "appended" }).to_string())
1239        }
1240        "update" => {
1241            let id = id_str
1242                .ok_or_else(|| RpcError::new(INVALID_PARAMS, "essence update: missing 'id'"))?;
1243            let ulid = Ulid::from_string(id).map_err(|_| {
1244                RpcError::new(
1245                    INVALID_PARAMS,
1246                    format!("essence update: invalid ULID '{id}'"),
1247                )
1248            })?;
1249            let text = text
1250                .ok_or_else(|| RpcError::new(INVALID_PARAMS, "essence update: missing 'text'"))?;
1251            let outcome = db.essence_update(&ulid, &text, refs).map_err(mnemo_err)?;
1252            db.flush().map_err(mnemo_err)?;
1253            Ok(json!({ "id": id, "outcome": outcome_to_str(outcome) }).to_string())
1254        }
1255        "resolve" => {
1256            let id = id_str
1257                .ok_or_else(|| RpcError::new(INVALID_PARAMS, "essence resolve: missing 'id'"))?;
1258            let ulid = Ulid::from_string(id).map_err(|_| {
1259                RpcError::new(
1260                    INVALID_PARAMS,
1261                    format!("essence resolve: invalid ULID '{id}'"),
1262                )
1263            })?;
1264            let outcome = db.essence_resolve(&ulid).map_err(mnemo_err)?;
1265            db.flush().map_err(mnemo_err)?;
1266            let s = match outcome {
1267                WriteOutcome::Updated => "resolved",
1268                WriteOutcome::Unchanged => "already-resolved",
1269                _ => "resolved",
1270            };
1271            Ok(json!({ "id": id, "outcome": s }).to_string())
1272        }
1273        "remove" => {
1274            let id = id_str
1275                .ok_or_else(|| RpcError::new(INVALID_PARAMS, "essence remove: missing 'id'"))?;
1276            let ulid = Ulid::from_string(id).map_err(|_| {
1277                RpcError::new(
1278                    INVALID_PARAMS,
1279                    format!("essence remove: invalid ULID '{id}'"),
1280                )
1281            })?;
1282            db.essence_remove(&ulid).map_err(mnemo_err)?;
1283            db.flush().map_err(mnemo_err)?;
1284            Ok(json!({ "id": id, "outcome": "removed" }).to_string())
1285        }
1286        "render" => {
1287            let budget = args.get("budget").and_then(|v| v.as_u64()).unwrap_or(6144) as usize;
1288            let scope = scope_from(thread, project)?;
1289            let rendered = db.essence(scope, budget).map_err(mnemo_err)?;
1290            Ok(rendered)
1291        }
1292        other => Err(RpcError::new(
1293            INVALID_PARAMS,
1294            format!("essence: unknown op '{other}'"),
1295        )),
1296    }
1297}
1298
1299fn tool_thread_ingest(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1300    let thread = args
1301        .get("thread")
1302        .and_then(|v| v.as_str())
1303        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "thread_ingest: missing 'thread'"))?;
1304    let path = args
1305        .get("transcript_path")
1306        .and_then(|v| v.as_str())
1307        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "thread_ingest: missing 'transcript_path'"))?;
1308    let format_str = args
1309        .get("format")
1310        .and_then(|v| v.as_str())
1311        .unwrap_or("jsonl");
1312    let format = crate::TranscriptFormat::parse(format_str).ok_or_else(|| {
1313        RpcError::new(
1314            INVALID_PARAMS,
1315            format!(
1316                "thread_ingest: unknown format '{format_str}' (want 'jsonl' or 'claude-code-jsonl')"
1317            ),
1318        )
1319    })?;
1320    let file = std::fs::File::open(path).map_err(|e| {
1321        RpcError::new(
1322            INVALID_PARAMS,
1323            format!("thread_ingest: cannot open {path}: {e}"),
1324        )
1325    })?;
1326    let turns = crate::read_transcript(file, format).map_err(mnemo_err)?;
1327    let report = db.ingest_thread(thread, &turns).map_err(mnemo_err)?;
1328    db.flush().map_err(mnemo_err)?;
1329    Ok(json!({
1330        "added_records": report.added_records,
1331        "skipped_existing_turns": report.skipped_existing_turns,
1332        "chunked_turns": report.chunked_turns,
1333        "truncated_turns": report.truncated_turns,
1334    })
1335    .to_string())
1336}
1337
1338fn tool_history(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1339    use crate::KeyScope;
1340    let key = args
1341        .get("key")
1342        .and_then(|v| v.as_str())
1343        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "history: missing 'key'"))?;
1344    let agent = args.get("agent").and_then(|v| v.as_str());
1345    let shared = args
1346        .get("shared")
1347        .and_then(|v| v.as_bool())
1348        .unwrap_or(false);
1349    if shared && agent.is_some() {
1350        return Err(RpcError::new(
1351            INVALID_PARAMS,
1352            "history: 'agent' and 'shared' are mutually exclusive",
1353        ));
1354    }
1355    // v0.8.1 bugfix (lane A): match keyed `remember` slot resolution.
1356    let scope = if shared {
1357        KeyScope::Shared
1358    } else {
1359        match agent {
1360            Some(a) => KeyScope::Private(a.to_string()),
1361            None => KeyScope::private_default(),
1362        }
1363    };
1364    let chain = db.history_in(key, scope).map_err(mnemo_err)?;
1365    let out: Vec<Value> = chain
1366        .iter()
1367        .enumerate()
1368        .map(|(i, m)| {
1369            let mut v = memory_to_json(m);
1370            if let Some(obj) = v.as_object_mut() {
1371                obj.insert(
1372                    "state".into(),
1373                    Value::String(if i == 0 {
1374                        "live".into()
1375                    } else {
1376                        "superseded".into()
1377                    }),
1378                );
1379            }
1380            v
1381        })
1382        .collect();
1383    Ok(serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".into()))
1384}
1385
1386fn tool_recall(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1387    // Accept either `query` (explicit) or `text` (embedded).
1388    let query = match (args.get("query"), args.get("text")) {
1389        (Some(_), Some(_)) => {
1390            return Err(RpcError::new(
1391                INVALID_PARAMS,
1392                "recall: pass EITHER 'query' or 'text', not both",
1393            ))
1394        }
1395        (Some(v), None) => v
1396            .as_array()
1397            .ok_or_else(|| RpcError::new(INVALID_PARAMS, "recall: 'query' must be a JSON array"))?
1398            .iter()
1399            .map(|v| v.as_f64().map(|f| f as f32))
1400            .collect::<Option<Vec<f32>>>()
1401            .ok_or_else(|| {
1402                RpcError::new(
1403                    INVALID_PARAMS,
1404                    "recall: 'query' must be an array of numbers",
1405                )
1406            })?,
1407        (None, Some(t)) => {
1408            let text = t
1409                .as_str()
1410                .ok_or_else(|| RpcError::new(INVALID_PARAMS, "recall: 'text' must be a string"))?;
1411            embed_text_via_server(db, "recall", text)?
1412        }
1413        (None, None) => {
1414            return Err(RpcError::new(
1415                INVALID_PARAMS,
1416                "recall: missing 'query' or 'text'",
1417            ))
1418        }
1419    };
1420    let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1421    let metric = match args
1422        .get("metric")
1423        .and_then(|v| v.as_str())
1424        .unwrap_or("cosine")
1425    {
1426        "cosine" => Metric::Cosine,
1427        "l2" => Metric::L2,
1428        "dot" => Metric::Dot,
1429        other => {
1430            return Err(RpcError::new(
1431                INVALID_PARAMS,
1432                format!("recall: unknown metric '{other}'"),
1433            ))
1434        }
1435    };
1436    // Scoring v2 (v0.7.0) flipped the default: MCP recalls DO track
1437    // access, so an agent's own retrievals contribute to the usage
1438    // saturation term on later queries. The bump happens in-memory
1439    // (`accessed_at` / `access_count` + `dirty_catalog`), and persists
1440    // through the next flush — recall itself does not fsync, so there
1441    // is no per-tool-call write-amplification cost. Access stats reach
1442    // disk on either (a) the next mutating tool call's exit path (every
1443    // one of `remember` / `forget` / `cache_put_semantic` /
1444    // `cache_get_semantic`-on-miss ends in `db.flush()`), or (b) the
1445    // server's own shutdown flush when stdin closes (see
1446    // `serve_with_streams`), so a read-only session that issued only
1447    // recalls still persists its stats.
1448    let mut req = RecallRequest::new(query)
1449        .top_k(top_k)
1450        .metric(metric)
1451        .track_access(true);
1452    if let Some(types) = args.get("types").and_then(|v| v.as_array()) {
1453        let parsed: Vec<MemoryType> = types
1454            .iter()
1455            .filter_map(|t| t.as_str().and_then(MemoryType::parse))
1456            .collect();
1457        if !parsed.is_empty() {
1458            req = req.types(parsed);
1459        }
1460    }
1461    if let Some(agent) = args.get("agent").and_then(|v| v.as_str()) {
1462        req = req.agent(agent.to_string());
1463    }
1464    // Phase 12.6: filter object. Shape:
1465    //   { "session_id": "...",
1466    //     "metadata": { "k": v,
1467    //                   "k2": {"$in": [v1, v2]},
1468    //                   "k3": {"$exists": true},
1469    //                   "k4": {"$gte": 5, "$lte": 10} } }
1470    if let Some(filter_obj) = args.get("filter").and_then(|v| v.as_object()) {
1471        req = req.filter(parse_mcp_filter(filter_obj)?);
1472    }
1473    // Phase 12.8: `max_bytes` opts into the budgeted response shape.
1474    let has_max_bytes = args.get("max_bytes").is_some();
1475    if let Some(n) = args.get("max_bytes").and_then(|v| v.as_u64()) {
1476        req = req.max_bytes(n as usize);
1477    }
1478
1479    let resp = db.recall_budgeted(&req).map_err(mnemo_err)?;
1480    let out: Vec<Value> = resp
1481        .hits
1482        .into_iter()
1483        .map(|h| {
1484            let mut obj = memory_to_json(&h.memory);
1485            if let Some(map) = obj.as_object_mut() {
1486                map.insert("score".into(), json!(h.score));
1487                map.insert("similarity".into(), json!(h.similarity));
1488            }
1489            obj
1490        })
1491        .collect();
1492    if has_max_bytes {
1493        // Phase 12.8 wrapped shape so callers can tell whether the
1494        // budget cut anything.
1495        let wrapped = json!({
1496            "hits": out,
1497            "truncated": resp.truncated,
1498        });
1499        Ok(serde_json::to_string_pretty(&wrapped).unwrap_or_else(|_| "{}".into()))
1500    } else {
1501        Ok(serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".into()))
1502    }
1503}
1504
1505/// Phase 12.6: parse an MCP `filter` object into a [`crate::Filter`].
1506///
1507/// Values under `metadata.<key>`:
1508/// * plain scalar / array / string → [`crate::FilterTerm::Eq`]
1509/// * `{"$exists": true}` → [`crate::FilterTerm::Exists`]
1510/// * `{"$in": [...]}` → [`crate::FilterTerm::In`]
1511/// * `{"$gte": n, "$lte": n}` → [`crate::FilterTerm::Range`] (either bound
1512///   optional)
1513/// * `{"$exists": false}` is rejected (use the absence of a term instead)
1514fn parse_mcp_filter(
1515    obj: &serde_json::Map<String, Value>,
1516) -> std::result::Result<crate::Filter, RpcError> {
1517    let mut f = crate::Filter::default();
1518    if let Some(sid) = obj.get("session_id").and_then(|v| v.as_str()) {
1519        f = f.with_session(sid.to_string());
1520    }
1521    if let Some(meta) = obj.get("metadata").and_then(|v| v.as_object()) {
1522        for (k, v) in meta {
1523            match v {
1524                // Nested operator object.
1525                Value::Object(ops) => {
1526                    for (op_key, op_val) in ops {
1527                        match op_key.as_str() {
1528                            "$exists" => match op_val.as_bool() {
1529                                Some(true) => {
1530                                    f = f.metadata_exists(k.clone());
1531                                }
1532                                Some(false) => {
1533                                    return Err(RpcError::new(
1534                                        INVALID_PARAMS,
1535                                        format!(
1536                                            "filter.metadata.{k}.$exists=false isn't supported; \
1537                                             drop the filter term instead"
1538                                        ),
1539                                    ));
1540                                }
1541                                None => {
1542                                    return Err(RpcError::new(
1543                                        INVALID_PARAMS,
1544                                        format!("filter.metadata.{k}.$exists must be a boolean"),
1545                                    ));
1546                                }
1547                            },
1548                            "$in" => {
1549                                let arr = op_val.as_array().ok_or_else(|| {
1550                                    RpcError::new(
1551                                        INVALID_PARAMS,
1552                                        format!("filter.metadata.{k}.$in must be an array"),
1553                                    )
1554                                })?;
1555                                f = f.metadata_in(k.clone(), arr.clone());
1556                            }
1557                            "$gte" | "$lte" => {
1558                                let mut min = None;
1559                                let mut max = None;
1560                                for (bound, bound_val) in ops {
1561                                    let n = bound_val.as_f64().ok_or_else(|| {
1562                                        RpcError::new(
1563                                            INVALID_PARAMS,
1564                                            format!("filter.metadata.{k}.{bound} must be a number"),
1565                                        )
1566                                    })?;
1567                                    match bound.as_str() {
1568                                        "$gte" => min = Some(n),
1569                                        "$lte" => max = Some(n),
1570                                        other => {
1571                                            return Err(RpcError::new(
1572                                                INVALID_PARAMS,
1573                                                format!(
1574                                                    "filter.metadata.{k}: unsupported range \
1575                                                     operator '{other}' (want $gte, $lte)"
1576                                                ),
1577                                            ));
1578                                        }
1579                                    }
1580                                }
1581                                f = f.metadata_range(k.clone(), min, max);
1582                                break; // All operator entries consumed in one pass.
1583                            }
1584                            other => {
1585                                return Err(RpcError::new(
1586                                    INVALID_PARAMS,
1587                                    format!(
1588                                        "filter.metadata.{k}: unknown operator '{other}' \
1589                                         (want $exists, $in, $gte, $lte)"
1590                                    ),
1591                                ));
1592                            }
1593                        }
1594                    }
1595                }
1596                // Plain scalar / array / string → equality.
1597                other => {
1598                    f = f.metadata_eq(k.clone(), other.clone());
1599                }
1600            }
1601        }
1602    }
1603    Ok(f)
1604}
1605
1606fn tool_forget(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1607    let id_str = args
1608        .get("id")
1609        .and_then(|v| v.as_str())
1610        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "forget: missing 'id'"))?;
1611    let ulid = crate::Ulid::from_string(id_str)
1612        .map_err(|_| RpcError::new(INVALID_PARAMS, format!("forget: invalid ULID '{id_str}'")))?;
1613    db.delete(&ulid).map_err(mnemo_err)?;
1614    db.flush().map_err(mnemo_err)?;
1615    Ok(json!({ "deleted": id_str }).to_string())
1616}
1617
1618fn tool_list(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1619    let include_vectors = args
1620        .get("include_vectors")
1621        .and_then(|v| v.as_bool())
1622        .unwrap_or(false);
1623    let mems = db.memories().map_err(mnemo_err)?;
1624    let out: Vec<Value> = mems
1625        .iter()
1626        .map(|m| {
1627            let mut v = memory_to_json(m);
1628            if !include_vectors {
1629                if let Some(o) = v.as_object_mut() {
1630                    o.remove("vector");
1631                }
1632            }
1633            v
1634        })
1635        .collect();
1636    Ok(serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".into()))
1637}
1638
1639fn tool_snapshot_list(db: &mut Mnemo) -> std::result::Result<String, RpcError> {
1640    let snaps = db.snapshots();
1641    let out: Vec<Value> = snaps
1642        .into_iter()
1643        .map(|s| {
1644            json!({
1645                "txn_id": s.txn_id,
1646                "created_at": s.created_at,
1647                "memory_count": s.memory_count,
1648            })
1649        })
1650        .collect();
1651    Ok(serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".into()))
1652}
1653
1654fn tool_stats(db: &mut Mnemo) -> std::result::Result<String, RpcError> {
1655    let s = db.stats().map_err(mnemo_err)?;
1656    let (cache_pages, cache_capacity) = db.page_cache_stats();
1657    let snapshot_count = db.snapshots().len();
1658    // Field names mirror `Stats` in `store.rs` verbatim so MCP clients
1659    // that reference the Rust doc for the same DB see the same shape.
1660    Ok(json!({
1661        "memories": s.memories,
1662        "deleted": s.deleted,
1663        "dimensions": s.dimensions,
1664        "file_bytes": s.file_bytes,
1665        "agents": s.agents,
1666        "encrypted": s.encrypted,
1667        "created_at": s.created_at,
1668        "wal_pages": s.wal_pages,
1669        "index": s.index,
1670        "snapshot_count": snapshot_count,
1671        "cache_pages": cache_pages,
1672        "cache_capacity": cache_capacity,
1673    })
1674    .to_string())
1675}
1676
1677// --- Result-cache tool handlers (Phase 10.4) -----------------------------
1678
1679fn tool_cache_put(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1680    let ns = args
1681        .get("namespace")
1682        .and_then(|v| v.as_str())
1683        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put: missing 'namespace'"))?;
1684    let key = args
1685        .get("key")
1686        .and_then(|v| v.as_str())
1687        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put: missing 'key'"))?;
1688    let value = args
1689        .get("value")
1690        .and_then(|v| v.as_str())
1691        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put: missing 'value'"))?;
1692    let content_type = args
1693        .get("content_type")
1694        .and_then(|v| v.as_str())
1695        .unwrap_or("text")
1696        .to_string();
1697    let ttl_secs = args.get("ttl_secs").and_then(|v| v.as_u64());
1698    let opts = CachePutOpts {
1699        content_type,
1700        ttl_secs,
1701        cost_hint_ms: None,
1702    };
1703    db.cache_put(ns, key, value.as_bytes(), opts)
1704        .map_err(mnemo_err)?;
1705    db.flush().map_err(mnemo_err)?;
1706    Ok(json!({ "cached": true, "namespace": ns, "key": key, "bytes": value.len() }).to_string())
1707}
1708
1709fn tool_cache_get(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1710    let ns = args
1711        .get("namespace")
1712        .and_then(|v| v.as_str())
1713        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_get: missing 'namespace'"))?;
1714    let key = args
1715        .get("key")
1716        .and_then(|v| v.as_str())
1717        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_get: missing 'key'"))?;
1718    match db.cache_get(ns, key).map_err(mnemo_err)? {
1719        Some(hit) => Ok(json!({
1720            "hit": true,
1721            "value": String::from_utf8_lossy(&hit.value),
1722            "content_type": hit.content_type,
1723            "created_at": hit.created_at,
1724            "accessed_at": hit.accessed_at,
1725            "access_count": hit.access_count,
1726        })
1727        .to_string()),
1728        None => Ok(json!({ "hit": false }).to_string()),
1729    }
1730}
1731
1732fn tool_cache_stats(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1733    let ns = args.get("namespace").and_then(|v| v.as_str());
1734    let s = db.cache_stats(ns);
1735    Ok(json!({
1736        "namespace": ns,
1737        "entries": s.entries,
1738        "bytes": s.bytes,
1739        "hits": s.hits,
1740        "misses": s.misses,
1741        "hit_rate": s.hit_rate,
1742        "evictions": s.evictions,
1743    })
1744    .to_string())
1745}
1746
1747fn tool_cache_put_semantic(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1748    let ns = args
1749        .get("namespace")
1750        .and_then(|v| v.as_str())
1751        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put_semantic: missing 'namespace'"))?;
1752    let key = args
1753        .get("key")
1754        .and_then(|v| v.as_str())
1755        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put_semantic: missing 'key'"))?;
1756    let value = args
1757        .get("value")
1758        .and_then(|v| v.as_str())
1759        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put_semantic: missing 'value'"))?;
1760    // When `text` is used, derive the model name from the server's
1761    // registered embedder (identity). Explicit `model` wins otherwise.
1762    let (vector, model) = match (args.get("vector"), args.get("text")) {
1763        (Some(_), Some(_)) => {
1764            return Err(RpcError::new(
1765                INVALID_PARAMS,
1766                "cache_put_semantic: pass EITHER 'vector' or 'text', not both",
1767            ));
1768        }
1769        (Some(v), None) => {
1770            let vec = v
1771                .as_array()
1772                .ok_or_else(|| {
1773                    RpcError::new(
1774                        INVALID_PARAMS,
1775                        "cache_put_semantic: 'vector' must be a JSON array",
1776                    )
1777                })?
1778                .iter()
1779                .map(|v| v.as_f64().map(|f| f as f32))
1780                .collect::<Option<Vec<f32>>>()
1781                .ok_or_else(|| {
1782                    RpcError::new(
1783                        INVALID_PARAMS,
1784                        "cache_put_semantic: 'vector' must be an array of numbers",
1785                    )
1786                })?;
1787            let model = args
1788                .get("model")
1789                .and_then(|v| v.as_str())
1790                .ok_or_else(|| {
1791                    RpcError::new(INVALID_PARAMS, "cache_put_semantic: missing 'model'")
1792                })?
1793                .to_string();
1794            (vec, model)
1795        }
1796        (None, Some(t)) => {
1797            let text = t.as_str().ok_or_else(|| {
1798                RpcError::new(
1799                    INVALID_PARAMS,
1800                    "cache_put_semantic: 'text' must be a string",
1801                )
1802            })?;
1803            let vec = embed_text_via_server(db, "cache_put_semantic", text)?;
1804            // Allow explicit model override; else derive from embedder.
1805            let model = match args.get("model").and_then(|v| v.as_str()) {
1806                Some(m) => m.to_string(),
1807                None => {
1808                    db.embedder()
1809                        .expect("embed_text_via_server would have errored otherwise")
1810                        .identity()
1811                        .name
1812                }
1813            };
1814            (vec, model)
1815        }
1816        (None, None) => {
1817            return Err(RpcError::new(
1818                INVALID_PARAMS,
1819                "cache_put_semantic: missing 'vector' or 'text'",
1820            ))
1821        }
1822    };
1823    let content_type = args
1824        .get("content_type")
1825        .and_then(|v| v.as_str())
1826        .unwrap_or("text")
1827        .to_string();
1828    let ttl_secs = args.get("ttl_secs").and_then(|v| v.as_u64());
1829    let opts = SemanticCachePutOpts {
1830        content_type,
1831        ttl_secs,
1832        cost_hint_ms: None,
1833        model,
1834    };
1835    db.cache_put_semantic(ns, key, vector, value.as_bytes(), opts)
1836        .map_err(mnemo_err)?;
1837    db.flush().map_err(mnemo_err)?;
1838    Ok(json!({ "cached": true, "namespace": ns, "key": key, "bytes": value.len() }).to_string())
1839}
1840
1841fn tool_cache_get_semantic(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1842    let ns = args
1843        .get("namespace")
1844        .and_then(|v| v.as_str())
1845        .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_get_semantic: missing 'namespace'"))?;
1846    // When `text` is used, derive the model name from the server's
1847    // registered embedder (identity). Explicit `model` wins otherwise.
1848    let (query, model_owned) = match (args.get("query"), args.get("text")) {
1849        (Some(_), Some(_)) => {
1850            return Err(RpcError::new(
1851                INVALID_PARAMS,
1852                "cache_get_semantic: pass EITHER 'query' or 'text', not both",
1853            ));
1854        }
1855        (Some(q), None) => {
1856            let vec = q
1857                .as_array()
1858                .ok_or_else(|| {
1859                    RpcError::new(
1860                        INVALID_PARAMS,
1861                        "cache_get_semantic: 'query' must be a JSON array",
1862                    )
1863                })?
1864                .iter()
1865                .map(|v| v.as_f64().map(|f| f as f32))
1866                .collect::<Option<Vec<f32>>>()
1867                .ok_or_else(|| {
1868                    RpcError::new(
1869                        INVALID_PARAMS,
1870                        "cache_get_semantic: 'query' must be an array of numbers",
1871                    )
1872                })?;
1873            let model = args
1874                .get("model")
1875                .and_then(|v| v.as_str())
1876                .ok_or_else(|| {
1877                    RpcError::new(INVALID_PARAMS, "cache_get_semantic: missing 'model'")
1878                })?
1879                .to_string();
1880            (vec, model)
1881        }
1882        (None, Some(t)) => {
1883            let text = t.as_str().ok_or_else(|| {
1884                RpcError::new(
1885                    INVALID_PARAMS,
1886                    "cache_get_semantic: 'text' must be a string",
1887                )
1888            })?;
1889            let vec = embed_text_via_server(db, "cache_get_semantic", text)?;
1890            let model = match args.get("model").and_then(|v| v.as_str()) {
1891                Some(m) => m.to_string(),
1892                None => {
1893                    db.embedder()
1894                        .expect("embed_text_via_server would have errored otherwise")
1895                        .identity()
1896                        .name
1897                }
1898            };
1899            (vec, model)
1900        }
1901        (None, None) => {
1902            return Err(RpcError::new(
1903                INVALID_PARAMS,
1904                "cache_get_semantic: missing 'query' or 'text'",
1905            ))
1906        }
1907    };
1908    let model = model_owned.as_str();
1909    let threshold = args
1910        .get("threshold")
1911        .and_then(|v| v.as_f64())
1912        .map(|f| f as f32)
1913        .unwrap_or(DEFAULT_SEMANTIC_THRESHOLD);
1914    match db
1915        .cache_get_semantic(ns, &query, threshold, model)
1916        .map_err(mnemo_err)?
1917    {
1918        Some((hit, similarity)) => Ok(json!({
1919            "hit": true,
1920            "similarity": similarity,
1921            "value": String::from_utf8_lossy(&hit.value),
1922            "content_type": hit.content_type,
1923            "created_at": hit.created_at,
1924            "accessed_at": hit.accessed_at,
1925            "access_count": hit.access_count,
1926        })
1927        .to_string()),
1928        None => Ok(json!({ "hit": false }).to_string()),
1929    }
1930}
1931
1932/// Serialize a `Memory` into the JSON shape MCP tool responses use.
1933/// Serde on `Memory` produces the canonical shape; we mirror what the
1934/// CLI's `memory_to_json` does but without the CLI-only vector-stripping
1935/// switch (that lives in the individual tools that care).
1936fn memory_to_json(m: &Memory) -> Value {
1937    serde_json::to_value(m).unwrap_or_else(|_| {
1938        let mut map = Map::new();
1939        map.insert("id".into(), json!(m.id.to_string()));
1940        Value::Object(map)
1941    })
1942}