Skip to main content

trusty_memory/tools/
definitions.rs

1//! MCP `tools/list` schema + server marker for trusty-memory.
2//!
3//! Why: Concentrates the public tool contract (the `tools/list` payload) in
4//! one place so the MCP schema stays auditable and in sync with the handlers.
5//! What: Defines `MemoryMcpServer` and the `tool_definitions{,_with}` schema
6//! builders moved out of the former monolithic `tools.rs` (issue #607).
7//! Test: `tool_definitions_lists_all_tools`,
8//! `tool_definitions_drops_palace_required_when_default_set` in `tools::tests`.
9
10use serde_json::{json, Value};
11
12use super::task_definitions::task_tool_definitions;
13
14/// Marker server type. Reserved for future stateful MCP server impls.
15///
16/// Why: Keep a stable type name while the protocol-loop is implemented at
17/// module level, so external callers can still depend on a server symbol.
18/// What: Zero-sized struct with `new` / `Default`.
19/// Test: `MemoryMcpServer::default()` constructs without panic.
20pub struct MemoryMcpServer;
21
22impl MemoryMcpServer {
23    pub fn new() -> Self {
24        Self
25    }
26}
27
28impl Default for MemoryMcpServer {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34/// MCP `tools/list` response payload.
35///
36/// Why: Claude Code calls `tools/list` once on connect and uses the schema
37/// to drive the tool picker; the schema is the source of truth for arg names.
38/// `palace` is required only when the server has no `--palace` default
39/// configured — when a default is set, the schema omits `palace` from
40/// `required` so clients can drop it.
41/// What: Returns a JSON object `{ "tools": [...] }` with all 10 tool defs.
42/// Test: `tool_definitions_lists_all_tools`,
43/// `tool_definitions_drops_palace_required_when_default_set`.
44pub fn tool_definitions() -> Value {
45    tool_definitions_with(false)
46}
47
48/// Variant of `tool_definitions` aware of whether a default palace is
49/// configured. When `has_default` is true, the `palace` argument is moved
50/// out of the `required` list for every tool that takes it.
51///
52/// Why: Lets `handle_message` emit a schema that matches the running
53/// server's actual contract — clients reading the schema should see exactly
54/// what they need to send.
55/// What: Builds the same shape as `tool_definitions` but with conditional
56/// `required` arrays.
57/// Test: `tool_definitions_drops_palace_required_when_default_set`.
58pub fn tool_definitions_with(has_default: bool) -> Value {
59    let memory_remember_required: Vec<&str> = if has_default {
60        vec!["text"]
61    } else {
62        vec!["palace", "text"]
63    };
64    let memory_recall_required: Vec<&str> = if has_default {
65        vec!["query"]
66    } else {
67        vec!["palace", "query"]
68    };
69    let kg_assert_required: Vec<&str> = if has_default {
70        vec!["subject", "predicate", "object"]
71    } else {
72        vec!["palace", "subject", "predicate", "object"]
73    };
74    let kg_query_required: Vec<&str> = if has_default {
75        vec!["subject"]
76    } else {
77        vec!["palace", "subject"]
78    };
79    let memory_list_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
80    let memory_forget_required: Vec<&str> = if has_default {
81        vec!["drawer_id"]
82    } else {
83        vec!["palace", "drawer_id"]
84    };
85    let palace_info_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
86    let palace_compact_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
87    let memory_note_required: Vec<&str> = if has_default {
88        vec!["content"]
89    } else {
90        vec!["palace", "content"]
91    };
92    // Issue #664: add_alias and discover_aliases both call resolve_palace() but
93    // previously omitted `palace` from their schemas, making them uncallable
94    // without a server-side default. Now follow the memory_remember pattern.
95    let add_alias_required: Vec<&str> = if has_default {
96        vec!["short", "full"]
97    } else {
98        vec!["palace", "short", "full"]
99    };
100    let discover_aliases_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
101    // spec-001 chat-session tools: `palace` is optional only when a server
102    // default is configured, matching the convention used by every other
103    // palace-scoped tool above.
104    let chat_session_palace_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
105    let chat_session_get_required: Vec<&str> = if has_default {
106        vec!["session_id"]
107    } else {
108        vec!["palace", "session_id"]
109    };
110    let chat_session_add_turn_required: Vec<&str> = if has_default {
111        vec!["session_id", "role", "content"]
112    } else {
113        vec!["palace", "session_id", "role", "content"]
114    };
115    let dream_consolidate_room_required: Vec<&str> =
116        if has_default { vec![] } else { vec!["palace"] };
117    // chat_turn_append requires palace + session_id + prompt + response.
118    let chat_turn_append_required: Vec<&str> = if has_default {
119        vec!["session_id", "prompt", "response"]
120    } else {
121        vec!["palace", "session_id", "prompt", "response"]
122    };
123    let chat_session_delete_required: Vec<&str> = if has_default {
124        vec!["session_id"]
125    } else {
126        vec!["palace", "session_id"]
127    };
128
129    let mut result = json!({
130        "tools": [
131            {
132                "name": "memory_remember",
133                "description": "Store a memory (drawer) in a palace room. Content is filtered for signal vs. noise (issue #61): rejects empty/very short content, raw tool/commit output, and code-only blobs. Issue #215: very short standalone content (< 4 words) is silently dropped unless a `context` is supplied, in which case the context is prepended so the stored memory has standalone value. Pass force=true to bypass filtering, or use memory_note for short curated facts.",
134                "inputSchema": {
135                    "type": "object",
136                    "properties": {
137                        "palace":  {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
138                        "text":    {"type": "string", "description": "Memory content"},
139                        "room":    {"type": "string", "description": "Room type (optional)"},
140                        "tags":    {"type": "array", "items": {"type": "string"}},
141                        "force":   {"type": "boolean", "description": "Bypass the signal/noise filter. Use sparingly — intended for explicit operator overrides.", "default": false},
142                        "context": {"type": "string", "description": "Optional surrounding context. When supplied alongside very short content (< 4 words), the context is prepended (separated by `---`) so the stored memory has standalone meaning; without it, short content is dropped (issue #215)."}
143                    },
144                    "required": memory_remember_required,
145                }
146            },
147            {
148                "name": "memory_note",
149                "description": "Curated shortcut for short, high-signal facts (\"User prefers snake_case\", \"Deploy target is prod-east\"). Bypasses the token-length filter but still rejects auto-capture noise. Stored as DrawerType::UserFact with importance 1.0. Issue #215: a `context` argument can be supplied to wrap an otherwise meaningless single-word response.",
150                "inputSchema": {
151                    "type": "object",
152                    "properties": {
153                        "palace":  {"type": "string"},
154                        "content": {"type": "string", "description": "Brief fact to remember"},
155                        "tags":    {"type": "array", "items": {"type": "string"}},
156                        "context": {"type": "string", "description": "Optional surrounding context. Prepended to `content` (separated by `---`) when supplied; with very short content (< 4 words) and no context the write is skipped (issue #215)."}
157                    },
158                    "required": memory_note_required,
159                }
160            },
161            {
162                "name": "memory_recall",
163                "description": "Recall memories using L0+L1+L2 progressive retrieval.",
164                "inputSchema": {
165                    "type": "object",
166                    "properties": {
167                        "palace": {"type": "string"},
168                        "query":  {"type": "string"},
169                        "top_k":  {"type": "integer", "default": 10}
170                    },
171                    "required": memory_recall_required,
172                }
173            },
174            {
175                "name": "memory_recall_deep",
176                "description": "Deep recall using L3 full HNSW search.",
177                "inputSchema": {
178                    "type": "object",
179                    "properties": {
180                        "palace": {"type": "string"},
181                        "query":  {"type": "string"},
182                        "top_k":  {"type": "integer", "default": 10}
183                    },
184                    "required": memory_recall_required,
185                }
186            },
187            {
188                "name": "palace_create",
189                "description": "Create a new memory palace.",
190                "inputSchema": {
191                    "type": "object",
192                    "properties": {
193                        "name":        {"type": "string"},
194                        "description": {"type": "string"},
195                        "cwd":         {"type": "string", "description": "Optional caller working directory used for palace-name enforcement. Pass the project root (or any path inside it) so the pin file at `.trusty-tools/trusty-memory.yaml` is honoured. When omitted, the daemon's own cwd is used (rarely meaningful for remote calls)."},
196                        "force":       {"type": "boolean", "description": "Bypass project-slug validation so an application can create a palace under an arbitrary slug (spec-001: chat-session manager, one palace per app/tenant). Defaults to false.", "default": false}
197                    },
198                    "required": ["name"]
199                }
200            },
201            {
202                "name": "palace_list",
203                "description": "List all palaces on this machine.",
204                "inputSchema": {"type": "object", "properties": {}}
205            },
206            {
207                "name": "palace_delete",
208                "description": "Delete an entire memory palace, including its drawers, vectors, and knowledge graph. Refuses to delete a non-empty palace unless `force=true` is set.",
209                "inputSchema": {
210                    "type": "object",
211                    "properties": {
212                        "palace_id": {"type": "string", "description": "Id of the palace to delete."},
213                        "force":     {"type": "boolean", "description": "Required when the palace still has drawers; defaults to false.", "default": false}
214                    },
215                    "required": ["palace_id"]
216                }
217            },
218            {
219                "name": "palace_update",
220                "description": "Update the display name of an existing palace. The palace's drawers, vectors, and knowledge graph are preserved; only the human-readable name changes.",
221                "inputSchema": {
222                    "type": "object",
223                    "properties": {
224                        "palace_id": {"type": "string", "description": "Id of the palace to rename."},
225                        "name":      {"type": "string", "description": "New display name. Trimmed; must be non-empty."}
226                    },
227                    "required": ["palace_id", "name"]
228                }
229            },
230            {
231                "name": "kg_assert",
232                "description": "Assert a fact in the temporal knowledge graph.",
233                "inputSchema": {
234                    "type": "object",
235                    "properties": {
236                        "palace":     {"type": "string"},
237                        "subject":    {"type": "string"},
238                        "predicate":  {"type": "string"},
239                        "object":     {"type": "string"},
240                        "confidence": {"type": "number", "default": 1.0},
241                        "provenance": {"type": "string"}
242                    },
243                    "required": kg_assert_required,
244                }
245            },
246            {
247                "name": "kg_query",
248                "description": "Query active knowledge-graph triples for a subject.",
249                "inputSchema": {
250                    "type": "object",
251                    "properties": {
252                        "palace":  {"type": "string"},
253                        "subject": {"type": "string"}
254                    },
255                    "required": kg_query_required,
256                }
257            },
258            {
259                "name": "memory_list",
260                "description": "List drawers in a palace, optionally filtered by room type or tag.",
261                "inputSchema": {
262                    "type": "object",
263                    "properties": {
264                        "palace": {"type": "string"},
265                        "room":   {"type": "string", "description": "Filter by room type (Frontend, Backend, Testing, Planning, Documentation, Research, Configuration, Meetings, General, or custom)"},
266                        "tag":    {"type": "string", "description": "Filter by tag"},
267                        "limit":  {"type": "integer", "description": "Max results (default 50)"}
268                    },
269                    "required": memory_list_required,
270                }
271            },
272            {
273                "name": "memory_forget",
274                "description": "Delete a drawer from a palace by its UUID.",
275                "inputSchema": {
276                    "type": "object",
277                    "properties": {
278                        "palace":    {"type": "string"},
279                        "drawer_id": {"type": "string", "description": "UUID of the drawer to delete"}
280                    },
281                    "required": memory_forget_required,
282                }
283            },
284            {
285                "name": "palace_info",
286                "description": "Get metadata and stats for a single palace.",
287                "inputSchema": {
288                    "type": "object",
289                    "properties": {
290                        "palace": {"type": "string"}
291                    },
292                    "required": palace_info_required,
293                }
294            },
295            {
296                "name": "palace_compact",
297                "description": "Remove orphaned vector index entries (vectors with no matching drawer row). See issue #49.",
298                "inputSchema": {
299                    "type": "object",
300                    "properties": {
301                        "palace": {"type": "string"}
302                    },
303                    "required": palace_compact_required,
304                }
305            },
306            {
307                "name": "add_alias",
308                "description": "Add a short→full alias (e.g. tga → trusty-git-analytics) to the prompt-facts surface. Asserts the alias as a hot KG triple and refreshes the session-init prompt cache.",
309                "inputSchema": {
310                    "type": "object",
311                    "properties": {
312                        "palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
313                        "short": {"type": "string", "description": "Short name / alias (subject)"},
314                        "full":  {"type": "string", "description": "Full / canonical name (object)"},
315                        "extra": {"type": "string", "description": "Optional extra context appended to the full name"}
316                    },
317                    "required": add_alias_required,
318                }
319            },
320            {
321                "name": "list_prompt_facts",
322                "description": "List every active prompt-fact triple (aliases, conventions, facts, shorthands) across all palaces.",
323                "inputSchema": {"type": "object", "properties": {}}
324            },
325            {
326                "name": "remove_prompt_fact",
327                "description": "Retract the active triple for a (subject, predicate) pair from the prompt-facts surface. Closes the interval without inserting a replacement.",
328                "inputSchema": {
329                    "type": "object",
330                    "properties": {
331                        "subject":   {"type": "string"},
332                        "predicate": {"type": "string", "description": "One of is_alias_for, has_convention, is_fact, is_shorthand_for"}
333                    },
334                    "required": ["subject", "predicate"],
335                }
336            },
337            {
338                "name": "get_prompt_context",
339                "description": "Fetch the current project context (aliases, conventions, facts, shorthands) from the memory palace as a Markdown block ready to drop into the model's working context. Call at the start of each turn. Pass an optional `query` to filter to facts whose subject or object contains the query string (case-insensitive).",
340                "inputSchema": {
341                    "type": "object",
342                    "properties": {
343                        "query": {
344                            "type": "string",
345                            "description": "Optional filter — only return facts whose subject or object contains this string (case-insensitive). Omit to return all hot facts."
346                        }
347                    }
348                }
349            },
350            {
351                "name": "discover_aliases",
352                "description": "Auto-discover project aliases by scanning Cargo workspace members, binary names, first-letter abbreviations, and the git remote. Asserts any newly-discovered (short, is_alias_for, full) triples into the resolved palace and rebuilds the prompt cache. Skips triples that already exist active in the KG.",
353                "inputSchema": {
354                    "type": "object",
355                    "properties": {
356                        "palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
357                        "project_root": {"type": "string", "description": "Optional filesystem path to scan. Defaults to the process cwd."}
358                    },
359                    "required": discover_aliases_required,
360                }
361            },
362            {
363                "name": "kg_gaps",
364                "description": "List knowledge gaps detected in the memory palace graph. Returns communities (clusters of related entities) with low internal density that may benefit from additional knowledge. Populated by the dream cycle; an empty list means no cycle has run yet.",
365                "inputSchema": {
366                    "type": "object",
367                    "properties": {
368                        "palace": {"type": "string", "description": "Palace name (optional, defaults to the active palace)"}
369                    }
370                }
371            },
372            {
373                "name": "kg_bootstrap",
374                "description": "Seed the knowledge graph from well-known project files (Cargo.toml, package.json, pyproject.toml, go.mod, CLAUDE.md, .git/config). Asserts structured triples (has_language, has_version, source_repo, ...) plus temporal metadata (created_at, bootstrapped_at). Idempotent: re-running refreshes bootstrapped_at without disturbing created_at. See issue #60.",
375                "inputSchema": {
376                    "type": "object",
377                    "properties": {
378                        "palace":       {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
379                        "project_path": {"type": "string", "description": "Filesystem path to scan. Omit to scan the palace's own data dir (temporal metadata only)."}
380                    }
381                }
382            },
383            {
384                "name": "memory_recall_all",
385                "description": "Semantic search across ALL palaces simultaneously. Returns the top-k most relevant drawers ranked by similarity, regardless of which palace they belong to. Each result includes a `palace_id` field identifying its source.",
386                "inputSchema": {
387                    "type": "object",
388                    "properties": {
389                        "q":     {"type": "string", "description": "Free-text query"},
390                        "top_k": {"type": "integer", "default": 10},
391                        "deep":  {"type": "boolean", "default": false}
392                    },
393                    "required": ["q"],
394                }
395            },
396            {
397                "name": "memory_send_message",
398                "description": "Send an inter-project message (issue #99). Writes a tagged drawer into the recipient palace; the recipient's SessionStart hook picks it up via `trusty-memory inbox-check`. `to_palace` is the recipient repo slug (e.g. `trusty-tools`, `claude-mpm`). `from_palace` defaults to the calling project's cwd-derived slug when omitted.",
399                "inputSchema": {
400                    "type": "object",
401                    "properties": {
402                        "to_palace":   {"type": "string", "description": "Recipient palace id (repo slug)."},
403                        "purpose":     {"type": "string", "description": "Free-text purpose / category (e.g. `task`, `notify`, `reply`)."},
404                        "content":     {"type": "string", "description": "Message body — plain text, no length limit. Rendered into the recipient session as a Markdown block."},
405                        "from_palace": {"type": "string", "description": "Sender palace id (optional, defaults to cwd-derived slug)."}
406                    },
407                    "required": ["to_palace", "purpose", "content"],
408                }
409            },
410            {
411                "name": "upgrade",
412                "description": "Check for or install a new version of trusty-memory (issue #537). With check=true (or without confirm): report current vs. available version only — NEVER installs. With confirm=true: install via `cargo install trusty-memory --locked`, run a binary health gate, then restart the daemon under launchd (or print a restart hint when not supervised). The MCP response is returned BEFORE the daemon exits so the client sees the result before reconnecting.",
413                "inputSchema": {
414                    "type": "object",
415                    "properties": {
416                        "check":   {"type": "boolean", "description": "Report current and available versions only. No install. Default: true when confirm is absent.", "default": true},
417                        "confirm": {"type": "boolean", "description": "Set to true to install the new version. NEVER set automatically — the operator must explicitly pass confirm=true.", "default": false}
418                    },
419                    "required": []
420                }
421            },
422            {
423                "name": "chat_session_create",
424                "description": "Create a new chat session in a palace (spec-001 chat-session manager). Returns the session id, its creation timestamp, and the message count (0 for a fresh session). Pass an optional session_id to use a caller-chosen id (idempotent — an existing session is returned unchanged); pass an optional title to name a server-generated session. Sessions are stored in the palace's dedicated redb chat store, NOT the generic memory drawer surface.",
425                "inputSchema": {
426                    "type": "object",
427                    "properties": {
428                        "palace":     {"type": "string", "description": "Palace slug (optional if server started with --palace)"},
429                        "session_id": {"type": "string", "description": "Optional caller-supplied session id; a UUID is generated when omitted."},
430                        "title":      {"type": "string", "description": "Optional session name (applied only when session_id is omitted)."}
431                    },
432                    "required": chat_session_palace_required,
433                }
434            },
435            {
436                "name": "chat_session_add_turn",
437                "description": "Append a message (prompt or response) to a chat session's history. Creates the session if it does not yet exist. Returns the new message_count and updated_at. Bypasses the memory_remember signal/noise + dedup gates so sequential conversational turns persist verbatim.",
438                "inputSchema": {
439                    "type": "object",
440                    "properties": {
441                        "palace":     {"type": "string"},
442                        "session_id": {"type": "string"},
443                        "role":       {"type": "string", "enum": ["user", "assistant", "system"]},
444                        "content":    {"type": "string"}
445                    },
446                    "required": chat_session_add_turn_required,
447                }
448            },
449            {
450                "name": "chat_session_get",
451                "description": "Retrieve a full chat session: metadata plus every turn in chronological order. Errors if the session id is unknown.",
452                "inputSchema": {
453                    "type": "object",
454                    "properties": {
455                        "palace":     {"type": "string"},
456                        "session_id": {"type": "string"}
457                    },
458                    "required": chat_session_get_required,
459                }
460            },
461            {
462                "name": "chat_session_list",
463                "description": "List chat sessions in a palace as paginated metadata (id, title, timestamps, message_count) ordered most-recently-updated first. Does not include message bodies. Returns { sessions, total_count }.",
464                "inputSchema": {
465                    "type": "object",
466                    "properties": {
467                        "palace": {"type": "string"},
468                        "limit":  {"type": "integer", "default": 50},
469                        "offset": {"type": "integer", "default": 0}
470                    },
471                    "required": chat_session_palace_required,
472                }
473            },
474            {
475                "name": "dream_consolidate_room",
476                "description": "Trigger LLM-driven semantic consolidation for one room (or all rooms) of a palace, on demand and synchronously (spec-001). Consolidates facts older than max_age_days into canonical summaries, then evicts the superseded originals so history shrinks. Task drawers are always skipped. No-op (zero counts) when no inference backend (OpenRouter key / local model) is configured. Returns { summary_facts_created, facts_evicted }.",
477                "inputSchema": {
478                    "type": "object",
479                    "properties": {
480                        "palace":       {"type": "string"},
481                        "room":         {"type": "string", "description": "Room to scope to (e.g. Backend, Planning, or a custom name). Omit or null to consolidate all rooms."},
482                        "max_age_days": {"type": "integer", "default": 7, "description": "Only consolidate facts older than this many days."}
483                    },
484                    "required": dream_consolidate_room_required,
485                }
486            },
487            {
488                "name": "palace_dream",
489                "description": "On-demand LLM-driven consolidation for a palace (issue #1721). Alias for dream_consolidate_room with the same parameters; use this name when following the palace_* convention. Triggers a scoped dream/consolidation cycle immediately for the named palace, optionally filtered to one room. Task drawers are always skipped. Gracefully returns zero counts when no inference backend is configured. Returns { palace, room, summary_facts_created, facts_evicted }.",
490                "inputSchema": {
491                    "type": "object",
492                    "properties": {
493                        "palace":       {"type": "string"},
494                        "room":         {"type": "string", "description": "Room to scope to. Omit or null to consolidate all rooms."},
495                        "max_age_days": {"type": "integer", "default": 7, "description": "Only consolidate facts older than this many days."}
496                    },
497                    "required": dream_consolidate_room_required,
498                }
499            },
500            {
501                "name": "chat_session_recall",
502                "description": "Retrieve a full chat session with all turns in order (alias for chat_session_get, preferred name for agent-facing recall). Errors if the session id is unknown.",
503                "inputSchema": {
504                    "type": "object",
505                    "properties": {
506                        "palace":     {"type": "string"},
507                        "session_id": {"type": "string"}
508                    },
509                    "required": chat_session_get_required,
510                }
511            },
512            {
513                "name": "chat_session_delete",
514                "description": "Delete a chat session (and its full history) from a palace. Idempotent: deleting an unknown session id is a no-op, not an error. Returns { deleted: session_id }.",
515                "inputSchema": {
516                    "type": "object",
517                    "properties": {
518                        "palace":     {"type": "string"},
519                        "session_id": {"type": "string"}
520                    },
521                    "required": chat_session_delete_required,
522                }
523            },
524            {
525                "name": "chat_turn_append",
526                "description": "Append a prompt/response PAIR to a chat session as two consecutive messages (user role then assistant role). Atomic at the session level — both messages are written together. Creates the session implicitly when it does not exist. Returns { message_count, updated_at }.",
527                "inputSchema": {
528                    "type": "object",
529                    "properties": {
530                        "palace":     {"type": "string"},
531                        "session_id": {"type": "string"},
532                        "prompt":     {"type": "string", "description": "User-side message (stored with role=user)."},
533                        "response":   {"type": "string", "description": "Assistant-side message (stored with role=assistant)."}
534                    },
535                    "required": chat_turn_append_required,
536                }
537            },
538            crate::console_metrics::descriptor()
539        ]
540    });
541    // spec-001 Phase 4 (issue #1722): splice task tool schemas.
542    // Defined in task_definitions.rs to respect the 500-SLOC cap on this file.
543    let tools = result["tools"].as_array_mut().expect("tools is array");
544    let metrics = tools.pop().expect("console_metrics sentinel");
545    tools.extend(task_tool_definitions(has_default));
546    tools.push(metrics);
547    result
548}