Skip to main content

recall_echo/mcp/
tools.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//! The tool catalogue exposed over MCP.
6//!
7//! Each tool is a thin, well-described face on one daemon operation. The
8//! descriptions are the interface: an agent picks a tool by reading them, so
9//! they say what the tool answers and when to prefer a sibling, not how it is
10//! implemented.
11//!
12//! Nothing here touches the store. A tool turns arguments into a
13//! [`Request`]; [`crate::mcp`] runs it through the daemon.
14
15use serde_json::{json, Value};
16
17use crate::serve::{
18    OverviewArgs, QueryArgs, Request, SearchArgs, SearchEpisodesArgs, TraverseArgs,
19};
20
21/// Result limits an agent may ask for. The daemon clamps far higher; these
22/// bounds keep a single tool result inside a sane share of the context window.
23const MIN_LIMIT: u64 = 1;
24const MAX_LIMIT: u64 = 50;
25const MAX_EPISODE_LIMIT: u64 = 20;
26/// Deepest traversal a tool may ask for. Expansion is exponential in the
27/// branching factor, and the rendered tree has to stay readable.
28const MAX_TRAVERSE_DEPTH: u64 = 4;
29
30const DEFAULT_ENTITY_LIMIT: u64 = 8;
31const DEFAULT_EPISODE_LIMIT: u64 = 5;
32const DEFAULT_TRAVERSE_DEPTH: u64 = 2;
33/// One hop of graph expansion around the semantic hits — the same default the
34/// `graph query` CLI uses. Deeper expansion buys noise faster than recall.
35const QUERY_GRAPH_DEPTH: u32 = 1;
36
37/// A tool call whose arguments the agent can fix by trying again.
38///
39/// Reported as a tool execution error (`isError: true`), never as a JSON-RPC
40/// error: the model is the one who can correct it.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct InvalidArguments(pub String);
43
44impl std::fmt::Display for InvalidArguments {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.write_str(&self.0)
47    }
48}
49
50/// A memory tool an MCP client can call.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Tool {
53    /// Semantic entity search.
54    Search,
55    /// Hybrid retrieval: semantic + graph expansion + optional episodes.
56    Query,
57    /// Relationships out of one named entity.
58    Traverse,
59    /// Episode (conversation-fragment) search.
60    Episodes,
61    /// What memory holds, without being asked about anything in particular.
62    Overview,
63    /// Graph counts.
64    Status,
65}
66
67/// Every tool, in the order `tools/list` reports them.
68///
69/// The order is fixed: clients cache the tool list, and a stable order keeps
70/// their prompt caches warm.
71pub const ALL: [Tool; 6] = [
72    Tool::Query,
73    Tool::Search,
74    Tool::Episodes,
75    Tool::Traverse,
76    Tool::Overview,
77    Tool::Status,
78];
79
80impl Tool {
81    /// Look a tool up by its wire name.
82    #[must_use]
83    pub fn from_name(name: &str) -> Option<Self> {
84        ALL.into_iter().find(|tool| tool.name() == name)
85    }
86
87    /// The name an agent calls this tool by.
88    #[must_use]
89    pub const fn name(self) -> &'static str {
90        match self {
91            Tool::Search => "recall_search",
92            Tool::Query => "recall_query",
93            Tool::Traverse => "recall_traverse",
94            Tool::Episodes => "recall_episodes",
95            Tool::Overview => "recall_overview",
96            Tool::Status => "recall_status",
97        }
98    }
99
100    /// Human-readable name, for client UIs that show one.
101    #[must_use]
102    pub const fn title(self) -> &'static str {
103        match self {
104            Tool::Search => "Search memory for entities",
105            Tool::Query => "Recall from memory",
106            Tool::Traverse => "Explore an entity's relationships",
107            Tool::Episodes => "Search past conversations",
108            Tool::Overview => "What memory holds",
109            Tool::Status => "Memory graph status",
110        }
111    }
112
113    /// What the tool answers, and when to prefer a sibling.
114    #[must_use]
115    pub const fn description(self) -> &'static str {
116        match self {
117            Tool::Query => {
118                "Recall what you already know about something, from your own long-term memory of \
119                 past sessions. This is the default memory lookup and usually the right first \
120                 call. It runs a semantic search over the distilled knowledge graph, expands one \
121                 hop along the relationships around each hit, and (by default) also returns the \
122                 conversation fragments the knowledge came from. Reach for it whenever the user \
123                 refers to something outside this conversation — \"the approach we settled on\", \
124                 \"like we did last time\", \"my usual setup\" — or before asserting that you \
125                 have no prior context. Returns each entity with its type, a one-line abstract, \
126                 a retrieval score, and whether it was matched directly or pulled in through a \
127                 relationship."
128            }
129            Tool::Search => {
130                "Semantic search over the entities in your long-term memory: the people, \
131                 projects, tools, services, decisions, preferences and concepts distilled from \
132                 past conversations. Matching is by meaning, not keywords, so \"how do we ship \
133                 releases\" finds entities about CI, tagging and deployment. Returns names, \
134                 types, abstracts and retrieval scores — a compact map of what is known. Use \
135                 this when you want the inventory of relevant entities and nothing more; use \
136                 recall_query when you also want their relationships and the original \
137                 conversation text."
138            }
139            Tool::Episodes => {
140                "Search the raw conversation fragments (episodes) stored in memory, rather than \
141                 the distilled entities. Each result is a dated chunk of a past session with its \
142                 session id and text. Use it when you need what was actually said — exact \
143                 wording, a command, a number, a snippet of code — instead of a summarised fact, \
144                 or when recall_search and recall_query come back empty because the topic was \
145                 discussed but never distilled into an entity. Episodes are the ground truth the \
146                 entities were derived from."
147            }
148            Tool::Traverse => {
149                "Walk the relationships out of one named entity and show them as a tree, with \
150                 each edge's confidence. Use it after recall_search or recall_query has given \
151                 you an exact entity name, when you need the structure around a fact rather than \
152                 more facts: what a project depends on, who decided what, which choice \
153                 superseded which. The entity name must match an existing entity exactly. Edges \
154                 annotated with a percentage are ones the graph is not fully certain of — that \
155                 number is accumulated Bayesian evidence, not a guess — and edges marked \
156                 [superseded] describe something that was true once and no longer is."
157            }
158            Tool::Overview => {
159                "Read out what memory actually holds, without querying for anything in \
160                 particular: the strongest entities of each type, how firmly the relationships \
161                 between them are believed, the least certain of those relationships, and the \
162                 ones whose confidence rests largely on the agent having repeated itself. Use it \
163                 at the start of working with a user you have no context on, when the user asks \
164                 what you remember about them, or before assuming memory is empty. Unlike \
165                 recall_status, which only counts rows, this returns the content — and it is the \
166                 only tool that surfaces where memory is unsure, which is worth saying out loud \
167                 rather than presenting an uncertain fact as settled. Takes no arguments; ask \
168                 recall_query instead when you have a specific subject in mind."
169            }
170            Tool::Status => {
171                "Report the size and shape of the memory graph: how many entities, relationships \
172                 and conversation episodes it holds, plus the entity counts by type. Use it to \
173                 tell an empty memory apart from a failed lookup — if a recall returns nothing, \
174                 this says whether that means \"never discussed\" or \"nothing has been ingested \
175                 yet\". Takes no arguments."
176            }
177        }
178    }
179
180    /// JSON Schema for this tool's arguments (JSON Schema 2020-12).
181    #[must_use]
182    pub fn input_schema(self) -> Value {
183        match self {
184            Tool::Search => object_schema(
185                json!({
186                    "query": {
187                        "type": "string",
188                        "description": "What to look for, in natural language. A question or a \
189                                        topic both work; matching is on meaning, not wording."
190                    },
191                    "limit": limit_schema(
192                        MAX_LIMIT,
193                        DEFAULT_ENTITY_LIMIT,
194                        "Maximum entities to return.",
195                    ),
196                }),
197                &["query"],
198            ),
199            Tool::Query => object_schema(
200                json!({
201                    "query": {
202                        "type": "string",
203                        "description": "What you are trying to remember, in natural language. \
204                                        Phrase it as the actual question — the whole query is \
205                                        embedded, so more context retrieves better."
206                    },
207                    "limit": limit_schema(
208                        MAX_LIMIT,
209                        DEFAULT_ENTITY_LIMIT,
210                        "Maximum entities to return.",
211                    ),
212                    "include_episodes": {
213                        "type": "boolean",
214                        "description": "Also return the conversation fragments behind the \
215                                        entities. Defaults to true; set false when you only \
216                                        need the distilled facts and want a shorter result."
217                    },
218                }),
219                &["query"],
220            ),
221            Tool::Episodes => object_schema(
222                json!({
223                    "query": {
224                        "type": "string",
225                        "description": "What was said, in natural language. Matching is on \
226                                        meaning, so paraphrasing the topic works."
227                    },
228                    "limit": limit_schema(
229                        MAX_EPISODE_LIMIT,
230                        DEFAULT_EPISODE_LIMIT,
231                        "Maximum conversation fragments to return. Fragments are long; ask for \
232                         few.",
233                    ),
234                }),
235                &["query"],
236            ),
237            Tool::Traverse => object_schema(
238                json!({
239                    "entity": {
240                        "type": "string",
241                        "description": "Exact name of the entity to start from, as returned by \
242                                        recall_search or recall_query."
243                    },
244                    "depth": {
245                        "type": "integer",
246                        "minimum": MIN_LIMIT,
247                        "maximum": MAX_TRAVERSE_DEPTH,
248                        "description": "How many relationship hops to follow (1-4). Defaults to \
249                                        2. Each hop multiplies the size of the answer."
250                    },
251                }),
252                &["entity"],
253            ),
254            Tool::Overview | Tool::Status => json!({
255                "type": "object",
256                "properties": {},
257                "additionalProperties": false
258            }),
259        }
260    }
261
262    /// The full `Tool` object `tools/list` reports.
263    #[must_use]
264    pub fn descriptor(self) -> Value {
265        json!({
266            "name": self.name(),
267            "title": self.title(),
268            "description": self.description(),
269            "inputSchema": self.input_schema(),
270        })
271    }
272
273    /// Turn call arguments into the daemon request that answers them.
274    ///
275    /// `arguments` is whatever the client sent; a missing `arguments` member
276    /// arrives here as [`Value::Null`].
277    pub fn request(self, arguments: &Value) -> Result<Request, InvalidArguments> {
278        let args = normalize(self, arguments)?;
279        let request = match self {
280            Tool::Search => Request::Search(SearchArgs {
281                query: required_text(&args, "query")?,
282                limit: bounded_int(&args, "limit", DEFAULT_ENTITY_LIMIT, MAX_LIMIT)? as usize,
283                entity_type: None,
284                keyword: None,
285            }),
286            Tool::Query => Request::Query(QueryArgs {
287                query: required_text(&args, "query")?,
288                limit: bounded_int(&args, "limit", DEFAULT_ENTITY_LIMIT, MAX_LIMIT)? as usize,
289                entity_type: None,
290                keyword: None,
291                depth: QUERY_GRAPH_DEPTH,
292                episodes: flag(&args, "include_episodes", true)?,
293            }),
294            Tool::Episodes => Request::SearchEpisodes(SearchEpisodesArgs {
295                query: required_text(&args, "query")?,
296                limit: bounded_int(&args, "limit", DEFAULT_EPISODE_LIMIT, MAX_EPISODE_LIMIT)?
297                    as usize,
298            }),
299            Tool::Traverse => Request::Traverse(TraverseArgs {
300                entity: required_text(&args, "entity")?,
301                depth: bounded_int(&args, "depth", DEFAULT_TRAVERSE_DEPTH, MAX_TRAVERSE_DEPTH)?
302                    as u32,
303                type_filter: None,
304            }),
305            // An overview an agent has to page through is not an overview:
306            // the daemon's default listing size is the right one, always.
307            Tool::Overview => Request::Overview(OverviewArgs { per_type: 0 }),
308            Tool::Status => Request::Status,
309        };
310        Ok(request)
311    }
312}
313
314// ── Schema helpers ───────────────────────────────────────────────────────
315
316fn object_schema(properties: Value, required: &[&str]) -> Value {
317    json!({
318        "type": "object",
319        "properties": properties,
320        "required": required,
321        "additionalProperties": false
322    })
323}
324
325fn limit_schema(max: u64, default: u64, purpose: &str) -> Value {
326    json!({
327        "type": "integer",
328        "minimum": MIN_LIMIT,
329        "maximum": max,
330        "description": format!("{purpose} Between {MIN_LIMIT} and {max}; defaults to {default}."),
331    })
332}
333
334// ── Argument helpers ─────────────────────────────────────────────────────
335
336/// Accept the argument object, an absent one, or the empty one.
337///
338/// Anything else is a shape the agent can fix.
339fn normalize(tool: Tool, arguments: &Value) -> Result<Value, InvalidArguments> {
340    match arguments {
341        Value::Object(_) => Ok(arguments.clone()),
342        Value::Null => Ok(json!({})),
343        other => Err(InvalidArguments(format!(
344            "{}: `arguments` must be a JSON object, got {}",
345            tool.name(),
346            type_name(other)
347        ))),
348    }
349}
350
351fn required_text(args: &Value, field: &str) -> Result<String, InvalidArguments> {
352    match args.get(field) {
353        Some(Value::String(text)) if !text.trim().is_empty() => Ok(text.trim().to_string()),
354        Some(Value::String(_)) => Err(InvalidArguments(format!(
355            "`{field}` must not be empty — say what you are looking for"
356        ))),
357        Some(other) => Err(InvalidArguments(format!(
358            "`{field}` must be a string, got {}",
359            type_name(other)
360        ))),
361        None => Err(InvalidArguments(format!("`{field}` is required"))),
362    }
363}
364
365/// A whole number in `MIN_LIMIT..=max`, defaulting when absent.
366///
367/// Out-of-range numbers are clamped rather than rejected: asking for more
368/// than the ceiling is a preference, not a mistake. A non-integer is a
369/// mistake, and says so.
370fn bounded_int(args: &Value, field: &str, default: u64, max: u64) -> Result<u64, InvalidArguments> {
371    match args.get(field) {
372        None | Some(Value::Null) => Ok(default),
373        Some(Value::Number(number)) => match number.as_u64() {
374            Some(value) => Ok(value.clamp(MIN_LIMIT, max)),
375            // Negative or fractional: as_i64 catches the negatives, and
376            // anything else is a float the caller meant as a count.
377            None if number.as_i64().is_some() => Ok(MIN_LIMIT),
378            None => Err(InvalidArguments(format!(
379                "`{field}` must be a whole number between {MIN_LIMIT} and {max}"
380            ))),
381        },
382        Some(other) => Err(InvalidArguments(format!(
383            "`{field}` must be a whole number between {MIN_LIMIT} and {max}, got {}",
384            type_name(other)
385        ))),
386    }
387}
388
389fn flag(args: &Value, field: &str, default: bool) -> Result<bool, InvalidArguments> {
390    match args.get(field) {
391        None | Some(Value::Null) => Ok(default),
392        Some(Value::Bool(value)) => Ok(*value),
393        Some(other) => Err(InvalidArguments(format!(
394            "`{field}` must be true or false, got {}",
395            type_name(other)
396        ))),
397    }
398}
399
400fn type_name(value: &Value) -> &'static str {
401    match value {
402        Value::Null => "null",
403        Value::Bool(_) => "a boolean",
404        Value::Number(_) => "a number",
405        Value::String(_) => "a string",
406        Value::Array(_) => "an array",
407        Value::Object(_) => "an object",
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn every_tool_resolves_from_its_own_name() {
417        for tool in ALL {
418            assert_eq!(Tool::from_name(tool.name()), Some(tool));
419        }
420    }
421
422    #[test]
423    fn unknown_names_do_not_resolve() {
424        assert_eq!(Tool::from_name("recall_forget"), None);
425        assert_eq!(Tool::from_name(""), None);
426    }
427
428    #[test]
429    fn descriptors_carry_a_schema_and_a_description() {
430        for tool in ALL {
431            let descriptor = tool.descriptor();
432            assert_eq!(descriptor["name"], tool.name());
433            assert_eq!(descriptor["inputSchema"]["type"], "object");
434            assert!(
435                descriptor["description"].as_str().unwrap().len() > 80,
436                "{} needs a description an agent can choose from",
437                tool.name()
438            );
439        }
440    }
441
442    #[test]
443    fn search_defaults_the_limit() {
444        let request = Tool::Search.request(&json!({ "query": "rust" })).unwrap();
445        assert_eq!(
446            request,
447            Request::Search(SearchArgs {
448                query: "rust".into(),
449                limit: DEFAULT_ENTITY_LIMIT as usize,
450                entity_type: None,
451                keyword: None,
452            })
453        );
454    }
455
456    #[test]
457    fn query_includes_episodes_unless_told_otherwise() {
458        let Request::Query(args) = Tool::Query.request(&json!({ "query": "deploys" })).unwrap()
459        else {
460            panic!("expected a query request");
461        };
462        assert!(args.episodes);
463        assert_eq!(args.depth, QUERY_GRAPH_DEPTH);
464
465        let Request::Query(args) = Tool::Query
466            .request(&json!({ "query": "deploys", "include_episodes": false }))
467            .unwrap()
468        else {
469            panic!("expected a query request");
470        };
471        assert!(!args.episodes);
472    }
473
474    #[test]
475    fn status_ignores_arguments_and_absent_arguments() {
476        assert_eq!(Tool::Status.request(&Value::Null).unwrap(), Request::Status);
477        assert_eq!(
478            Tool::Status.request(&json!({ "noise": 1 })).unwrap(),
479            Request::Status
480        );
481    }
482
483    #[test]
484    fn oversized_limits_clamp_instead_of_failing() {
485        let Request::Search(args) = Tool::Search
486            .request(&json!({ "query": "rust", "limit": 9_000 }))
487            .unwrap()
488        else {
489            panic!("expected a search request");
490        };
491        assert_eq!(args.limit, MAX_LIMIT as usize);
492
493        let Request::Traverse(args) = Tool::Traverse
494            .request(&json!({ "entity": "Rust", "depth": 0 }))
495            .unwrap()
496        else {
497            panic!("expected a traverse request");
498        };
499        assert_eq!(args.depth, MIN_LIMIT as u32);
500    }
501
502    #[test]
503    fn missing_required_arguments_are_reported_by_name() {
504        let error = Tool::Search.request(&json!({})).unwrap_err();
505        assert!(error.to_string().contains("`query` is required"), "{error}");
506
507        let error = Tool::Traverse.request(&json!({ "depth": 2 })).unwrap_err();
508        assert!(
509            error.to_string().contains("`entity` is required"),
510            "{error}"
511        );
512    }
513
514    #[test]
515    fn blank_and_mistyped_arguments_are_rejected() {
516        let error = Tool::Search.request(&json!({ "query": "  " })).unwrap_err();
517        assert!(error.to_string().contains("must not be empty"), "{error}");
518
519        let error = Tool::Search.request(&json!({ "query": 12 })).unwrap_err();
520        assert!(error.to_string().contains("must be a string"), "{error}");
521
522        let error = Tool::Search
523            .request(&json!({ "query": "rust", "limit": "many" }))
524            .unwrap_err();
525        assert!(error.to_string().contains("whole number"), "{error}");
526
527        let error = Tool::Query
528            .request(&json!({ "query": "rust", "include_episodes": "yes" }))
529            .unwrap_err();
530        assert!(error.to_string().contains("true or false"), "{error}");
531    }
532
533    #[test]
534    fn non_object_arguments_are_rejected() {
535        let error = Tool::Search.request(&json!([1, 2, 3])).unwrap_err();
536        assert!(
537            error.to_string().contains("must be a JSON object"),
538            "{error}"
539        );
540    }
541
542    #[test]
543    fn schemas_declare_their_required_arguments() {
544        assert_eq!(Tool::Search.input_schema()["required"], json!(["query"]));
545        assert_eq!(Tool::Traverse.input_schema()["required"], json!(["entity"]));
546        assert_eq!(
547            Tool::Status.input_schema()["additionalProperties"],
548            json!(false)
549        );
550    }
551}