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