1use 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
57const PROTOCOL_VERSION: &str = "2025-06-18";
59
60const PASSPHRASE_ENV: &str = "MNEMO_PASSPHRASE";
62
63#[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#[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#[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#[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
119const 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
126pub fn serve_stdio(path: &Path) -> Result<()> {
139 serve_stdio_with_embedder(path, None)
140}
141
142pub 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
160pub 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 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 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
258fn 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
282fn 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
302fn tools_list() -> Value {
308 json!({
309 "tools": [
310 {
311 "name": "about",
312 "description": "Read-only briefing for this .mnemo file: manifest scaffold plus any memories tagged as onboarding. Run this first to learn how the file expects to be used.",
313 "inputSchema": {
314 "type": "object",
315 "properties": {},
316 "additionalProperties": false
317 }
318 },
319 {
320 "name": "remember",
321 "description": "Insert, overwrite, or UPSERT a memory. Pass EITHER `vector` (explicit embedding) OR `text` (embedded by the server if it was started with --embed-cmd / --embed-name). Phase 12.1: when `key` is set, this becomes a keyed upsert — if a live memory already holds this `(agent + key)` slot the prior record is marked `_mnemo.superseded_by` and a new one is inserted, and an identical re-write is a no-op. **PREFER `key` for any fact that replaces an earlier one** (versions, current status, user preferences, plan outcomes). Persists to disk before returning. Returns `{id, outcome}` where `outcome` is `added`, `superseded`, `unchanged`, or (no key) `inserted`.",
322 "inputSchema": {
323 "type": "object",
324 "required": ["content", "memory_type"],
325 "properties": {
326 "content": { "type": "string" },
327 "memory_type": { "type": "string", "enum": ["episodic", "semantic", "procedural", "working"] },
328 "vector": { "type": "array", "items": { "type": "number" }, "description": "Explicit embedding; mutually exclusive with `text`." },
329 "text": { "type": "string", "description": "Content to embed via the server's registered embedder; mutually exclusive with `vector`. Requires the server to have been started with --embed-cmd." },
330 "importance": { "type": "number", "minimum": 0, "maximum": 1 },
331 "agent_id": { "type": "string" },
332 "session_id": { "type": "string" },
333 "ttl_secs": { "type": "integer" },
334 "shared": { "type": "boolean" },
335 "metadata": { "type": "object" },
336 "key": { "type": "string", "description": "Phase 12.1 upsert slot name. A later `remember` with the same `key` + agent scope supersedes the prior record; an identical re-write is a no-op. Use this for any fact that replaces an earlier one." }
337 },
338 "additionalProperties": false
339 }
340 },
341 {
342 "name": "update",
343 "description": "Edit a memory in place (Phase 12.1). Preserves the ULID, `created_at` and access stats. Any of `content` (needs `vector` to avoid embedding drift), `text` (re-embeds via the server), `importance`, `metadata`, `ttl_secs` can change. An identical patch returns `{id, outcome: 'unchanged'}` without writing. Prefer `remember` with `key` for facts that replace earlier ones; use `update` for in-place corrections to one specific record.",
344 "inputSchema": {
345 "type": "object",
346 "required": ["id"],
347 "properties": {
348 "id": { "type": "string", "description": "ULID of the memory to update." },
349 "content": { "type": "string", "description": "New content. Requires a matching `vector` (use `text` to re-embed instead)." },
350 "vector": { "type": "array", "items": { "type": "number" } },
351 "text": { "type": "string", "description": "New content; the server embeds it through its registered embedder. Mutually exclusive with `content` + `vector`." },
352 "importance": { "type": "number", "minimum": 0, "maximum": 1 },
353 "metadata": { "type": "object", "description": "Replace the user-visible metadata. Reserved `_mnemo.*` keys on the record are preserved." },
354 "ttl_secs": { "type": "integer", "description": "Set the TTL to this many seconds. Use `clear_ttl: true` to drop any TTL." },
355 "clear_ttl": { "type": "boolean", "description": "Clear any TTL on the record." }
356 },
357 "additionalProperties": false
358 }
359 },
360 {
361 "name": "history",
362 "description": "Walk the full chain for a keyed slot (Phase 12.1), newest first. Entry 0 is the live record (or empty if the slot is empty); subsequent entries are the superseded history. Slot resolution matches keyed `remember`: with no `agent` and `shared=false`, walks the default agent's private slot (v0.8.1 bugfix — pre-v0.8.1 this walked the shared slot by accident and returned empty for the common case). Pass `shared: true` for the shared slot; `agent` + `shared=true` is rejected.",
363 "inputSchema": {
364 "type": "object",
365 "required": ["key"],
366 "properties": {
367 "key": { "type": "string" },
368 "agent": { "type": "string", "description": "Query the private slot for this agent. Mutually exclusive with `shared`." },
369 "shared": { "type": "boolean", "default": false, "description": "Query the shared slot (one per file). Mutually exclusive with `agent`." }
370 },
371 "additionalProperties": false
372 }
373 },
374 {
375 "name": "recall",
376 "description": "Score every live memory (or the ANN-narrowed candidate set) against a query and return the top-k. Pass EITHER `query` (explicit vector) OR `text` (embedded by the server; requires --embed-cmd at serve start). Phase 12.6: add a `filter` object to restrict candidates before scoring (`session_id` plus per-metadata-key conditions: equality, `$in`, `$exists`, `$gte`/`$lte`). Phase 12.1: superseded records never appear. Phase 12.8: set `max_bytes: N` to cap the total UTF-8 bytes of returned `content` fields; the response is then shaped as `{hits: [...], truncated: bool}` so the caller can tell whether the budget cut hits. Without `max_bytes`, the response is a bare JSON array of hits (unchanged).",
377 "inputSchema": {
378 "type": "object",
379 "properties": {
380 "query": { "type": "array", "items": { "type": "number" }, "description": "Explicit query vector; mutually exclusive with `text`." },
381 "text": { "type": "string", "description": "Query text; embedded by the server's registered embedder. Mutually exclusive with `query`." },
382 "top_k": { "type": "integer", "default": 10 },
383 "types": { "type": "array", "items": { "type": "string" } },
384 "agent": { "type": "string" },
385 "metric": { "type": "string", "enum": ["cosine", "l2", "dot"], "default": "cosine" },
386 "max_bytes": { "type": "integer", "minimum": 1, "description": "Phase 12.8 budget: stop adding hits once the UTF-8 bytes of their `content` fields would exceed N. Always returns at least the top hit. When set, the response is `{hits, truncated}` instead of a bare array." },
387 "filter": {
388 "type": "object",
389 "description": "Phase 12.6 pre-scoring filter. All conditions AND together. Shape: `{\"session_id\": \"...\", \"metadata\": {key: value | {\"$in\": [...]} | {\"$exists\": true} | {\"$gte\": n, \"$lte\": n}}}`.",
390 "properties": {
391 "session_id": { "type": "string" },
392 "metadata": { "type": "object" }
393 },
394 "additionalProperties": false
395 }
396 },
397 "additionalProperties": false
398 }
399 },
400 {
401 "name": "forget",
402 "description": "Soft-delete a memory by ULID. Space is reclaimed on the next `mnemo compact`. Persists to disk before returning.",
403 "inputSchema": {
404 "type": "object",
405 "required": ["id"],
406 "properties": {
407 "id": { "type": "string", "description": "ULID of the memory to delete" }
408 },
409 "additionalProperties": false
410 }
411 },
412 {
413 "name": "list",
414 "description": "Return every live memory as JSON. Sorted by creation time, oldest first. Vectors are omitted by default to keep payloads small.",
415 "inputSchema": {
416 "type": "object",
417 "properties": {
418 "include_vectors": { "type": "boolean", "default": false }
419 },
420 "additionalProperties": false
421 }
422 },
423 {
424 "name": "snapshot_list",
425 "description": "List every restorable snapshot (one per committed flush). Each entry gives the transaction ID, creation timestamp, and memory count.",
426 "inputSchema": {
427 "type": "object",
428 "properties": {},
429 "additionalProperties": false
430 }
431 },
432 {
433 "name": "stats",
434 "description": "Summary statistics: memory count, deleted count, agents present, snapshot count, page cache occupancy.",
435 "inputSchema": {
436 "type": "object",
437 "properties": {},
438 "additionalProperties": false
439 }
440 },
441 {
442 "name": "cache_put",
443 "description": "Insert or overwrite an exact-key cache entry. Value is a UTF-8 string; use `content_type` to label the payload. Persists to disk before returning. Namespace and key are caller-chosen; the key is SHA-256 hashed by the engine.",
444 "inputSchema": {
445 "type": "object",
446 "required": ["namespace", "key", "value"],
447 "properties": {
448 "namespace": { "type": "string" },
449 "key": { "type": "string" },
450 "value": { "type": "string" },
451 "content_type": { "type": "string", "default": "text" },
452 "ttl_secs": { "type": "integer", "minimum": 0 }
453 },
454 "additionalProperties": false
455 }
456 },
457 {
458 "name": "cache_get",
459 "description": "Look up an exact-key cache entry. Returns `{value, content_type, hit}` on a hit, `{hit: false}` on miss (missing or TTL-expired). Bumps access stats catalog-only.",
460 "inputSchema": {
461 "type": "object",
462 "required": ["namespace", "key"],
463 "properties": {
464 "namespace": { "type": "string" },
465 "key": { "type": "string" }
466 },
467 "additionalProperties": false
468 }
469 },
470 {
471 "name": "cache_stats",
472 "description": "Cache statistics for a namespace (omit for the whole cache). Returns entries, bytes, hits, misses, hit_rate, evictions.",
473 "inputSchema": {
474 "type": "object",
475 "properties": {
476 "namespace": { "type": "string" }
477 },
478 "additionalProperties": false
479 }
480 },
481 {
482 "name": "cache_put_semantic",
483 "description": "Insert an entry with an embedding vector for semantic recall (Phase 10.2). Pass EITHER `vector` + `model` (explicit) OR `text` (embedded by the server; `model` defaults to the server embedder's identity name). Later cache_get_semantic calls under a different `model` won't see this entry (a different-model hit would be a semantics bug).",
484 "inputSchema": {
485 "type": "object",
486 "required": ["namespace", "key", "value"],
487 "properties": {
488 "namespace": { "type": "string" },
489 "key": { "type": "string" },
490 "vector": { "type": "array", "items": { "type": "number" }, "description": "Explicit embedding; mutually exclusive with `text`. Requires `model`." },
491 "text": { "type": "string", "description": "Content to embed via the server's registered embedder; mutually exclusive with `vector`." },
492 "value": { "type": "string" },
493 "model": { "type": "string", "description": "Required with `vector`; optional with `text` (defaults to the embedder's identity name)." },
494 "content_type": { "type": "string", "default": "text" },
495 "ttl_secs": { "type": "integer", "minimum": 0 }
496 },
497 "additionalProperties": false
498 }
499 },
500 {
501 "name": "cache_get_semantic",
502 "description": "Semantic recall over the cache: top-1 cosine over live vectored entries in the namespace whose `model` matches. Pass EITHER `query` + `model` OR `text` (which embeds via the server and defaults the model to the embedder's identity). Returns `{hit: true, value, similarity, ...}` iff similarity >= threshold, else `{hit: false}`. Threshold defaults to 0.97.",
503 "inputSchema": {
504 "type": "object",
505 "required": ["namespace"],
506 "properties": {
507 "namespace": { "type": "string" },
508 "query": { "type": "array", "items": { "type": "number" }, "description": "Explicit query vector; mutually exclusive with `text`. Requires `model`." },
509 "text": { "type": "string", "description": "Query text; embedded by the server. Mutually exclusive with `query`." },
510 "model": { "type": "string", "description": "Required with `query`; optional with `text`." },
511 "threshold": { "type": "number", "minimum": -1.0, "maximum": 1.0 }
512 },
513 "additionalProperties": false
514 }
515 }
516 ]
517 })
518}
519
520fn tools_call(db: &mut Mnemo, params: &Value) -> std::result::Result<Value, RpcError> {
525 let name = params
526 .get("name")
527 .and_then(|v| v.as_str())
528 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "missing tool name"))?;
529 let empty = json!({});
530 let args = params.get("arguments").unwrap_or(&empty);
531
532 let text = match name {
533 "about" => tool_about(db)?,
534 "remember" => tool_remember(db, args)?,
535 "update" => tool_update(db, args)?,
536 "history" => tool_history(db, args)?,
537 "recall" => tool_recall(db, args)?,
538 "forget" => tool_forget(db, args)?,
539 "list" => tool_list(db, args)?,
540 "snapshot_list" => tool_snapshot_list(db)?,
541 "stats" => tool_stats(db)?,
542 "cache_put" => tool_cache_put(db, args)?,
543 "cache_get" => tool_cache_get(db, args)?,
544 "cache_stats" => tool_cache_stats(db, args)?,
545 "cache_put_semantic" => tool_cache_put_semantic(db, args)?,
546 "cache_get_semantic" => tool_cache_get_semantic(db, args)?,
547 other => {
548 return Err(RpcError::new(
549 METHOD_NOT_FOUND,
550 format!("unknown tool '{other}'"),
551 ))
552 }
553 };
554
555 Ok(json!({
556 "content": [ { "type": "text", "text": text } ],
557 "isError": false,
558 }))
559}
560
561fn embed_text_via_server(
572 db: &mut Mnemo,
573 tool: &str,
574 text: &str,
575) -> std::result::Result<Vec<f32>, RpcError> {
576 let embedder = db.embedder().ok_or_else(|| {
577 RpcError::new(
578 INVALID_PARAMS,
579 format!(
580 "{tool}: 'text' requires an embedder on the server. \
581 Start with: mnemo serve --mcp <file> --embed-cmd '<cmd>' --embed-name <name>"
582 ),
583 )
584 })?;
585 let mut vs = embedder.embed(&[text]).map_err(mnemo_err)?;
586 vs.pop().ok_or_else(|| {
587 RpcError::new(
588 INTERNAL_ERROR,
589 format!("{tool}: embedder returned no vector for the input text"),
590 )
591 })
592}
593
594fn mnemo_err(e: MnemoError) -> RpcError {
595 RpcError::new(INTERNAL_ERROR, e.to_string())
596}
597
598fn tool_about(db: &mut Mnemo) -> std::result::Result<String, RpcError> {
599 let mems = db.about().map_err(mnemo_err)?;
600 let arr: Vec<Value> = mems.iter().map(memory_to_json).collect();
601 Ok(serde_json::to_string_pretty(&arr).unwrap_or_else(|_| "[]".into()))
602}
603
604fn tool_remember(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
605 let content = args
606 .get("content")
607 .and_then(|v| v.as_str())
608 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "remember: missing 'content'"))?;
609 let mtype_str = args
610 .get("memory_type")
611 .and_then(|v| v.as_str())
612 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "remember: missing 'memory_type'"))?;
613 let mtype = MemoryType::parse(mtype_str).ok_or_else(|| {
614 RpcError::new(
615 INVALID_PARAMS,
616 format!("remember: unknown memory_type '{mtype_str}'"),
617 )
618 })?;
619 let vector = match (args.get("vector"), args.get("text")) {
622 (Some(_), Some(_)) => {
623 return Err(RpcError::new(
624 INVALID_PARAMS,
625 "remember: pass EITHER 'vector' or 'text', not both",
626 ));
627 }
628 (Some(v), None) => v
629 .as_array()
630 .ok_or_else(|| {
631 RpcError::new(INVALID_PARAMS, "remember: 'vector' must be a JSON array")
632 })?
633 .iter()
634 .map(|v| v.as_f64().map(|f| f as f32))
635 .collect::<Option<Vec<f32>>>()
636 .ok_or_else(|| {
637 RpcError::new(
638 INVALID_PARAMS,
639 "remember: 'vector' must be an array of numbers",
640 )
641 })?,
642 (None, Some(t)) => {
643 let text = t.as_str().ok_or_else(|| {
644 RpcError::new(INVALID_PARAMS, "remember: 'text' must be a string")
645 })?;
646 embed_text_via_server(db, "remember", text)?
647 }
648 (None, None) => {
649 return Err(RpcError::new(
650 INVALID_PARAMS,
651 "remember: missing 'vector' or 'text'",
652 ))
653 }
654 };
655
656 let mut mem = Memory::new(content, mtype, vector);
657 if let Some(agent) = args.get("agent_id").and_then(|v| v.as_str()) {
658 mem = mem.with_agent(agent);
659 }
660 if let Some(sess) = args.get("session_id").and_then(|v| v.as_str()) {
661 mem = mem.with_session(sess);
662 }
663 if let Some(imp) = args.get("importance").and_then(|v| v.as_f64()) {
664 mem = mem.with_importance(imp as f32);
665 }
666 if let Some(ttl) = args.get("ttl_secs").and_then(|v| v.as_i64()) {
667 mem = mem.with_ttl(ttl);
668 }
669 if let Some(shared) = args.get("shared").and_then(|v| v.as_bool()) {
670 if shared {
671 mem = mem.with_scope(Scope::Shared);
672 }
673 }
674 if let Some(meta) = args.get("metadata").and_then(|v| v.as_object()) {
675 for (k, v) in meta {
676 mem.metadata.insert(k.clone(), v.clone());
677 }
678 }
679
680 match args.get("key").and_then(|v| v.as_str()) {
685 None => {
686 let id = db.remember(mem).map_err(mnemo_err)?;
687 db.flush().map_err(mnemo_err)?;
688 Ok(json!({ "id": id.to_string(), "outcome": "inserted" }).to_string())
689 }
690 Some(k) => {
691 let (id, outcome) = db.remember_keyed(mem, k).map_err(mnemo_err)?;
692 db.flush().map_err(mnemo_err)?;
693 let outcome_str = match outcome {
694 crate::WriteOutcome::Added => "added",
695 crate::WriteOutcome::Superseded => "superseded",
696 crate::WriteOutcome::Unchanged => "unchanged",
697 crate::WriteOutcome::Updated => "superseded",
698 };
699 Ok(json!({ "id": id.to_string(), "outcome": outcome_str }).to_string())
700 }
701 }
702}
703
704fn tool_update(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
705 use crate::Ulid;
706 use crate::{MemoryPatch, WriteOutcome};
707 let id_str = args
708 .get("id")
709 .and_then(|v| v.as_str())
710 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "update: missing 'id'"))?;
711 let id = Ulid::from_string(id_str)
712 .map_err(|_| RpcError::new(INVALID_PARAMS, format!("update: invalid ULID '{id_str}'")))?;
713 let has_text = args.get("text").is_some();
717 let has_content = args.get("content").is_some();
718 let has_vector = args.get("vector").is_some();
719 if has_text && (has_content || has_vector) {
720 return Err(RpcError::new(
721 INVALID_PARAMS,
722 "update: pass EITHER 'text' or 'content'+'vector', not both",
723 ));
724 }
725 let mut patch = MemoryPatch::default();
726 if has_content {
727 let c = args
728 .get("content")
729 .and_then(|v| v.as_str())
730 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "update: 'content' must be a string"))?;
731 patch.content = Some(c.to_string());
732 }
733 if has_vector {
734 let v = args
735 .get("vector")
736 .and_then(|v| v.as_array())
737 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "update: 'vector' must be a JSON array"))?
738 .iter()
739 .map(|v| v.as_f64().map(|f| f as f32))
740 .collect::<Option<Vec<f32>>>()
741 .ok_or_else(|| {
742 RpcError::new(
743 INVALID_PARAMS,
744 "update: 'vector' must be an array of numbers",
745 )
746 })?;
747 patch.vector = Some(v);
748 }
749 if let Some(imp) = args.get("importance").and_then(|v| v.as_f64()) {
750 patch.importance = Some(imp as f32);
751 }
752 if let Some(meta) = args.get("metadata").and_then(|v| v.as_object()) {
753 patch.metadata = Some(meta.clone());
754 }
755 let clear_ttl = args
756 .get("clear_ttl")
757 .and_then(|v| v.as_bool())
758 .unwrap_or(false);
759 if clear_ttl {
760 patch.ttl_secs = Some(None);
761 } else if let Some(ttl) = args.get("ttl_secs").and_then(|v| v.as_i64()) {
762 patch.ttl_secs = Some(Some(ttl));
763 }
764
765 let outcome = if has_text {
766 let t = args
767 .get("text")
768 .and_then(|v| v.as_str())
769 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "update: 'text' must be a string"))?;
770 db.update_text(&id, t, patch).map_err(mnemo_err)?
771 } else {
772 db.update(&id, patch).map_err(mnemo_err)?
773 };
774 db.flush().map_err(mnemo_err)?;
775 let outcome_str = match outcome {
776 WriteOutcome::Updated => "updated",
777 WriteOutcome::Unchanged => "unchanged",
778 WriteOutcome::Added => "updated",
779 WriteOutcome::Superseded => "updated",
780 };
781 Ok(json!({ "id": id.to_string(), "outcome": outcome_str }).to_string())
782}
783
784fn tool_history(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
785 use crate::KeyScope;
786 let key = args
787 .get("key")
788 .and_then(|v| v.as_str())
789 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "history: missing 'key'"))?;
790 let agent = args.get("agent").and_then(|v| v.as_str());
791 let shared = args
792 .get("shared")
793 .and_then(|v| v.as_bool())
794 .unwrap_or(false);
795 if shared && agent.is_some() {
796 return Err(RpcError::new(
797 INVALID_PARAMS,
798 "history: 'agent' and 'shared' are mutually exclusive",
799 ));
800 }
801 let scope = if shared {
803 KeyScope::Shared
804 } else {
805 match agent {
806 Some(a) => KeyScope::Private(a.to_string()),
807 None => KeyScope::private_default(),
808 }
809 };
810 let chain = db.history_in(key, scope).map_err(mnemo_err)?;
811 let out: Vec<Value> = chain
812 .iter()
813 .enumerate()
814 .map(|(i, m)| {
815 let mut v = memory_to_json(m);
816 if let Some(obj) = v.as_object_mut() {
817 obj.insert(
818 "state".into(),
819 Value::String(if i == 0 {
820 "live".into()
821 } else {
822 "superseded".into()
823 }),
824 );
825 }
826 v
827 })
828 .collect();
829 Ok(serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".into()))
830}
831
832fn tool_recall(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
833 let query = match (args.get("query"), args.get("text")) {
835 (Some(_), Some(_)) => {
836 return Err(RpcError::new(
837 INVALID_PARAMS,
838 "recall: pass EITHER 'query' or 'text', not both",
839 ))
840 }
841 (Some(v), None) => v
842 .as_array()
843 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "recall: 'query' must be a JSON array"))?
844 .iter()
845 .map(|v| v.as_f64().map(|f| f as f32))
846 .collect::<Option<Vec<f32>>>()
847 .ok_or_else(|| {
848 RpcError::new(
849 INVALID_PARAMS,
850 "recall: 'query' must be an array of numbers",
851 )
852 })?,
853 (None, Some(t)) => {
854 let text = t
855 .as_str()
856 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "recall: 'text' must be a string"))?;
857 embed_text_via_server(db, "recall", text)?
858 }
859 (None, None) => {
860 return Err(RpcError::new(
861 INVALID_PARAMS,
862 "recall: missing 'query' or 'text'",
863 ))
864 }
865 };
866 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
867 let metric = match args
868 .get("metric")
869 .and_then(|v| v.as_str())
870 .unwrap_or("cosine")
871 {
872 "cosine" => Metric::Cosine,
873 "l2" => Metric::L2,
874 "dot" => Metric::Dot,
875 other => {
876 return Err(RpcError::new(
877 INVALID_PARAMS,
878 format!("recall: unknown metric '{other}'"),
879 ))
880 }
881 };
882 let mut req = RecallRequest::new(query)
895 .top_k(top_k)
896 .metric(metric)
897 .track_access(true);
898 if let Some(types) = args.get("types").and_then(|v| v.as_array()) {
899 let parsed: Vec<MemoryType> = types
900 .iter()
901 .filter_map(|t| t.as_str().and_then(MemoryType::parse))
902 .collect();
903 if !parsed.is_empty() {
904 req = req.types(parsed);
905 }
906 }
907 if let Some(agent) = args.get("agent").and_then(|v| v.as_str()) {
908 req = req.agent(agent.to_string());
909 }
910 if let Some(filter_obj) = args.get("filter").and_then(|v| v.as_object()) {
917 req = req.filter(parse_mcp_filter(filter_obj)?);
918 }
919 let has_max_bytes = args.get("max_bytes").is_some();
921 if let Some(n) = args.get("max_bytes").and_then(|v| v.as_u64()) {
922 req = req.max_bytes(n as usize);
923 }
924
925 let resp = db.recall_budgeted(&req).map_err(mnemo_err)?;
926 let out: Vec<Value> = resp
927 .hits
928 .into_iter()
929 .map(|h| {
930 let mut obj = memory_to_json(&h.memory);
931 if let Some(map) = obj.as_object_mut() {
932 map.insert("score".into(), json!(h.score));
933 map.insert("similarity".into(), json!(h.similarity));
934 }
935 obj
936 })
937 .collect();
938 if has_max_bytes {
939 let wrapped = json!({
942 "hits": out,
943 "truncated": resp.truncated,
944 });
945 Ok(serde_json::to_string_pretty(&wrapped).unwrap_or_else(|_| "{}".into()))
946 } else {
947 Ok(serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".into()))
948 }
949}
950
951fn parse_mcp_filter(
961 obj: &serde_json::Map<String, Value>,
962) -> std::result::Result<crate::Filter, RpcError> {
963 let mut f = crate::Filter::default();
964 if let Some(sid) = obj.get("session_id").and_then(|v| v.as_str()) {
965 f = f.with_session(sid.to_string());
966 }
967 if let Some(meta) = obj.get("metadata").and_then(|v| v.as_object()) {
968 for (k, v) in meta {
969 match v {
970 Value::Object(ops) => {
972 for (op_key, op_val) in ops {
973 match op_key.as_str() {
974 "$exists" => match op_val.as_bool() {
975 Some(true) => {
976 f = f.metadata_exists(k.clone());
977 }
978 Some(false) => {
979 return Err(RpcError::new(
980 INVALID_PARAMS,
981 format!(
982 "filter.metadata.{k}.$exists=false isn't supported; \
983 drop the filter term instead"
984 ),
985 ));
986 }
987 None => {
988 return Err(RpcError::new(
989 INVALID_PARAMS,
990 format!("filter.metadata.{k}.$exists must be a boolean"),
991 ));
992 }
993 },
994 "$in" => {
995 let arr = op_val.as_array().ok_or_else(|| {
996 RpcError::new(
997 INVALID_PARAMS,
998 format!("filter.metadata.{k}.$in must be an array"),
999 )
1000 })?;
1001 f = f.metadata_in(k.clone(), arr.clone());
1002 }
1003 "$gte" | "$lte" => {
1004 let mut min = None;
1005 let mut max = None;
1006 for (bound, bound_val) in ops {
1007 let n = bound_val.as_f64().ok_or_else(|| {
1008 RpcError::new(
1009 INVALID_PARAMS,
1010 format!("filter.metadata.{k}.{bound} must be a number"),
1011 )
1012 })?;
1013 match bound.as_str() {
1014 "$gte" => min = Some(n),
1015 "$lte" => max = Some(n),
1016 other => {
1017 return Err(RpcError::new(
1018 INVALID_PARAMS,
1019 format!(
1020 "filter.metadata.{k}: unsupported range \
1021 operator '{other}' (want $gte, $lte)"
1022 ),
1023 ));
1024 }
1025 }
1026 }
1027 f = f.metadata_range(k.clone(), min, max);
1028 break; }
1030 other => {
1031 return Err(RpcError::new(
1032 INVALID_PARAMS,
1033 format!(
1034 "filter.metadata.{k}: unknown operator '{other}' \
1035 (want $exists, $in, $gte, $lte)"
1036 ),
1037 ));
1038 }
1039 }
1040 }
1041 }
1042 other => {
1044 f = f.metadata_eq(k.clone(), other.clone());
1045 }
1046 }
1047 }
1048 }
1049 Ok(f)
1050}
1051
1052fn tool_forget(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1053 let id_str = args
1054 .get("id")
1055 .and_then(|v| v.as_str())
1056 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "forget: missing 'id'"))?;
1057 let ulid = crate::Ulid::from_string(id_str)
1058 .map_err(|_| RpcError::new(INVALID_PARAMS, format!("forget: invalid ULID '{id_str}'")))?;
1059 db.delete(&ulid).map_err(mnemo_err)?;
1060 db.flush().map_err(mnemo_err)?;
1061 Ok(json!({ "deleted": id_str }).to_string())
1062}
1063
1064fn tool_list(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1065 let include_vectors = args
1066 .get("include_vectors")
1067 .and_then(|v| v.as_bool())
1068 .unwrap_or(false);
1069 let mems = db.memories().map_err(mnemo_err)?;
1070 let out: Vec<Value> = mems
1071 .iter()
1072 .map(|m| {
1073 let mut v = memory_to_json(m);
1074 if !include_vectors {
1075 if let Some(o) = v.as_object_mut() {
1076 o.remove("vector");
1077 }
1078 }
1079 v
1080 })
1081 .collect();
1082 Ok(serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".into()))
1083}
1084
1085fn tool_snapshot_list(db: &mut Mnemo) -> std::result::Result<String, RpcError> {
1086 let snaps = db.snapshots();
1087 let out: Vec<Value> = snaps
1088 .into_iter()
1089 .map(|s| {
1090 json!({
1091 "txn_id": s.txn_id,
1092 "created_at": s.created_at,
1093 "memory_count": s.memory_count,
1094 })
1095 })
1096 .collect();
1097 Ok(serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".into()))
1098}
1099
1100fn tool_stats(db: &mut Mnemo) -> std::result::Result<String, RpcError> {
1101 let s = db.stats().map_err(mnemo_err)?;
1102 let (cache_pages, cache_capacity) = db.page_cache_stats();
1103 let snapshot_count = db.snapshots().len();
1104 Ok(json!({
1107 "memories": s.memories,
1108 "deleted": s.deleted,
1109 "dimensions": s.dimensions,
1110 "file_bytes": s.file_bytes,
1111 "agents": s.agents,
1112 "encrypted": s.encrypted,
1113 "created_at": s.created_at,
1114 "wal_pages": s.wal_pages,
1115 "index": s.index,
1116 "snapshot_count": snapshot_count,
1117 "cache_pages": cache_pages,
1118 "cache_capacity": cache_capacity,
1119 })
1120 .to_string())
1121}
1122
1123fn tool_cache_put(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1126 let ns = args
1127 .get("namespace")
1128 .and_then(|v| v.as_str())
1129 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put: missing 'namespace'"))?;
1130 let key = args
1131 .get("key")
1132 .and_then(|v| v.as_str())
1133 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put: missing 'key'"))?;
1134 let value = args
1135 .get("value")
1136 .and_then(|v| v.as_str())
1137 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put: missing 'value'"))?;
1138 let content_type = args
1139 .get("content_type")
1140 .and_then(|v| v.as_str())
1141 .unwrap_or("text")
1142 .to_string();
1143 let ttl_secs = args.get("ttl_secs").and_then(|v| v.as_u64());
1144 let opts = CachePutOpts {
1145 content_type,
1146 ttl_secs,
1147 cost_hint_ms: None,
1148 };
1149 db.cache_put(ns, key, value.as_bytes(), opts)
1150 .map_err(mnemo_err)?;
1151 db.flush().map_err(mnemo_err)?;
1152 Ok(json!({ "cached": true, "namespace": ns, "key": key, "bytes": value.len() }).to_string())
1153}
1154
1155fn tool_cache_get(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1156 let ns = args
1157 .get("namespace")
1158 .and_then(|v| v.as_str())
1159 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_get: missing 'namespace'"))?;
1160 let key = args
1161 .get("key")
1162 .and_then(|v| v.as_str())
1163 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_get: missing 'key'"))?;
1164 match db.cache_get(ns, key).map_err(mnemo_err)? {
1165 Some(hit) => Ok(json!({
1166 "hit": true,
1167 "value": String::from_utf8_lossy(&hit.value),
1168 "content_type": hit.content_type,
1169 "created_at": hit.created_at,
1170 "accessed_at": hit.accessed_at,
1171 "access_count": hit.access_count,
1172 })
1173 .to_string()),
1174 None => Ok(json!({ "hit": false }).to_string()),
1175 }
1176}
1177
1178fn tool_cache_stats(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1179 let ns = args.get("namespace").and_then(|v| v.as_str());
1180 let s = db.cache_stats(ns);
1181 Ok(json!({
1182 "namespace": ns,
1183 "entries": s.entries,
1184 "bytes": s.bytes,
1185 "hits": s.hits,
1186 "misses": s.misses,
1187 "hit_rate": s.hit_rate,
1188 "evictions": s.evictions,
1189 })
1190 .to_string())
1191}
1192
1193fn tool_cache_put_semantic(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1194 let ns = args
1195 .get("namespace")
1196 .and_then(|v| v.as_str())
1197 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put_semantic: missing 'namespace'"))?;
1198 let key = args
1199 .get("key")
1200 .and_then(|v| v.as_str())
1201 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put_semantic: missing 'key'"))?;
1202 let value = args
1203 .get("value")
1204 .and_then(|v| v.as_str())
1205 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_put_semantic: missing 'value'"))?;
1206 let (vector, model) = match (args.get("vector"), args.get("text")) {
1209 (Some(_), Some(_)) => {
1210 return Err(RpcError::new(
1211 INVALID_PARAMS,
1212 "cache_put_semantic: pass EITHER 'vector' or 'text', not both",
1213 ));
1214 }
1215 (Some(v), None) => {
1216 let vec = v
1217 .as_array()
1218 .ok_or_else(|| {
1219 RpcError::new(
1220 INVALID_PARAMS,
1221 "cache_put_semantic: 'vector' must be a JSON array",
1222 )
1223 })?
1224 .iter()
1225 .map(|v| v.as_f64().map(|f| f as f32))
1226 .collect::<Option<Vec<f32>>>()
1227 .ok_or_else(|| {
1228 RpcError::new(
1229 INVALID_PARAMS,
1230 "cache_put_semantic: 'vector' must be an array of numbers",
1231 )
1232 })?;
1233 let model = args
1234 .get("model")
1235 .and_then(|v| v.as_str())
1236 .ok_or_else(|| {
1237 RpcError::new(INVALID_PARAMS, "cache_put_semantic: missing 'model'")
1238 })?
1239 .to_string();
1240 (vec, model)
1241 }
1242 (None, Some(t)) => {
1243 let text = t.as_str().ok_or_else(|| {
1244 RpcError::new(
1245 INVALID_PARAMS,
1246 "cache_put_semantic: 'text' must be a string",
1247 )
1248 })?;
1249 let vec = embed_text_via_server(db, "cache_put_semantic", text)?;
1250 let model = match args.get("model").and_then(|v| v.as_str()) {
1252 Some(m) => m.to_string(),
1253 None => {
1254 db.embedder()
1255 .expect("embed_text_via_server would have errored otherwise")
1256 .identity()
1257 .name
1258 }
1259 };
1260 (vec, model)
1261 }
1262 (None, None) => {
1263 return Err(RpcError::new(
1264 INVALID_PARAMS,
1265 "cache_put_semantic: missing 'vector' or 'text'",
1266 ))
1267 }
1268 };
1269 let content_type = args
1270 .get("content_type")
1271 .and_then(|v| v.as_str())
1272 .unwrap_or("text")
1273 .to_string();
1274 let ttl_secs = args.get("ttl_secs").and_then(|v| v.as_u64());
1275 let opts = SemanticCachePutOpts {
1276 content_type,
1277 ttl_secs,
1278 cost_hint_ms: None,
1279 model,
1280 };
1281 db.cache_put_semantic(ns, key, vector, value.as_bytes(), opts)
1282 .map_err(mnemo_err)?;
1283 db.flush().map_err(mnemo_err)?;
1284 Ok(json!({ "cached": true, "namespace": ns, "key": key, "bytes": value.len() }).to_string())
1285}
1286
1287fn tool_cache_get_semantic(db: &mut Mnemo, args: &Value) -> std::result::Result<String, RpcError> {
1288 let ns = args
1289 .get("namespace")
1290 .and_then(|v| v.as_str())
1291 .ok_or_else(|| RpcError::new(INVALID_PARAMS, "cache_get_semantic: missing 'namespace'"))?;
1292 let (query, model_owned) = match (args.get("query"), args.get("text")) {
1295 (Some(_), Some(_)) => {
1296 return Err(RpcError::new(
1297 INVALID_PARAMS,
1298 "cache_get_semantic: pass EITHER 'query' or 'text', not both",
1299 ));
1300 }
1301 (Some(q), None) => {
1302 let vec = q
1303 .as_array()
1304 .ok_or_else(|| {
1305 RpcError::new(
1306 INVALID_PARAMS,
1307 "cache_get_semantic: 'query' must be a JSON array",
1308 )
1309 })?
1310 .iter()
1311 .map(|v| v.as_f64().map(|f| f as f32))
1312 .collect::<Option<Vec<f32>>>()
1313 .ok_or_else(|| {
1314 RpcError::new(
1315 INVALID_PARAMS,
1316 "cache_get_semantic: 'query' must be an array of numbers",
1317 )
1318 })?;
1319 let model = args
1320 .get("model")
1321 .and_then(|v| v.as_str())
1322 .ok_or_else(|| {
1323 RpcError::new(INVALID_PARAMS, "cache_get_semantic: missing 'model'")
1324 })?
1325 .to_string();
1326 (vec, model)
1327 }
1328 (None, Some(t)) => {
1329 let text = t.as_str().ok_or_else(|| {
1330 RpcError::new(
1331 INVALID_PARAMS,
1332 "cache_get_semantic: 'text' must be a string",
1333 )
1334 })?;
1335 let vec = embed_text_via_server(db, "cache_get_semantic", text)?;
1336 let model = match args.get("model").and_then(|v| v.as_str()) {
1337 Some(m) => m.to_string(),
1338 None => {
1339 db.embedder()
1340 .expect("embed_text_via_server would have errored otherwise")
1341 .identity()
1342 .name
1343 }
1344 };
1345 (vec, model)
1346 }
1347 (None, None) => {
1348 return Err(RpcError::new(
1349 INVALID_PARAMS,
1350 "cache_get_semantic: missing 'query' or 'text'",
1351 ))
1352 }
1353 };
1354 let model = model_owned.as_str();
1355 let threshold = args
1356 .get("threshold")
1357 .and_then(|v| v.as_f64())
1358 .map(|f| f as f32)
1359 .unwrap_or(DEFAULT_SEMANTIC_THRESHOLD);
1360 match db
1361 .cache_get_semantic(ns, &query, threshold, model)
1362 .map_err(mnemo_err)?
1363 {
1364 Some((hit, similarity)) => Ok(json!({
1365 "hit": true,
1366 "similarity": similarity,
1367 "value": String::from_utf8_lossy(&hit.value),
1368 "content_type": hit.content_type,
1369 "created_at": hit.created_at,
1370 "accessed_at": hit.accessed_at,
1371 "access_count": hit.access_count,
1372 })
1373 .to_string()),
1374 None => Ok(json!({ "hit": false }).to_string()),
1375 }
1376}
1377
1378fn memory_to_json(m: &Memory) -> Value {
1383 serde_json::to_value(m).unwrap_or_else(|_| {
1384 let mut map = Map::new();
1385 map.insert("id".into(), json!(m.id.to_string()));
1386 Value::Object(map)
1387 })
1388}