1pub 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
53pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] =
58 &["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
59
60pub const PREFERRED_PROTOCOL_VERSION: &str = "2025-11-25";
62
63const MAX_MESSAGE_BYTES: u64 = 4 * 1024 * 1024;
67
68const PARSE_ERROR: i64 = -32700;
70const INVALID_REQUEST: i64 = -32600;
71const METHOD_NOT_FOUND: i64 = -32601;
72const INVALID_PARAMS: i64 = -32602;
73
74const 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#[async_trait::async_trait]
97pub trait GraphBackend: Send + Sync {
98 async fn execute(&self, request: &Request) -> Result<Value, RecallError>;
100}
101
102#[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#[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#[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#[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 #[must_use]
201 pub fn backend(&self) -> &B {
202 &self.backend
203 }
204
205 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 let id = message.get("id").cloned().unwrap_or(Value::Null);
241
242 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 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 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#[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
382fn tool_error(text: String) -> Value {
385 json!({
386 "content": [{ "type": "text", "text": text }],
387 "isError": true,
388 })
389}
390
391fn 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 _ => serde_json::to_string_pretty(&data)?,
417 };
418 Ok(text)
419}
420
421fn 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
460type MessageLines = tokio::io::Lines<BufReader<tokio::io::Take<tokio::io::Stdin>>>;
464
465pub 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 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, ¬_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}