1pub 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
61pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] =
66 &["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
67
68pub const PREFERRED_PROTOCOL_VERSION: &str = "2025-11-25";
70
71const MAX_MESSAGE_BYTES: u64 = 4 * 1024 * 1024;
75
76const PARSE_ERROR: i64 = -32700;
78const INVALID_REQUEST: i64 = -32600;
79const METHOD_NOT_FOUND: i64 = -32601;
80const INVALID_PARAMS: i64 = -32602;
81
82const 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#[async_trait::async_trait]
105pub trait GraphBackend: Send + Sync {
106 async fn execute(&self, request: &Request) -> Result<Value, RecallError>;
108}
109
110#[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#[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#[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#[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 #[must_use]
209 pub fn backend(&self) -> &B {
210 &self.backend
211 }
212
213 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 let id = message.get("id").cloned().unwrap_or(Value::Null);
249
250 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 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 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#[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
390fn tool_error(text: String) -> Value {
393 json!({
394 "content": [{ "type": "text", "text": text }],
395 "isError": true,
396 })
397}
398
399fn 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 _ => serde_json::to_string_pretty(&data)?,
429 };
430 Ok(text)
431}
432
433fn 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
472type MessageLines = tokio::io::Lines<BufReader<tokio::io::Take<tokio::io::Stdin>>>;
476
477pub 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 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, ¬_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}