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