Skip to main content

recall_echo/
mcp.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! MCP server — the read path into the knowledge graph.
6//!
7//! Without this, the graph is write-only in practice. `SessionEnd` ingests
8//! episodes and `SessionStart` runs `consume`, which only prints EPHEMERAL.md;
9//! nothing in a normal session ever queries the store. Bayesian confidence,
10//! HNSW search, provenance weighting and temporal decay all sit behind a
11//! command a human has to type by hand. An MCP server closes that loop: the
12//! agent asks its own memory, with the actual question, at the moment the
13//! question comes up.
14//!
15//! # Shape
16//!
17//! JSON-RPC 2.0 over stdin/stdout, one message per line — the standard local
18//! MCP transport. The surface is small on purpose: `initialize`, `ping`,
19//! `tools/list`, `tools/call`, and notifications, which are consumed
20//! silently. Anything else is a JSON-RPC `method not found`; nothing here
21//! panics on hostile input.
22//!
23//! Every tool runs through [`crate::serve_client::execute`], so the MCP server
24//! is just another daemon client and inherits the daemon's locking discipline,
25//! concurrency and auto-start. It never opens the store itself.
26//!
27//! # Read-only
28//!
29//! No tool writes. The graph's confidence model deliberately discounts what
30//! the agent asserts about itself (see `[graph.provenance]`); a tool that let
31//! the model create entities and edges directly would route around exactly
32//! the mechanism that keeps self-generated claims from becoming evidence.
33//! Writing stays on the ingest path, where every episode is stamped with its
34//! authorship.
35//!
36//! That applies to correction too, and for the same reason inverted:
37//! `graph correct` enters a contradiction at *user* authority, which is only
38//! true while a human is the one typing it. A model calling a correction tool
39//! would be recording its own judgement as the human's — the loudest possible
40//! version of the failure provenance weighting exists to prevent. Corrections
41//! stay on the CLI.
42
43pub mod render;
44pub mod tools;
45
46use std::path::{Path, PathBuf};
47
48use serde::Deserialize;
49use serde_json::{json, Value};
50use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
51
52use crate::error::RecallError;
53use crate::graph::inspect::MemoryOverview;
54use crate::graph::types::{
55    EpisodeSearchResult, GraphStats, QueryResult, ScoredEntity, TraversalNode,
56};
57use crate::serve::Request;
58use crate::serve_client;
59use tools::Tool;
60
61/// MCP revisions this server speaks, newest first.
62///
63/// All of them carry the same `initialize` / `tools/list` / `tools/call`
64/// shapes for a tools-only server, so one implementation serves them all.
65pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] =
66    &["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
67
68/// The revision offered to a client that asks for one we do not implement.
69pub const PREFERRED_PROTOCOL_VERSION: &str = "2025-11-25";
70
71/// Largest single JSON-RPC message accepted. A tool call is tiny; anything
72/// approaching this is a broken or hostile client trying to make us buffer
73/// without bound.
74const MAX_MESSAGE_BYTES: u64 = 4 * 1024 * 1024;
75
76// JSON-RPC 2.0 error codes.
77const PARSE_ERROR: i64 = -32700;
78const INVALID_REQUEST: i64 = -32600;
79const METHOD_NOT_FOUND: i64 = -32601;
80const INVALID_PARAMS: i64 = -32602;
81
82/// What the client is told this server is for, at handshake time. It is the
83/// only chance to say *when* to reach for memory before the model has to
84/// decide.
85const INSTRUCTIONS: &str = "\
86recall-echo is this agent's own long-term memory: a knowledge graph of entities, \
87relationships and conversation fragments built from previous sessions, with Bayesian \
88confidence on every relationship. None of it is loaded automatically — memory is written \
89when a session ends and read only when one of these tools is called.
90
91Call recall_query before answering anything that depends on earlier sessions: the user's \
92established preferences and setup, decisions already made, projects already discussed, or \
93any reference to \"what we did\" that is not in the current conversation. Prefer asking \
94memory over asking the user to repeat themselves. Every tool here is read-only and cheap; \
95calling one speculatively costs nothing but tokens.";
96
97// ── Backend ──────────────────────────────────────────────────────────────
98
99/// The graph operations this server runs tools against.
100///
101/// One method, one meaning: hand a daemon [`Request`] over, get its JSON back.
102/// The indirection exists so the protocol layer can be exercised without a
103/// store, an embedding model or a daemon.
104#[async_trait::async_trait]
105pub trait GraphBackend: Send + Sync {
106    /// Run a graph operation and return the daemon's `data` payload.
107    async fn execute(&self, request: &Request) -> Result<Value, RecallError>;
108}
109
110/// The real backend: the graph daemon for a memory directory.
111#[derive(Debug, Clone)]
112pub struct DaemonBackend {
113    memory_dir: PathBuf,
114}
115
116impl DaemonBackend {
117    #[must_use]
118    pub fn new(memory_dir: impl Into<PathBuf>) -> Self {
119        Self {
120            memory_dir: memory_dir.into(),
121        }
122    }
123}
124
125#[async_trait::async_trait]
126impl GraphBackend for DaemonBackend {
127    async fn execute(&self, request: &Request) -> Result<Value, RecallError> {
128        serve_client::execute(&self.memory_dir, request).await
129    }
130}
131
132// ── Wire types ───────────────────────────────────────────────────────────
133
134/// An incoming JSON-RPC message. A message without an `id` is a notification
135/// and is never answered.
136#[derive(Debug, Deserialize)]
137struct RpcMessage {
138    jsonrpc: String,
139    #[serde(default)]
140    id: Option<Value>,
141    method: String,
142    #[serde(default)]
143    params: Option<Value>,
144}
145
146/// A JSON-RPC error, as returned in the `error` member of a response.
147#[derive(Debug, Clone, PartialEq)]
148pub struct RpcError {
149    code: i64,
150    message: String,
151    data: Option<Value>,
152}
153
154impl RpcError {
155    fn new(code: i64, message: impl Into<String>) -> Self {
156        Self {
157            code,
158            message: message.into(),
159            data: None,
160        }
161    }
162
163    fn with_data(mut self, data: Value) -> Self {
164        self.data = Some(data);
165        self
166    }
167
168    fn to_value(&self) -> Value {
169        let mut error = json!({ "code": self.code, "message": self.message });
170        if let Some(data) = &self.data {
171            error["data"] = data.clone();
172        }
173        error
174    }
175}
176
177fn success(id: Value, result: Value) -> Value {
178    json!({ "jsonrpc": "2.0", "id": id, "result": result })
179}
180
181fn failure(id: Value, error: &RpcError) -> Value {
182    json!({ "jsonrpc": "2.0", "id": id, "error": error.to_value() })
183}
184
185// ── Server ───────────────────────────────────────────────────────────────
186
187/// An MCP server over some [`GraphBackend`].
188///
189/// Stateless by design: it does not require `initialize` before answering
190/// `tools/list`, because refusing would only turn a client's ordering bug into
191/// a silent memory outage. Nothing it returns depends on connection state.
192#[derive(Debug, Clone)]
193pub struct McpServer<B> {
194    backend: B,
195    server_version: String,
196}
197
198impl<B: GraphBackend> McpServer<B> {
199    #[must_use]
200    pub fn new(backend: B) -> Self {
201        Self {
202            backend,
203            server_version: env!("CARGO_PKG_VERSION").to_string(),
204        }
205    }
206
207    /// The backend this server runs tools against.
208    #[must_use]
209    pub fn backend(&self) -> &B {
210        &self.backend
211    }
212
213    /// Handle one line of the transport, returning the message to write back.
214    ///
215    /// `None` means "say nothing": a notification, or a batch of them.
216    pub async fn handle_line(&self, line: &str) -> Option<Value> {
217        let incoming: Value = match serde_json::from_str(line) {
218            Ok(value) => value,
219            Err(err) => {
220                return Some(failure(
221                    Value::Null,
222                    &RpcError::new(PARSE_ERROR, format!("invalid JSON: {err}")),
223                ))
224            }
225        };
226
227        match incoming {
228            Value::Array(messages) if messages.is_empty() => Some(failure(
229                Value::Null,
230                &RpcError::new(INVALID_REQUEST, "a batch must not be empty"),
231            )),
232            Value::Array(messages) => {
233                let mut responses = Vec::with_capacity(messages.len());
234                for message in messages {
235                    if let Some(response) = self.handle_message(message).await {
236                        responses.push(response);
237                    }
238                }
239                (!responses.is_empty()).then_some(Value::Array(responses))
240            }
241            other => self.handle_message(other).await,
242        }
243    }
244
245    async fn handle_message(&self, message: Value) -> Option<Value> {
246        // Recovered before parsing so a structurally invalid request can still
247        // be answered against the id the client is waiting on.
248        let id = message.get("id").cloned().unwrap_or(Value::Null);
249
250        // A structurally invalid message is not a notification, even without
251        // an id: JSON-RPC 2.0 answers it against a null id rather than
252        // leaving the client to time out.
253        let request: RpcMessage = match serde_json::from_value(message) {
254            Ok(request) => request,
255            Err(err) => {
256                return Some(failure(
257                    id,
258                    &RpcError::new(INVALID_REQUEST, format!("invalid JSON-RPC request: {err}")),
259                ))
260            }
261        };
262
263        if request.jsonrpc != "2.0" {
264            return Some(failure(
265                id,
266                &RpcError::new(
267                    INVALID_REQUEST,
268                    format!(
269                        "unsupported JSON-RPC version `{}`; this server speaks 2.0",
270                        request.jsonrpc
271                    ),
272                ),
273            ));
274        }
275
276        // A well-formed notification is never answered, whatever it carries.
277        if request.method.starts_with("notifications/") || request.id.is_none() {
278            return None;
279        }
280        let id = request.id.unwrap_or(Value::Null);
281
282        let result = self.dispatch(&request.method, request.params).await;
283        Some(match result {
284            Ok(value) => success(id, value),
285            Err(error) => failure(id, &error),
286        })
287    }
288
289    async fn dispatch(&self, method: &str, params: Option<Value>) -> Result<Value, RpcError> {
290        match method {
291            "initialize" => Ok(self.initialize(params)),
292            "ping" => Ok(json!({})),
293            "tools/list" => self.list_tools(params),
294            "tools/call" => self.call_tool(params).await,
295            other => Err(
296                RpcError::new(METHOD_NOT_FOUND, format!("unknown method `{other}`")).with_data(
297                    json!({
298                        "supported": ["initialize", "ping", "tools/list", "tools/call"]
299                    }),
300                ),
301            ),
302        }
303    }
304
305    fn initialize(&self, params: Option<Value>) -> Value {
306        let requested = params
307            .as_ref()
308            .and_then(|params| params.get("protocolVersion"))
309            .and_then(Value::as_str);
310
311        json!({
312            "protocolVersion": negotiate_protocol_version(requested),
313            "capabilities": { "tools": { "listChanged": false } },
314            "serverInfo": {
315                "name": "recall-echo",
316                "title": "recall-echo memory",
317                "version": self.server_version,
318            },
319            "instructions": INSTRUCTIONS,
320        })
321    }
322
323    fn list_tools(&self, params: Option<Value>) -> Result<Value, RpcError> {
324        // The catalogue is static and fits in one page, so no cursor we could
325        // have issued is ever valid.
326        if let Some(cursor) = params.as_ref().and_then(|params| params.get("cursor")) {
327            if !cursor.is_null() {
328                return Err(RpcError::new(
329                    INVALID_PARAMS,
330                    "the tool list is a single page; no cursor is valid",
331                ));
332            }
333        }
334
335        let catalogue: Vec<Value> = tools::ALL.into_iter().map(Tool::descriptor).collect();
336        Ok(json!({ "tools": catalogue }))
337    }
338
339    async fn call_tool(&self, params: Option<Value>) -> Result<Value, RpcError> {
340        let params = params.unwrap_or(Value::Null);
341        let Some(name) = params.get("name").and_then(Value::as_str) else {
342            return Err(RpcError::new(
343                INVALID_PARAMS,
344                "tools/call requires a `name` naming the tool to run",
345            ));
346        };
347        let Some(tool) = Tool::from_name(name) else {
348            return Err(
349                RpcError::new(INVALID_PARAMS, format!("unknown tool `{name}`")).with_data(json!({
350                    "available": tools::ALL.map(Tool::name),
351                })),
352            );
353        };
354
355        let arguments = params.get("arguments").cloned().unwrap_or(Value::Null);
356        let request = match tool.request(&arguments) {
357            Ok(request) => request,
358            Err(invalid) => return Ok(tool_error(invalid.to_string())),
359        };
360
361        match self.backend.execute(&request).await {
362            Ok(data) => Ok(match render(&request, data) {
363                Ok(text) => tool_success(text),
364                Err(err) => tool_error(format!(
365                    "{} could not read the memory store's answer: {err}",
366                    tool.name()
367                )),
368            }),
369            Err(err) => Ok(tool_error(explain(tool, &err))),
370        }
371    }
372}
373
374/// Echo the client's revision when we speak it, otherwise offer ours.
375#[must_use]
376pub fn negotiate_protocol_version(requested: Option<&str>) -> &str {
377    match requested {
378        Some(version) if SUPPORTED_PROTOCOL_VERSIONS.contains(&version) => version,
379        _ => PREFERRED_PROTOCOL_VERSION,
380    }
381}
382
383fn tool_success(text: String) -> Value {
384    json!({
385        "content": [{ "type": "text", "text": text }],
386        "isError": false,
387    })
388}
389
390/// A failure the model can act on: reported in the result, not as a JSON-RPC
391/// error, so the client passes it back to the model instead of swallowing it.
392fn tool_error(text: String) -> Value {
393    json!({
394        "content": [{ "type": "text", "text": text }],
395        "isError": true,
396    })
397}
398
399/// Render a daemon payload as the text its tool promised.
400fn render(request: &Request, data: Value) -> Result<String, serde_json::Error> {
401    let text = match request {
402        Request::Search(args) => {
403            let results: Vec<ScoredEntity> = serde_json::from_value(data)?;
404            render::entities(&args.query, &results)
405        }
406        Request::Query(args) => {
407            let result: QueryResult = serde_json::from_value(data)?;
408            render::query_result(&args.query, &result)
409        }
410        Request::SearchEpisodes(args) => {
411            let results: Vec<EpisodeSearchResult> = serde_json::from_value(data)?;
412            render::episodes(&args.query, &results)
413        }
414        Request::Traverse(args) => {
415            let tree: TraversalNode = serde_json::from_value(data)?;
416            render::traversal(&args.entity, args.depth, &tree)
417        }
418        Request::Status => {
419            let stats: GraphStats = serde_json::from_value(data)?;
420            render::status(&stats)
421        }
422        Request::Overview(_) => {
423            let overview: MemoryOverview = serde_json::from_value(data)?;
424            render::overview(&overview)
425        }
426        // No tool builds any other request; a payload we cannot name is
427        // still better returned than dropped.
428        _ => serde_json::to_string_pretty(&data)?,
429    };
430    Ok(text)
431}
432
433/// A tool failure said in terms the model can do something about.
434fn explain(tool: Tool, error: &RecallError) -> String {
435    let mut message = format!("{} failed: {error}", tool.name());
436    if let Some(hint) = hint(error) {
437        message.push(' ');
438        message.push_str(hint);
439    }
440    message
441}
442
443fn hint(error: &RecallError) -> Option<&'static str> {
444    match error {
445        RecallError::Remote { code, .. } => match code.as_str() {
446            "not_found" => Some(
447                "Names must match an existing entity exactly — use recall_search or \
448                 recall_query to find the exact name first.",
449            ),
450            "embedding" => Some(
451                "The embedding model could not be loaded, so semantic recall is unavailable \
452                 until it is; do not retry this session.",
453            ),
454            "locked" => Some(
455                "Another recall-echo operation is holding the memory store; the same call \
456                 should succeed shortly.",
457            ),
458            _ => None,
459        },
460        RecallError::NotInitialized(_) => Some(
461            "Memory is not initialised in this directory; `recall-echo init` creates it. \
462             Do not retry until it is.",
463        ),
464        RecallError::Daemon(_) => Some(
465            "The memory daemon could not be reached, so memory is unavailable — continue \
466             without it rather than retrying.",
467        ),
468        _ => None,
469    }
470}
471
472// ── stdio transport ──────────────────────────────────────────────────────
473
474/// A message reader capped at [`MAX_MESSAGE_BYTES`] per message.
475type MessageLines = tokio::io::Lines<BufReader<tokio::io::Take<tokio::io::Stdin>>>;
476
477/// Serve MCP over stdin/stdout until the client closes the connection.
478///
479/// Messages are handled one at a time. MCP permits interleaved responses, but
480/// the daemon serializes graph work anyway, so concurrency here would buy
481/// nothing and cost an interleaved-write hazard on stdout.
482pub async fn run(memory_dir: &Path) -> Result<(), RecallError> {
483    serve(McpServer::new(DaemonBackend::new(memory_dir))).await
484}
485
486async fn serve<B: GraphBackend>(server: McpServer<B>) -> Result<(), RecallError> {
487    let mut lines = BufReader::new(tokio::io::stdin().take(MAX_MESSAGE_BYTES)).lines();
488    let mut stdout = tokio::io::stdout();
489
490    loop {
491        let line = match lines.next_line().await {
492            Ok(Some(line)) => line,
493            // The client closed stdin: the specified way to shut a stdio
494            // server down.
495            Ok(None) => return Ok(()),
496            Err(err) => return Err(err.into()),
497        };
498
499        if message_cap_reached(&mut lines) {
500            let response = failure(
501                Value::Null,
502                &RpcError::new(
503                    INVALID_REQUEST,
504                    format!("message exceeds the {MAX_MESSAGE_BYTES}-byte limit"),
505                ),
506            );
507            write_message(&mut stdout, &response).await?;
508            return Ok(());
509        }
510        recharge_message_cap(&mut lines);
511
512        if line.trim().is_empty() {
513            continue;
514        }
515        if let Some(response) = server.handle_line(&line).await {
516            write_message(&mut stdout, &response).await?;
517        }
518    }
519}
520
521fn message_cap_reached(lines: &mut MessageLines) -> bool {
522    lines.get_mut().get_mut().limit() == 0
523}
524
525fn recharge_message_cap(lines: &mut MessageLines) {
526    lines.get_mut().get_mut().set_limit(MAX_MESSAGE_BYTES);
527}
528
529async fn write_message(stdout: &mut tokio::io::Stdout, message: &Value) -> Result<(), RecallError> {
530    let mut line = serde_json::to_vec(message)?;
531    line.push(b'\n');
532    stdout.write_all(&line).await?;
533    stdout.flush().await?;
534    Ok(())
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn the_preferred_version_is_one_we_support() {
543        assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&PREFERRED_PROTOCOL_VERSION));
544        assert_eq!(SUPPORTED_PROTOCOL_VERSIONS[0], PREFERRED_PROTOCOL_VERSION);
545    }
546
547    #[test]
548    fn a_supported_version_is_echoed_back() {
549        for version in SUPPORTED_PROTOCOL_VERSIONS {
550            assert_eq!(negotiate_protocol_version(Some(version)), *version);
551        }
552    }
553
554    #[test]
555    fn an_unknown_version_falls_back_to_ours() {
556        assert_eq!(
557            negotiate_protocol_version(Some("1900-01-01")),
558            PREFERRED_PROTOCOL_VERSION
559        );
560        assert_eq!(negotiate_protocol_version(None), PREFERRED_PROTOCOL_VERSION);
561    }
562
563    #[test]
564    fn hints_are_attached_only_where_they_help() {
565        let not_found = RecallError::Remote {
566            code: "not_found".into(),
567            message: "entity not found: Rust".into(),
568        };
569        let text = explain(Tool::Traverse, &not_found);
570        assert!(text.starts_with("recall_traverse failed:"), "{text}");
571        assert!(text.contains("recall_search"), "{text}");
572
573        let unknown = RecallError::Remote {
574            code: "db".into(),
575            message: "connection reset".into(),
576        };
577        assert_eq!(
578            explain(Tool::Status, &unknown),
579            "recall_status failed: connection reset"
580        );
581    }
582
583    #[test]
584    fn an_unrenderable_payload_is_dumped_rather_than_dropped() {
585        let text = render(&Request::Hello, json!({ "version": "3.13.0" })).unwrap();
586        assert!(text.contains("3.13.0"), "{text}");
587    }
588}