Skip to main content

solo_api/
mcp.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! MCP (Model Context Protocol) server for Solo.
4//!
5//! Exposes thirteen tools to MCP clients (Claude Desktop, Cursor, etc.):
6//!
7//! Episode tools (v0.1+):
8//!   - `memory_remember(content, source_type?, source_id?)` — store an
9//!     episode. Returns the new MemoryId.
10//!   - `memory_recall(query, limit?)` — vector search. Returns the top-K
11//!     matches with content + tier + status.
12//!   - `memory_forget(memory_id, reason?)` — soft-delete an episode.
13//!   - `memory_inspect(memory_id)` — return the full episode record.
14//!
15//! Derived-layer tools (v0.4.0+):
16//!   - `memory_themes(window_days?, limit?)` — list cluster themes.
17//!   - `memory_facts_about(subject, ...)` — query the structured-fact
18//!     knowledge graph (subject-predicate-object triples).
19//!   - `memory_contradictions(limit?)` — disagreements flagged during
20//!     consolidation.
21//!
22//! Derived-layer tools (v0.5.0+):
23//!   - `memory_inspect_cluster(cluster_id, full_content?)` — drill
24//!     into one cluster's abstraction + source episodes (truncated).
25//!
26//! Document tools (v0.7.0+):
27//!   - `memory_ingest_document(path)` — read a file from disk, split it
28//!     into chunks, embed each, and store under documents/document_chunks.
29//!   - `memory_search_docs(query, limit?)` — vector search restricted to
30//!     document chunks; returns chunk content + parent-doc context.
31//!   - `memory_inspect_document(doc_id)` — show one document's metadata
32//!     plus a previewed list of its chunks.
33//!   - `memory_list_documents(limit?, offset?, include_forgotten?)` —
34//!     paginate over ingested documents, newest first.
35//!   - `memory_forget_document(doc_id)` — soft-delete a document; chunks
36//!     stop appearing in `memory_search_docs` and tombstone in HNSW.
37//!
38//! ## Transport
39//!
40//! `serve_stdio` wires the server to stdin/stdout for use as a subprocess
41//! ("`claude_desktop_config.json` or `~/.cursor/mcp.json` invokes
42//! `solo mcp-stdio`"). The function awaits a graceful shutdown when stdin
43//! closes (parent disconnects) — same lifecycle as `solo daemon`'s
44//! Ctrl+C path.
45//!
46//! ## What's deferred
47//!
48//! - SSE/HTTP transports — `rmcp` ships them, but v0.1 ships stdio only.
49//! - `prompts/` and `resources/` capabilities — not needed for the
50//!   four-tool surface; ServerHandler defaults return empty lists.
51//! - Tool argument validation beyond JSON Schema typing — we trust rmcp
52//!   to deserialize per the schema, then serde-deserialize into our
53//!   typed param structs. Bad inputs surface as clear errors.
54
55use std::sync::Arc;
56
57use rmcp::handler::server::ServerHandler;
58use rmcp::model::{
59    CallToolRequestParam, CallToolResult, Content, Implementation, ListToolsResult,
60    PaginatedRequestParam, ProtocolVersion, ServerCapabilities, ServerInfo, Tool,
61    ToolsCapability,
62};
63use rmcp::service::{RequestContext, RoleServer};
64use rmcp::{Error as McpError, ServiceExt};
65use serde::{Deserialize, Serialize};
66use solo_core::{
67    Confidence, DocumentId, Embedder, EncodingContext, Episode, MemoryId, Tier,
68    VectorIndex,
69};
70use solo_storage::{ReaderPool, WriteHandle};
71use std::str::FromStr;
72
73/// The MCP server. Cheap to clone — every field is `Arc`-cloneable.
74#[derive(Clone)]
75pub struct SoloMcpServer {
76    inner: Arc<Inner>,
77}
78
79struct Inner {
80    write: WriteHandle,
81    pool: ReaderPool,
82    embedder: Arc<dyn Embedder>,
83    hnsw: Arc<dyn VectorIndex + Send + Sync>,
84    /// Read-path aliases for the canonical `"user"` subject. Sourced
85    /// from `solo.config.toml` `[identity] user_aliases`; threaded
86    /// through to `solo_query::facts_about` so a query for `"alex"`
87    /// also surfaces rows historically extracted as `"user"`. Empty
88    /// vec = behave as today (no expansion).
89    user_aliases: Vec<String>,
90}
91
92impl SoloMcpServer {
93    /// Build a server without identity-config aliases (back-compat
94    /// constructor + tests). Equivalent to `new_with_identity` with an
95    /// empty alias list.
96    pub fn new(
97        write: WriteHandle,
98        pool: ReaderPool,
99        embedder: Arc<dyn Embedder>,
100        hnsw: Arc<dyn VectorIndex + Send + Sync>,
101    ) -> Self {
102        Self::new_with_identity(write, pool, embedder, hnsw, Vec::new())
103    }
104
105    /// Build a server, threading in `user_aliases` so `memory_facts_about`
106    /// resolves the canonical `"user"` subject to + from each alias.
107    /// Sourced from `solo.config.toml` `[identity] user_aliases`.
108    pub fn new_with_identity(
109        write: WriteHandle,
110        pool: ReaderPool,
111        embedder: Arc<dyn Embedder>,
112        hnsw: Arc<dyn VectorIndex + Send + Sync>,
113        user_aliases: Vec<String>,
114    ) -> Self {
115        Self {
116            inner: Arc::new(Inner {
117                write,
118                pool,
119                embedder,
120                hnsw,
121                user_aliases,
122            }),
123        }
124    }
125}
126
127/// Convenience: run the server over stdio and await its termination.
128/// Returns when stdin closes (parent disconnect) or the runtime exits.
129pub async fn serve_stdio(server: SoloMcpServer) -> anyhow::Result<()> {
130    use rmcp::transport::io::stdio;
131    let (stdin, stdout) = stdio();
132    let running = server.serve((stdin, stdout)).await?;
133    running.waiting().await?;
134    Ok(())
135}
136
137// ---------------------------------------------------------------------------
138// Tool argument schemas
139// ---------------------------------------------------------------------------
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct RememberArgs {
143    pub content: String,
144    #[serde(default)]
145    pub source_type: Option<String>,
146    #[serde(default)]
147    pub source_id: Option<String>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct RecallArgs {
152    pub query: String,
153    #[serde(default = "default_limit")]
154    pub limit: usize,
155}
156
157fn default_limit() -> usize {
158    5
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct ForgetArgs {
163    pub memory_id: String,
164    #[serde(default = "default_forget_reason")]
165    pub reason: String,
166}
167
168fn default_forget_reason() -> String {
169    "user-initiated via MCP".into()
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct InspectArgs {
174    pub memory_id: String,
175}
176
177// Path 1 derived-layer tools (v0.4.0+) — query the Steward's outputs.
178// `solo_query::derived` is the single source of truth; these handlers
179// just translate JSON args to function args and serialise the result
180// vec to JSON for the MCP wire.
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct ThemesArgs {
184    /// Optional time window in days; `None` = unfiltered, return up
185    /// to `limit` most-recent themes across all time. `Some(7)` =
186    /// "themes from the last week".
187    #[serde(default)]
188    pub window_days: Option<i64>,
189    #[serde(default = "default_limit")]
190    pub limit: usize,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct FactsAboutArgs {
195    /// Subject id to query — required (predicate-only scans
196    /// intentionally not supported).
197    pub subject: String,
198    #[serde(default)]
199    pub predicate: Option<String>,
200    #[serde(default)]
201    pub since_ms: Option<i64>,
202    #[serde(default)]
203    pub until_ms: Option<i64>,
204    /// v0.5.1 Priority 8 — widen the query to also match rows where
205    /// `subject` appears as the object (e.g. surface "Sam pushes back
206    /// on PRs about Maya" under `facts_about(subject="maya")`).
207    /// Default `false` preserves v0.5.0 behaviour.
208    #[serde(default)]
209    pub include_as_object: bool,
210    #[serde(default = "default_limit")]
211    pub limit: usize,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct ContradictionsArgs {
216    #[serde(default = "default_limit")]
217    pub limit: usize,
218}
219
220/// Args for `memory_inspect_cluster` (v0.5.0 Priority 3). `cluster_id`
221/// is required; `full_content` is opt-in for the rare power-user case
222/// where 200-char-per-episode truncation is too aggressive.
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct InspectClusterArgs {
225    pub cluster_id: String,
226    /// If `true`, episode `content` fields are returned verbatim. If
227    /// `false` or omitted (the default), each episode's content is
228    /// truncated to `solo_query::EPISODE_TRUNCATE_CHARS` chars with a
229    /// trailing `…`.
230    #[serde(default)]
231    pub full_content: bool,
232}
233
234// Document tools (v0.7.0+). Five args structs paired with five handlers.
235// Wire shapes per `docs/dev-log/0083-v0.7.0-implementation-plan.md` §2 P5.
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct IngestDocumentArgs {
239    /// Server-side filesystem path to the file to ingest. Must be
240    /// readable by the Solo process. The writer parses the file by
241    /// extension, splits it into ~500-token chunks, embeds each, and
242    /// stores them under `documents` + `document_chunks`.
243    pub path: String,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct SearchDocsArgs {
248    pub query: String,
249    #[serde(default = "default_search_docs_limit")]
250    pub limit: usize,
251}
252
253fn default_search_docs_limit() -> usize {
254    5
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct InspectDocumentArgs {
259    pub doc_id: String,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct ListDocumentsArgs {
264    #[serde(default = "default_list_documents_limit")]
265    pub limit: usize,
266    #[serde(default)]
267    pub offset: usize,
268    /// If `true`, also include documents the user has forgotten. Default
269    /// `false` matches the agent-UX expectation that recall + listing
270    /// ignore soft-deleted rows.
271    #[serde(default)]
272    pub include_forgotten: bool,
273}
274
275fn default_list_documents_limit() -> usize {
276    20
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct ForgetDocumentArgs {
281    pub doc_id: String,
282}
283
284// ---------------------------------------------------------------------------
285// ServerHandler implementation
286// ---------------------------------------------------------------------------
287
288impl ServerHandler for SoloMcpServer {
289    fn get_info(&self) -> ServerInfo {
290        ServerInfo {
291            protocol_version: ProtocolVersion::default(),
292            capabilities: ServerCapabilities {
293                tools: Some(ToolsCapability {
294                    list_changed: Some(false),
295                }),
296                ..Default::default()
297            },
298            server_info: Implementation {
299                name: "solo".into(),
300                version: env!("CARGO_PKG_VERSION").into(),
301            },
302            instructions: Some(
303                "Solo gives you persistent memory across conversations \
304                 with this user — what they've told you before, the \
305                 people and projects in their life, and where their \
306                 stated beliefs have shifted, plus a library of \
307                 documents the user has ingested (notes, runbooks, \
308                 PDFs). Reach for these tools whenever the user \
309                 references something from earlier (\"like I \
310                 mentioned\", \"the project I'm working on\", \"my \
311                 friend Alex\", \"the notes I uploaded last week\") \
312                 or asks a question that hinges on personal context \
313                 or document content you don't have in the current \
314                 chat. \
315                 \n\nTools to write or look up specific moments: \
316                 memory_remember (save something worth keeping), \
317                 memory_recall (search past conversations by topic), \
318                 memory_inspect (show one saved item by id), \
319                 memory_forget (delete one saved item). \
320                 \n\nTools for the bigger picture (populated as the \
321                 user uses Solo over time): memory_themes (recent \
322                 topics they've been thinking about), \
323                 memory_facts_about (what you know about a person, \
324                 project, or place — \"what do you know about \
325                 Alex?\"), memory_contradictions (places where the \
326                 user has said two things that disagree — surface \
327                 these before answering), memory_inspect_cluster \
328                 (the raw conversations behind one summary). \
329                 \n\nTools for the user's documents: \
330                 memory_ingest_document (read a file from disk and \
331                 add it to Solo's library), memory_search_docs \
332                 (search across ingested documents by topic — use \
333                 when the user asks about something they wrote down \
334                 or saved as a file), memory_inspect_document (show \
335                 one document's metadata plus a preview of its \
336                 chunks), memory_list_documents (browse documents \
337                 by recency), memory_forget_document (drop a \
338                 document from the library)."
339                    .into(),
340            ),
341        }
342    }
343
344    async fn list_tools(
345        &self,
346        _request: PaginatedRequestParam,
347        _context: RequestContext<RoleServer>,
348    ) -> std::result::Result<ListToolsResult, McpError> {
349        Ok(ListToolsResult {
350            tools: build_tools(),
351            next_cursor: None,
352        })
353    }
354
355    async fn call_tool(
356        &self,
357        request: CallToolRequestParam,
358        _context: RequestContext<RoleServer>,
359    ) -> std::result::Result<CallToolResult, McpError> {
360        let CallToolRequestParam { name, arguments } = request;
361        let args_value = serde_json::Value::Object(arguments.unwrap_or_default());
362        self.dispatch_tool(&name, args_value).await
363    }
364}
365
366impl SoloMcpServer {
367    /// Direct tool-dispatch path used by both `call_tool` (the
368    /// ServerHandler trait method, behind the rmcp protocol layer) and
369    /// in-process tests that don't want to spin up a full transport pair.
370    /// Bypasses `RequestContext` (which requires a `Peer` not constructible
371    /// outside rmcp internals).
372    pub async fn dispatch_tool(
373        &self,
374        name: &str,
375        args_value: serde_json::Value,
376    ) -> std::result::Result<CallToolResult, McpError> {
377        match name {
378            "memory_remember" => {
379                let args: RememberArgs = parse_args(&args_value)?;
380                self.handle_remember(args).await
381            }
382            "memory_recall" => {
383                let args: RecallArgs = parse_args(&args_value)?;
384                self.handle_recall(args).await
385            }
386            "memory_forget" => {
387                let args: ForgetArgs = parse_args(&args_value)?;
388                self.handle_forget(args).await
389            }
390            "memory_inspect" => {
391                let args: InspectArgs = parse_args(&args_value)?;
392                self.handle_inspect(args).await
393            }
394            "memory_themes" => {
395                let args: ThemesArgs = parse_args(&args_value)?;
396                self.handle_themes(args).await
397            }
398            "memory_facts_about" => {
399                let args: FactsAboutArgs = parse_args(&args_value)?;
400                self.handle_facts_about(args).await
401            }
402            "memory_contradictions" => {
403                let args: ContradictionsArgs = parse_args(&args_value)?;
404                self.handle_contradictions(args).await
405            }
406            "memory_inspect_cluster" => {
407                let args: InspectClusterArgs = parse_args(&args_value)?;
408                self.handle_inspect_cluster(args).await
409            }
410            "memory_ingest_document" => {
411                let args: IngestDocumentArgs = parse_args(&args_value)?;
412                self.handle_ingest_document(args).await
413            }
414            "memory_search_docs" => {
415                let args: SearchDocsArgs = parse_args(&args_value)?;
416                self.handle_search_docs(args).await
417            }
418            "memory_inspect_document" => {
419                let args: InspectDocumentArgs = parse_args(&args_value)?;
420                self.handle_inspect_document(args).await
421            }
422            "memory_list_documents" => {
423                let args: ListDocumentsArgs = parse_args(&args_value)?;
424                self.handle_list_documents(args).await
425            }
426            "memory_forget_document" => {
427                let args: ForgetDocumentArgs = parse_args(&args_value)?;
428                self.handle_forget_document(args).await
429            }
430            other => Err(McpError::invalid_params(
431                format!("unknown tool `{other}`"),
432                None,
433            )),
434        }
435    }
436
437    /// List the tools this server exposes. Mirrors `ServerHandler::list_tools`
438    /// without requiring a RequestContext.
439    pub fn dispatch_list_tools(&self) -> Vec<Tool> {
440        build_tools()
441    }
442}
443
444fn parse_args<T: serde::de::DeserializeOwned>(
445    v: &serde_json::Value,
446) -> std::result::Result<T, McpError> {
447    serde_json::from_value(v.clone()).map_err(|e| {
448        McpError::invalid_params(format!("invalid tool arguments: {e}"), None)
449    })
450}
451
452fn solo_to_mcp(e: solo_core::Error) -> McpError {
453    use solo_core::Error;
454    match e {
455        Error::NotFound(msg) => McpError::invalid_params(msg, None),
456        Error::InvalidInput(msg) => McpError::invalid_params(msg, None),
457        Error::Conflict(msg) => McpError::invalid_params(msg, None),
458        other => McpError::internal_error(other.to_string(), None),
459    }
460}
461
462// ---------------------------------------------------------------------------
463// Tool definitions (JSON Schema)
464// ---------------------------------------------------------------------------
465
466fn build_tools() -> Vec<Tool> {
467    vec![
468        Tool::new(
469            "memory_remember",
470            "Save something the user has told you — a fact, a \
471             preference, a name, a date, a context — so you can pick \
472             it up next conversation. Use whenever the user mentions \
473             something they'd reasonably expect you to recall later \
474             (\"I just started at Quotient\", \"my partner is Maya\"). \
475             Returns the saved item's id.",
476            json_schema_object(serde_json::json!({
477                "type": "object",
478                "properties": {
479                    "content": {
480                        "type": "string",
481                        "description": "The text to remember.",
482                    },
483                    "source_type": {
484                        "type": "string",
485                        "description": "Optional source-type tag (default: \"user_message\").",
486                    },
487                    "source_id": {
488                        "type": "string",
489                        "description": "Optional upstream id for traceability.",
490                    },
491                },
492                "required": ["content"],
493            })),
494        ),
495        Tool::new(
496            "memory_recall",
497            "Search past conversations with this user by topic or \
498             phrase. Returns up to `limit` of the closest matches, \
499             best match first. Use when the user references \
500             something they said before (\"that book I told you \
501             about\", \"the bug we were debugging last week\"). \
502             Skips items the user has deleted.",
503            json_schema_object(serde_json::json!({
504                "type": "object",
505                "properties": {
506                    "query": {
507                        "type": "string",
508                        "description": "The query text.",
509                    },
510                    "limit": {
511                        "type": "integer",
512                        "description": "Maximum results (default 5).",
513                        "minimum": 1,
514                        "maximum": 100,
515                    },
516                },
517                "required": ["query"],
518            })),
519        ),
520        Tool::new(
521            "memory_forget",
522            "Delete one saved item by id. Use when the user asks you \
523             to forget something specific (\"forget that I said \
524             X\"). The item stops appearing in future recalls. \
525             Reversible only via backups.",
526            json_schema_object(serde_json::json!({
527                "type": "object",
528                "properties": {
529                    "memory_id": {
530                        "type": "string",
531                        "description": "MemoryId to forget (UUID v7).",
532                    },
533                    "reason": {
534                        "type": "string",
535                        "description": "Optional free-form reason (logged, not yet persisted).",
536                    },
537                },
538                "required": ["memory_id"],
539            })),
540        ),
541        Tool::new(
542            "memory_inspect",
543            "Show the full record for one saved item — when it was \
544             saved, where it came from, and the full text. Use after \
545             memory_recall when you want the complete content of a \
546             specific hit (recall results may be truncated).",
547            json_schema_object(serde_json::json!({
548                "type": "object",
549                "properties": {
550                    "memory_id": {
551                        "type": "string",
552                        "description": "MemoryId to inspect (UUID v7).",
553                    },
554                },
555                "required": ["memory_id"],
556            })),
557        ),
558        // Path 1 derived-layer tools (v0.4.0+) — query the Steward's
559        // outputs. These four are populated by `solo consolidate` and
560        // were previously unreadable except via direct SQL.
561        Tool::new(
562            "memory_themes",
563            "Recent topics the user has been thinking about. Use to \
564             orient yourself at the start of a conversation, or when \
565             the user asks \"what have I been up to\" / \"what was I \
566             working on last week\". Pass `window_days` to scope \
567             (e.g. 7 for last week); omit for all-time.",
568            json_schema_object(serde_json::json!({
569                "type": "object",
570                "properties": {
571                    "window_days": {
572                        "type": "integer",
573                        "description": "Optional time window in days. Omit for unfiltered.",
574                        "minimum": 1,
575                    },
576                    "limit": {
577                        "type": "integer",
578                        "description": "Maximum results (default 5).",
579                        "minimum": 1,
580                        "maximum": 100,
581                    },
582                },
583            })),
584        ),
585        Tool::new(
586            "memory_facts_about",
587            "Look up what you remember about a person, project, or \
588             topic — names, dates, preferences, relationships. Use \
589             when the user asks \"what do you know about Alex?\", \
590             \"when did I start at Quotient?\", \"who is Maya?\", or \
591             whenever you need grounded facts about someone or \
592             something before answering. Subject is required (the \
593             person/place/thing you're asking about); narrow further \
594             with `predicate` (\"works_at\", \"lives_in\") or a date \
595             range. Set `include_as_object=true` to also surface \
596             facts where the subject appears on the receiving side of \
597             a relationship (e.g. \"Sam pushes back on PRs about \
598             Maya\" surfaces under facts_about(subject=\"Maya\", \
599             include_as_object=true)). (Backed by \
600             subject-predicate-object triples distilled from past \
601             conversations.) Clients should set a 30s timeout on this \
602             call; if exceeded, retry once or fall back to \
603             `memory_recall`.",
604            json_schema_object(serde_json::json!({
605                "type": "object",
606                "properties": {
607                    "subject": {
608                        "type": "string",
609                        "description": "Subject id to query (e.g. 'Sam').",
610                    },
611                    "predicate": {
612                        "type": "string",
613                        "description": "Optional predicate filter (e.g. 'works_at').",
614                    },
615                    "since_ms": {
616                        "type": "integer",
617                        "description": "Optional valid_from_ms lower bound (epoch ms).",
618                    },
619                    "until_ms": {
620                        "type": "integer",
621                        "description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through.",
622                    },
623                    "include_as_object": {
624                        "type": "boolean",
625                        "description": "If true, also match facts where `subject` appears as the object (e.g. 'Sam pushes back on PRs about Maya' surfaces under subject='Maya'). Default false.",
626                        "default": false,
627                    },
628                    "limit": {
629                        "type": "integer",
630                        "description": "Maximum results (default 5).",
631                        "minimum": 1,
632                        "maximum": 100,
633                    },
634                },
635                "required": ["subject"],
636            })),
637        ),
638        Tool::new(
639            "memory_contradictions",
640            "Find places where the user's stated beliefs or facts \
641             disagree across conversations — flag disagreements \
642             before answering. Use whenever you're about to rely on \
643             a remembered fact that could have changed (jobs, \
644             relationships, preferences, opinions); a disagreement \
645             here means the user has told you both X and not-X over \
646             time and you should ask which is current instead of \
647             guessing. Each result shows both conflicting statements \
648             with the topic.",
649            json_schema_object(serde_json::json!({
650                "type": "object",
651                "properties": {
652                    "limit": {
653                        "type": "integer",
654                        "description": "Maximum results (default 5).",
655                        "minimum": 1,
656                        "maximum": 100,
657                    },
658                },
659            })),
660        ),
661        Tool::new(
662            "memory_inspect_cluster",
663            "Show the raw conversations behind one summary. Returns \
664             the one-line topic (the LLM-generated summary) and the \
665             source conversations the topic was built from. Use \
666             after memory_themes when the user asks \"show me the \
667             raw context behind this\" or \"why does Solo think \
668             that about cluster Y\". Source items are truncated to \
669             200 chars unless `full_content` is set.",
670            json_schema_object(serde_json::json!({
671                "type": "object",
672                "properties": {
673                    "cluster_id": {
674                        "type": "string",
675                        "description": "Cluster id to inspect (from memory_themes hits).",
676                    },
677                    "full_content": {
678                        "type": "boolean",
679                        "description": "If true, episode content is returned verbatim. Default false (truncate to 200 chars + ellipsis).",
680                    },
681                },
682                "required": ["cluster_id"],
683            })),
684        ),
685        // Document tools (v0.7.0+). RAG over user-supplied files —
686        // markdown notes, PDFs, runbooks, code, etc. Same vector space
687        // as episodes; same embedder; same HNSW index.
688        Tool::new(
689            "memory_ingest_document",
690            "Read a file from disk and add it to the user's document \
691             library so it becomes searchable alongside past \
692             conversations. Use when the user asks you to remember a \
693             whole file (\"add my notes/runbook.md\", \"ingest this \
694             PDF\"). The file is split into ~500-token chunks and \
695             each chunk is embedded; chunks then surface through \
696             memory_search_docs. Returns the new document id, chunk \
697             count, and a `deduped` flag (true if the same content \
698             was already ingested under another id).",
699            json_schema_object(serde_json::json!({
700                "type": "object",
701                "properties": {
702                    "path": {
703                        "type": "string",
704                        "description": "Server-side absolute path to the file to ingest. The file must be readable by the Solo process.",
705                    },
706                },
707                "required": ["path"],
708            })),
709        ),
710        Tool::new(
711            "memory_search_docs",
712            "Search across the user's ingested documents by topic or \
713             phrase. Returns up to `limit` matching chunks, best \
714             match first, each with the parent document's title + \
715             source path so you can cite where the answer came from. \
716             Use when the user asks a question that hinges on \
717             material they've added as a file (\"what does my \
718             runbook say about backups?\", \"find the section in the \
719             notes about the new policy\"). Forgotten documents are \
720             skipped.",
721            json_schema_object(serde_json::json!({
722                "type": "object",
723                "properties": {
724                    "query": {
725                        "type": "string",
726                        "description": "The query text.",
727                    },
728                    "limit": {
729                        "type": "integer",
730                        "description": "Maximum results (default 5).",
731                        "minimum": 1,
732                        "maximum": 100,
733                    },
734                },
735                "required": ["query"],
736            })),
737        ),
738        Tool::new(
739            "memory_inspect_document",
740            "Show one document's metadata plus a preview of every \
741             chunk it was split into. Use after memory_search_docs \
742             when the user wants the bigger picture for one hit \
743             (\"show me the whole document this came from\"), or \
744             after memory_list_documents to drill into one entry. \
745             Each chunk preview is truncated to 200 chars.",
746            json_schema_object(serde_json::json!({
747                "type": "object",
748                "properties": {
749                    "doc_id": {
750                        "type": "string",
751                        "description": "Document id to inspect (UUID v7).",
752                    },
753                },
754                "required": ["doc_id"],
755            })),
756        ),
757        Tool::new(
758            "memory_list_documents",
759            "List the user's ingested documents, newest first. Use \
760             when the user asks \"what documents have I added?\" or \
761             \"show me my files\". Returns a paginated index — pass \
762             `offset` to page further back. Forgotten documents are \
763             hidden by default; set `include_forgotten=true` to see \
764             them too.",
765            json_schema_object(serde_json::json!({
766                "type": "object",
767                "properties": {
768                    "limit": {
769                        "type": "integer",
770                        "description": "Maximum results per page (default 20).",
771                        "minimum": 1,
772                        "maximum": 100,
773                    },
774                    "offset": {
775                        "type": "integer",
776                        "description": "Number of rows to skip (for paging). Default 0.",
777                        "minimum": 0,
778                    },
779                    "include_forgotten": {
780                        "type": "boolean",
781                        "description": "If true, also include documents the user has forgotten. Default false.",
782                    },
783                },
784            })),
785        ),
786        Tool::new(
787            "memory_forget_document",
788            "Drop one document from the user's library by id. Use \
789             when the user asks you to forget a specific file \
790             (\"forget my old runbook\"). The document's chunks stop \
791             appearing in memory_search_docs and the vectors are \
792             tombstoned in the index. The chunk rows themselves are \
793             kept for forensic value (a future restore command can \
794             undo this).",
795            json_schema_object(serde_json::json!({
796                "type": "object",
797                "properties": {
798                    "doc_id": {
799                        "type": "string",
800                        "description": "Document id to forget (UUID v7).",
801                    },
802                },
803                "required": ["doc_id"],
804            })),
805        ),
806    ]
807}
808
809fn json_schema_object(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
810    match value {
811        serde_json::Value::Object(map) => map,
812        _ => panic!("json_schema_object: input must be an object"),
813    }
814}
815
816/// Names of every tool this server exposes, in registration order.
817///
818/// Exposed for cross-crate consumers (notably `solo doctor
819/// --check-mcp-compat`) that want the name list without paying the
820/// cost of building full `rmcp::Tool` records (which allocate JSON
821/// schemas). The registration order matches `build_tools()` so any
822/// drift between the two would be caught by the cross-provider regex
823/// test which iterates `build_tools()`.
824pub fn tool_names() -> Vec<&'static str> {
825    vec![
826        "memory_remember",
827        "memory_recall",
828        "memory_forget",
829        "memory_inspect",
830        "memory_themes",
831        "memory_facts_about",
832        "memory_contradictions",
833        "memory_inspect_cluster",
834        // Document tools added in v0.7.0:
835        "memory_ingest_document",
836        "memory_search_docs",
837        "memory_inspect_document",
838        "memory_list_documents",
839        "memory_forget_document",
840    ]
841}
842
843// ---------------------------------------------------------------------------
844// Tool handlers
845// ---------------------------------------------------------------------------
846
847impl SoloMcpServer {
848    async fn handle_remember(
849        &self,
850        args: RememberArgs,
851    ) -> std::result::Result<CallToolResult, McpError> {
852        let content = args.content.trim_end().to_string();
853        if content.is_empty() {
854            return Err(McpError::invalid_params(
855                "memory_remember: content must not be empty".to_string(),
856                None,
857            ));
858        }
859        let embedding: solo_core::Embedding = self
860            .inner
861            .embedder
862            .embed(&content)
863            .await
864            .map_err(solo_to_mcp)?;
865        let episode = Episode {
866            memory_id: MemoryId::new(),
867            ts_ms: chrono::Utc::now().timestamp_millis(),
868            source_type: args.source_type.unwrap_or_else(|| "user_message".into()),
869            source_id: args.source_id,
870            content,
871            encoding_context: EncodingContext::default(),
872            provenance: None,
873            confidence: Confidence::new(0.9).unwrap(),
874            strength: 0.5,
875            salience: 0.5,
876            tier: Tier::Hot,
877        };
878        let mid = self
879            .inner
880            .write
881            .remember(episode, embedding)
882            .await
883            .map_err(solo_to_mcp)?;
884        Ok(CallToolResult::success(vec![Content::text(format!(
885            "remembered {mid}"
886        ))]))
887    }
888
889    async fn handle_recall(
890        &self,
891        args: RecallArgs,
892    ) -> std::result::Result<CallToolResult, McpError> {
893        // Pipeline lives in solo-query; the transport just formats the
894        // result. solo_query::run_recall validates empty queries
895        // (returns InvalidInput → invalid_params via solo_to_mcp).
896        let result = solo_query::run_recall(
897            &self.inner.embedder,
898            &self.inner.hnsw,
899            &self.inner.pool,
900            &args.query,
901            args.limit,
902        )
903        .await
904        .map_err(solo_to_mcp)?;
905
906        if result.hits.is_empty() {
907            return Ok(CallToolResult::success(vec![Content::text(format!(
908                "no matches (index has {} vectors)",
909                result.index_len
910            ))]));
911        }
912        let body = serde_json::to_string_pretty(&result.hits).unwrap_or_else(|_| String::new());
913        Ok(CallToolResult::success(vec![Content::text(body)]))
914    }
915
916    async fn handle_forget(
917        &self,
918        args: ForgetArgs,
919    ) -> std::result::Result<CallToolResult, McpError> {
920        let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
921            McpError::invalid_params(format!("invalid memory_id: {e}"), None)
922        })?;
923        self.inner
924            .write
925            .forget(mid, args.reason)
926            .await
927            .map_err(solo_to_mcp)?;
928        Ok(CallToolResult::success(vec![Content::text(format!(
929            "forgotten {mid}"
930        ))]))
931    }
932
933    async fn handle_inspect(
934        &self,
935        args: InspectArgs,
936    ) -> std::result::Result<CallToolResult, McpError> {
937        let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
938            McpError::invalid_params(format!("invalid memory_id: {e}"), None)
939        })?;
940        // Pipeline lives in solo-query::inspect; transports just format.
941        let row = solo_query::inspect_one(&self.inner.pool, mid)
942            .await
943            .map_err(solo_to_mcp)?;
944        let body = serde_json::to_string_pretty(&row).unwrap_or_else(|_| String::new());
945        Ok(CallToolResult::success(vec![Content::text(body)]))
946    }
947
948    // Path 1 derived-layer handlers (v0.4.0+). Each one delegates to a
949    // single solo-query::derived pipeline and serialises the result Vec
950    // to pretty JSON for the MCP wire. Empty result → JSON empty array
951    // `[]` (not a special-case "no matches" string) so MCP clients can
952    // parse uniformly.
953
954    async fn handle_themes(
955        &self,
956        args: ThemesArgs,
957    ) -> std::result::Result<CallToolResult, McpError> {
958        let hits = solo_query::themes(
959            &self.inner.pool,
960            args.window_days,
961            args.limit,
962        )
963        .await
964        .map_err(solo_to_mcp)?;
965        let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
966        Ok(CallToolResult::success(vec![Content::text(body)]))
967    }
968
969    async fn handle_facts_about(
970        &self,
971        args: FactsAboutArgs,
972    ) -> std::result::Result<CallToolResult, McpError> {
973        if args.subject.trim().is_empty() {
974            return Err(McpError::invalid_params(
975                "memory_facts_about: subject must not be empty".to_string(),
976                None,
977            ));
978        }
979        let hits = solo_query::facts_about(
980            &self.inner.pool,
981            &args.subject,
982            &self.inner.user_aliases,
983            args.include_as_object,
984            args.predicate.as_deref(),
985            args.since_ms,
986            args.until_ms,
987            args.limit,
988        )
989        .await
990        .map_err(solo_to_mcp)?;
991        let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
992        Ok(CallToolResult::success(vec![Content::text(body)]))
993    }
994
995    async fn handle_contradictions(
996        &self,
997        args: ContradictionsArgs,
998    ) -> std::result::Result<CallToolResult, McpError> {
999        let hits = solo_query::contradictions(&self.inner.pool, args.limit)
1000            .await
1001            .map_err(solo_to_mcp)?;
1002        let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1003        Ok(CallToolResult::success(vec![Content::text(body)]))
1004    }
1005
1006    async fn handle_inspect_cluster(
1007        &self,
1008        args: InspectClusterArgs,
1009    ) -> std::result::Result<CallToolResult, McpError> {
1010        if args.cluster_id.trim().is_empty() {
1011            return Err(McpError::invalid_params(
1012                "memory_inspect_cluster: cluster_id must not be empty".to_string(),
1013                None,
1014            ));
1015        }
1016        // `solo_to_mcp` maps `Error::NotFound` → `invalid_params` for
1017        // MCP (the protocol does not have a separate "not found" error
1018        // shape; clients see the message verbatim, which includes the
1019        // cluster_id).
1020        let record = solo_query::inspect_cluster(
1021            &self.inner.pool,
1022            &args.cluster_id,
1023            args.full_content,
1024        )
1025        .await
1026        .map_err(solo_to_mcp)?;
1027        let body = serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
1028        Ok(CallToolResult::success(vec![Content::text(body)]))
1029    }
1030
1031    // Document handlers (v0.7.0+). Each wraps the corresponding writer
1032    // / query API; the MCP wire shape is plain JSON serialisation of
1033    // the returned report / records.
1034
1035    async fn handle_ingest_document(
1036        &self,
1037        args: IngestDocumentArgs,
1038    ) -> std::result::Result<CallToolResult, McpError> {
1039        if args.path.trim().is_empty() {
1040            return Err(McpError::invalid_params(
1041                "memory_ingest_document: path must not be empty".to_string(),
1042                None,
1043            ));
1044        }
1045        let path = std::path::PathBuf::from(args.path);
1046        // Defaults match what the daemon uses today (target 500 tokens,
1047        // 50-token overlap). Future: thread a per-call override through
1048        // the args struct if a use case appears.
1049        let chunk_config = solo_storage::document::ChunkConfig::default();
1050        let report = self
1051            .inner
1052            .write
1053            .ingest_document(path, chunk_config)
1054            .await
1055            .map_err(solo_to_mcp)?;
1056        let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| String::new());
1057        Ok(CallToolResult::success(vec![Content::text(body)]))
1058    }
1059
1060    async fn handle_search_docs(
1061        &self,
1062        args: SearchDocsArgs,
1063    ) -> std::result::Result<CallToolResult, McpError> {
1064        // `solo_query::run_doc_search` validates empty queries (returns
1065        // InvalidInput → invalid_params via solo_to_mcp) and clamps
1066        // limit upstream of the embedder call.
1067        let hits = solo_query::run_doc_search(
1068            &self.inner.embedder,
1069            &self.inner.hnsw,
1070            &self.inner.pool,
1071            &args.query,
1072            args.limit,
1073        )
1074        .await
1075        .map_err(solo_to_mcp)?;
1076        let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1077        Ok(CallToolResult::success(vec![Content::text(body)]))
1078    }
1079
1080    async fn handle_inspect_document(
1081        &self,
1082        args: InspectDocumentArgs,
1083    ) -> std::result::Result<CallToolResult, McpError> {
1084        let doc_id = DocumentId::from_str(&args.doc_id).map_err(|e| {
1085            McpError::invalid_params(format!("invalid doc_id: {e}"), None)
1086        })?;
1087        let result_opt = solo_query::inspect_document(&self.inner.pool, &doc_id)
1088            .await
1089            .map_err(solo_to_mcp)?;
1090        match result_opt {
1091            Some(record) => {
1092                let body =
1093                    serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
1094                Ok(CallToolResult::success(vec![Content::text(body)]))
1095            }
1096            None => Err(McpError::invalid_params(
1097                format!("document {doc_id} not found"),
1098                None,
1099            )),
1100        }
1101    }
1102
1103    async fn handle_list_documents(
1104        &self,
1105        args: ListDocumentsArgs,
1106    ) -> std::result::Result<CallToolResult, McpError> {
1107        let rows = solo_query::list_documents(
1108            &self.inner.pool,
1109            args.limit,
1110            args.offset,
1111            args.include_forgotten,
1112        )
1113        .await
1114        .map_err(solo_to_mcp)?;
1115        let body = serde_json::to_string_pretty(&rows).unwrap_or_else(|_| String::new());
1116        Ok(CallToolResult::success(vec![Content::text(body)]))
1117    }
1118
1119    async fn handle_forget_document(
1120        &self,
1121        args: ForgetDocumentArgs,
1122    ) -> std::result::Result<CallToolResult, McpError> {
1123        let doc_id = DocumentId::from_str(&args.doc_id).map_err(|e| {
1124            McpError::invalid_params(format!("invalid doc_id: {e}"), None)
1125        })?;
1126        let report = self
1127            .inner
1128            .write
1129            .forget_document(doc_id)
1130            .await
1131            .map_err(solo_to_mcp)?;
1132        let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| String::new());
1133        Ok(CallToolResult::success(vec![Content::text(body)]))
1134    }
1135}
1136
1137#[cfg(test)]
1138mod dispatch_tests {
1139    //! In-process integration tests for the MCP tool surface. We invoke
1140    //! `SoloMcpServer::dispatch_tool` directly (bypasses the rmcp
1141    //! protocol framing + `RequestContext`, which requires a `Peer`
1142    //! that's not constructible outside rmcp internals). The server is
1143    //! constructed against a real WriterActor + ReaderPool +
1144    //! StubEmbedder + StubVectorIndex from `solo_storage::test_support`.
1145    //!
1146    //! Tests live inline in this module rather than `tests/` because an
1147    //! external integration-test exe in `target/debug/deps/mcp_dispatch-*`
1148    //! tripped Windows UAC ERROR_ELEVATION_REQUIRED on the dev machine.
1149    //! The lib test binary doesn't have that issue.
1150    use super::*;
1151    use serde_json::json;
1152    use solo_core::VectorIndex;
1153    use solo_storage::test_support::StubVectorIndex;
1154    use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
1155    use std::sync::Arc as StdArc;
1156
1157    struct Harness {
1158        server: SoloMcpServer,
1159        _tmp: tempfile::TempDir,
1160        write_handle_extra: Option<solo_storage::WriteHandle>,
1161        join: Option<std::thread::JoinHandle<()>>,
1162    }
1163
1164    impl Harness {
1165        fn new(runtime: &tokio::runtime::Runtime) -> Self {
1166            let tmp = tempfile::TempDir::new().unwrap();
1167            let dim = 16usize;
1168            let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1169            let embedder: StdArc<dyn solo_core::Embedder> = StdArc::new(StubEmbedder::new("stub", "v1", dim));
1170
1171            let conn = solo_storage::test_support::open_test_db_at(&tmp.path().join("test.db"));
1172            let WriterSpawn { handle, join } = WriterActor::spawn(conn, hnsw.clone());
1173
1174            // ReaderPool's deadpool::Pool needs a live tokio runtime for
1175            // both build + drop; build inside block_on.
1176            let path = tmp.path().join("test.db");
1177            let pool: ReaderPool =
1178                runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1179
1180            let server = SoloMcpServer::new(handle.clone(), pool, embedder, hnsw);
1181            Harness {
1182                server,
1183                _tmp: tmp,
1184                write_handle_extra: Some(handle),
1185                join: Some(join),
1186            }
1187        }
1188
1189        fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1190            // The whole shutdown runs inside block_on so deadpool-sqlite's
1191            // drop (which schedules cleanup on the active runtime) sees a
1192            // live reactor. Without this, dropping the SoloMcpServer
1193            // (which holds the ReaderPool through its Arc<Inner>) panics
1194            // with "no reactor running".
1195            let join = self.join.take();
1196            let extra = self.write_handle_extra.take();
1197            runtime.block_on(async move {
1198                drop(extra);
1199                drop(self.server);
1200                drop(self._tmp);
1201                if let Some(join) = join {
1202                    let (tx, rx) = std::sync::mpsc::channel();
1203                    std::thread::spawn(move || {
1204                        let _ = tx.send(join.join());
1205                    });
1206                    tokio::task::spawn_blocking(move || {
1207                        rx.recv_timeout(std::time::Duration::from_secs(5))
1208                    })
1209                    .await
1210                    .expect("blocking task")
1211                    .expect("writer thread did not exit within 5s")
1212                    .expect("writer thread panicked");
1213                }
1214            });
1215        }
1216    }
1217
1218    fn rt() -> tokio::runtime::Runtime {
1219        tokio::runtime::Builder::new_multi_thread()
1220            .worker_threads(2)
1221            .enable_all()
1222            .build()
1223            .unwrap()
1224    }
1225
1226    /// Pull the first Content::text body out of a CallToolResult. Use
1227    /// serde_json roundtrip as a robust extractor — `Content`'s public
1228    /// API doesn't directly expose the inner text without going through
1229    /// pattern-matching on RawContent.
1230    fn first_text(r: &rmcp::model::CallToolResult) -> String {
1231        let first = r.content.first().expect("at least one content item");
1232        let v = serde_json::to_value(first).expect("content serialises");
1233        v.get("text")
1234            .and_then(|t| t.as_str())
1235            .map(|s| s.to_string())
1236            .unwrap_or_else(|| format!("{v}"))
1237    }
1238
1239    #[test]
1240    fn tools_list_returns_thirteen_canonical_tools() {
1241        let runtime = rt();
1242        let h = Harness::new(&runtime);
1243        let tools = h.server.dispatch_list_tools();
1244        let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1245        assert_eq!(
1246            names,
1247            vec![
1248                "memory_remember",
1249                "memory_recall",
1250                "memory_forget",
1251                "memory_inspect",
1252                // Derived-layer tools added in v0.4.0:
1253                "memory_themes",
1254                "memory_facts_about",
1255                "memory_contradictions",
1256                // Added in v0.5.0 (Priority 3):
1257                "memory_inspect_cluster",
1258                // Document tools added in v0.7.0:
1259                "memory_ingest_document",
1260                "memory_search_docs",
1261                "memory_inspect_document",
1262                "memory_list_documents",
1263                "memory_forget_document",
1264            ]
1265        );
1266        for t in &tools {
1267            assert!(!t.description.is_empty(), "{} description empty", t.name);
1268            let _schema = t.schema_as_json_value();
1269            // `required` is intentionally absent on memory_themes +
1270            // memory_contradictions + memory_list_documents (all args
1271            // optional with defaults). memory_facts_about has required
1272            // = ["subject"], etc. We don't assert per-tool 'required'
1273            // shape here; the schema's `properties` field is the more
1274            // important signal and is always present.
1275        }
1276        h.shutdown(&runtime);
1277    }
1278
1279    #[test]
1280    fn themes_returns_json_array_on_empty_db() {
1281        let runtime = rt();
1282        let h = Harness::new(&runtime);
1283        runtime.block_on(async {
1284            let r = h
1285                .server
1286                .dispatch_tool("memory_themes", json!({}))
1287                .await
1288                .expect("themes succeeds");
1289            let text = first_text(&r);
1290            // Empty derived layer → empty array JSON. Parses cleanly.
1291            let v: serde_json::Value =
1292                serde_json::from_str(&text).expect("parses as json");
1293            assert!(v.is_array(), "expected array, got: {text}");
1294            assert_eq!(v.as_array().unwrap().len(), 0);
1295        });
1296        h.shutdown(&runtime);
1297    }
1298
1299    #[test]
1300    fn themes_passes_through_window_and_limit_args() {
1301        let runtime = rt();
1302        let h = Harness::new(&runtime);
1303        runtime.block_on(async {
1304            // Should not crash with optional + integer args present.
1305            let r = h
1306                .server
1307                .dispatch_tool(
1308                    "memory_themes",
1309                    json!({ "window_days": 7, "limit": 20 }),
1310                )
1311                .await
1312                .expect("themes with args succeeds");
1313            let text = first_text(&r);
1314            let v: serde_json::Value =
1315                serde_json::from_str(&text).expect("parses as json");
1316            assert!(v.is_array());
1317        });
1318        h.shutdown(&runtime);
1319    }
1320
1321    #[test]
1322    fn facts_about_rejects_empty_subject() {
1323        let runtime = rt();
1324        let h = Harness::new(&runtime);
1325        runtime.block_on(async {
1326            let err = h
1327                .server
1328                .dispatch_tool(
1329                    "memory_facts_about",
1330                    json!({ "subject": "   " }),
1331                )
1332                .await
1333                .expect_err("empty subject must error");
1334            // McpError doesn't expose a clean kind/message accessor; just
1335            // verify the error fires (validation path reached).
1336            let s = format!("{err:?}");
1337            assert!(
1338                s.to_lowercase().contains("subject")
1339                    || s.to_lowercase().contains("invalid"),
1340                "got: {s}"
1341            );
1342        });
1343        h.shutdown(&runtime);
1344    }
1345
1346    #[test]
1347    fn facts_about_returns_array_for_unknown_subject() {
1348        let runtime = rt();
1349        let h = Harness::new(&runtime);
1350        runtime.block_on(async {
1351            let r = h
1352                .server
1353                .dispatch_tool(
1354                    "memory_facts_about",
1355                    json!({ "subject": "NobodyKnowsThisSubject" }),
1356                )
1357                .await
1358                .expect("facts_about with unknown subject succeeds");
1359            let text = first_text(&r);
1360            let v: serde_json::Value =
1361                serde_json::from_str(&text).expect("parses as json");
1362            assert_eq!(v.as_array().unwrap().len(), 0);
1363        });
1364        h.shutdown(&runtime);
1365    }
1366
1367    #[test]
1368    fn facts_about_accepts_include_as_object_arg() {
1369        // Asserts the v0.5.1 P8 arg is parsed (serde default lets it
1370        // be omitted) and forwarded to the query lib without choking
1371        // the dispatcher. We don't seed triples — what we need to
1372        // verify is that the optional bool flows through. Both with
1373        // and without the arg, dispatch succeeds and returns an
1374        // empty array. (Functional coverage of the object-position
1375        // widening lives in the query-crate tests.)
1376        let runtime = rt();
1377        let h = Harness::new(&runtime);
1378        runtime.block_on(async {
1379            // With include_as_object=true.
1380            let r = h
1381                .server
1382                .dispatch_tool(
1383                    "memory_facts_about",
1384                    json!({ "subject": "Maya", "include_as_object": true }),
1385                )
1386                .await
1387                .expect("dispatch with include_as_object=true succeeds");
1388            let v: serde_json::Value = serde_json::from_str(&first_text(&r))
1389                .expect("parses as json");
1390            assert_eq!(v.as_array().unwrap().len(), 0);
1391
1392            // Omitted entirely — must default to false (no error).
1393            let r = h
1394                .server
1395                .dispatch_tool(
1396                    "memory_facts_about",
1397                    json!({ "subject": "Maya" }),
1398                )
1399                .await
1400                .expect("dispatch without include_as_object succeeds (default false)");
1401            let v: serde_json::Value = serde_json::from_str(&first_text(&r))
1402                .expect("parses as json");
1403            assert_eq!(v.as_array().unwrap().len(), 0);
1404        });
1405        h.shutdown(&runtime);
1406    }
1407
1408    #[test]
1409    fn contradictions_returns_json_array_on_empty_db() {
1410        let runtime = rt();
1411        let h = Harness::new(&runtime);
1412        runtime.block_on(async {
1413            let r = h
1414                .server
1415                .dispatch_tool("memory_contradictions", json!({}))
1416                .await
1417                .expect("contradictions succeeds");
1418            let text = first_text(&r);
1419            let v: serde_json::Value =
1420                serde_json::from_str(&text).expect("parses as json");
1421            assert!(v.is_array());
1422            assert_eq!(v.as_array().unwrap().len(), 0);
1423        });
1424        h.shutdown(&runtime);
1425    }
1426
1427    #[test]
1428    fn remember_then_recall_round_trip() {
1429        let runtime = rt();
1430        let h = Harness::new(&runtime);
1431        // Use &h.server directly (no clone) so the only outstanding
1432        // reference at shutdown time is the harness's own. The clone
1433        // path triggered a 5-second writer-thread timeout because the
1434        // local clone held an Arc<Inner> with its own WriteHandle past
1435        // h.shutdown().
1436        runtime.block_on(async {
1437            let r = h
1438                .server
1439                .dispatch_tool("memory_remember", json!({ "content": "the cat sat on the mat" }))
1440                .await
1441                .expect("remember succeeds");
1442            let text = first_text(&r);
1443            assert!(text.starts_with("remembered "), "got: {text}");
1444
1445            let r = h
1446                .server
1447                .dispatch_tool(
1448                    "memory_recall",
1449                    json!({ "query": "the cat sat on the mat", "limit": 5 }),
1450                )
1451                .await
1452                .expect("recall succeeds");
1453            let text = first_text(&r);
1454            assert!(text.contains("the cat sat on the mat"), "got: {text}");
1455        });
1456        h.shutdown(&runtime);
1457    }
1458
1459    #[test]
1460    fn forget_excludes_row_from_subsequent_recall() {
1461        let runtime = rt();
1462        let h = Harness::new(&runtime);
1463
1464        runtime.block_on(async {
1465            let r = h
1466                .server
1467                .dispatch_tool("memory_remember", json!({ "content": "to be forgotten" }))
1468                .await
1469                .unwrap();
1470            let text = first_text(&r);
1471            let mid = text.strip_prefix("remembered ").unwrap().to_string();
1472
1473            h.server
1474                .dispatch_tool(
1475                    "memory_forget",
1476                    json!({ "memory_id": mid, "reason": "test" }),
1477                )
1478                .await
1479                .expect("forget succeeds");
1480
1481            let r = h
1482                .server
1483                .dispatch_tool(
1484                    "memory_recall",
1485                    json!({ "query": "to be forgotten", "limit": 5 }),
1486                )
1487                .await
1488                .unwrap();
1489            let text = first_text(&r);
1490            assert!(
1491                !text.contains(r#""content": "to be forgotten""#),
1492                "forgotten row should be excluded; got: {text}"
1493            );
1494        });
1495        h.shutdown(&runtime);
1496    }
1497
1498    #[test]
1499    fn empty_remember_returns_invalid_params() {
1500        let runtime = rt();
1501        let h = Harness::new(&runtime);
1502        runtime.block_on(async {
1503            let err = h
1504                .server
1505                .dispatch_tool("memory_remember", json!({ "content": "" }))
1506                .await
1507                .unwrap_err();
1508            assert!(format!("{err:?}").contains("must not be empty"));
1509        });
1510        h.shutdown(&runtime);
1511    }
1512
1513    #[test]
1514    fn empty_recall_query_returns_invalid_params() {
1515        let runtime = rt();
1516        let h = Harness::new(&runtime);
1517        runtime.block_on(async {
1518            let err = h
1519                .server
1520                .dispatch_tool("memory_recall", json!({ "query": "   " }))
1521                .await
1522                .unwrap_err();
1523            assert!(format!("{err:?}").contains("must not be empty"));
1524        });
1525        h.shutdown(&runtime);
1526    }
1527
1528    #[test]
1529    fn inspect_with_invalid_id_returns_invalid_params() {
1530        let runtime = rt();
1531        let h = Harness::new(&runtime);
1532        runtime.block_on(async {
1533            let err = h
1534                .server
1535                .dispatch_tool("memory_inspect", json!({ "memory_id": "not-a-uuid" }))
1536                .await
1537                .unwrap_err();
1538            assert!(format!("{err:?}").contains("invalid memory_id"));
1539        });
1540        h.shutdown(&runtime);
1541    }
1542
1543    #[test]
1544    fn forget_unknown_id_returns_invalid_params() {
1545        let runtime = rt();
1546        let h = Harness::new(&runtime);
1547        runtime.block_on(async {
1548            // Valid UUID format but not in episodes — handle_forget
1549            // surfaces NotFound, mapped to invalid_params per
1550            // solo_to_mcp.
1551            let err = h
1552                .server
1553                .dispatch_tool(
1554                    "memory_forget",
1555                    json!({ "memory_id": "00000000-0000-7000-8000-000000000000" }),
1556                )
1557                .await
1558                .unwrap_err();
1559            assert!(format!("{err:?}").contains("not found"));
1560        });
1561        h.shutdown(&runtime);
1562    }
1563
1564    #[test]
1565    fn unknown_tool_name_returns_invalid_params() {
1566        let runtime = rt();
1567        let h = Harness::new(&runtime);
1568        runtime.block_on(async {
1569            let err = h
1570                .server
1571                .dispatch_tool("memory.summon", json!({}))
1572                .await
1573                .unwrap_err();
1574            assert!(format!("{err:?}").contains("unknown tool"));
1575        });
1576        h.shutdown(&runtime);
1577    }
1578
1579    /// Regression guard for v0.4.1's MCP tool name fix, generalised
1580    /// in v0.5.0 Priority 4 to cover **all three** major LLM
1581    /// providers, not just Anthropic.
1582    ///
1583    /// Each provider enforces its own tool-name regex on the
1584    /// function-calling wire. A tool name has to satisfy ALL of them
1585    /// to be portable across clients:
1586    ///
1587    ///   - **Anthropic**: `^[a-zA-Z0-9_-]{1,64}$` (what shipped in
1588    ///     v0.4.1; failing this rejects the entire toolset on Claude
1589    ///     Desktop / Cursor / Claude Code with
1590    ///     `FrontendRemoteMcpToolDefinition.name: String should
1591    ///     match pattern ...`).
1592    ///   - **OpenAI** function-calling: `^[a-zA-Z_][a-zA-Z0-9_-]*$`
1593    ///     with length ≤ 64 (must start with letter or underscore).
1594    ///   - **Gemini** function-calling: documented as a-z, A-Z, 0-9,
1595    ///     underscores and dashes; some sources also allow dots. We
1596    ///     use the conservative intersection — must start with
1597    ///     letter or underscore, alphanumeric + underscore only (no
1598    ///     hyphen, no dot), length ≤ 63. This is the strictest of
1599    ///     the three patterns, so any tool that passes it also
1600    ///     passes the other two. Sources differ on whether Gemini
1601    ///     accepts dots or hyphens; the strictest reading guards us
1602    ///     against the future where one provider tightens the regex
1603    ///     (which is the failure mode v0.4.1 hit on Anthropic). See
1604    ///     <https://github.com/google-gemini/deprecated-generative-ai-python/blob/main/docs/api/google/generativeai/protos/FunctionDeclaration.md>
1605    ///     and <https://ai.google.dev/gemini-api/docs/function-calling>.
1606    ///
1607    /// Lesson banked v0.3 #8: rmcp framing tests pass dot-named
1608    /// tools fine because rmcp's own client-side validation is
1609    /// permissive. Only the downstream provider API enforces the
1610    /// regex. This test gates the names at `cargo test` time so any
1611    /// future tool-name change has to pass all three provider
1612    /// regexes before reaching real clients.
1613    #[test]
1614    fn tool_names_match_cross_provider_regex() {
1615        /// Anthropic API name regex: `^[a-zA-Z0-9_-]{1,64}$`.
1616        fn passes_anthropic(name: &str) -> bool {
1617            let len = name.len();
1618            if !(1..=64).contains(&len) {
1619                return false;
1620            }
1621            name.chars()
1622                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1623        }
1624
1625        /// OpenAI function-calling name regex:
1626        /// `^[a-zA-Z_][a-zA-Z0-9_-]*$`, length ≤ 64.
1627        fn passes_openai(name: &str) -> bool {
1628            let len = name.len();
1629            if !(1..=64).contains(&len) {
1630                return false;
1631            }
1632            let mut chars = name.chars();
1633            let first = match chars.next() {
1634                Some(c) => c,
1635                None => return false,
1636            };
1637            if !(first.is_ascii_alphabetic() || first == '_') {
1638                return false;
1639            }
1640            chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1641        }
1642
1643        /// Gemini function-calling name regex (conservative
1644        /// reading): `^[a-zA-Z_][a-zA-Z0-9_]*$`, length ≤ 63. No
1645        /// hyphen, no dot — strictest of the three so any name that
1646        /// passes this passes the other two.
1647        fn passes_gemini(name: &str) -> bool {
1648            let len = name.len();
1649            if !(1..=63).contains(&len) {
1650                return false;
1651            }
1652            let mut chars = name.chars();
1653            let first = match chars.next() {
1654                Some(c) => c,
1655                None => return false,
1656            };
1657            if !(first.is_ascii_alphabetic() || first == '_') {
1658                return false;
1659            }
1660            chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1661        }
1662
1663        let tools = build_tools();
1664        assert_eq!(
1665            tools.len(),
1666            13,
1667            "expected 13 tools in v0.7.0 (8 v0.5.x + 5 document tools)"
1668        );
1669        // Sanity-check that tool_names() agrees with build_tools().
1670        let tool_name_strings: Vec<String> =
1671            tools.iter().map(|t| t.name.to_string()).collect();
1672        let public_names: Vec<String> =
1673            super::tool_names().iter().map(|s| s.to_string()).collect();
1674        assert_eq!(
1675            tool_name_strings, public_names,
1676            "tool_names() drifted from build_tools() — keep them in sync"
1677        );
1678
1679        for t in tools {
1680            assert!(
1681                passes_anthropic(&t.name),
1682                "tool name {:?} fails Anthropic regex \
1683                 ^[a-zA-Z0-9_-]{{1,64}}$ — see v0.3 lesson #8",
1684                t.name
1685            );
1686            assert!(
1687                passes_openai(&t.name),
1688                "tool name {:?} fails OpenAI function-calling regex \
1689                 ^[a-zA-Z_][a-zA-Z0-9_-]*$ (len ≤ 64)",
1690                t.name
1691            );
1692            assert!(
1693                passes_gemini(&t.name),
1694                "tool name {:?} fails Gemini function-calling regex \
1695                 ^[a-zA-Z_][a-zA-Z0-9_]*$ (len ≤ 63, strict)",
1696                t.name
1697            );
1698        }
1699    }
1700
1701    /// Regression guard for the v0.5.0 Priority 4 jargon pass.
1702    ///
1703    /// Tool descriptions and `get_info().instructions` are the first
1704    /// (and often only) thing a calling LLM reads when its
1705    /// tool-search mechanism decides whether Solo's tools are
1706    /// relevant. Earlier descriptions leaned on Solo-internal
1707    /// vocabulary (`SPO`, `Steward`, `LEFT JOIN`, `candidate pair`,
1708    /// `tagged_with`) which doesn't pattern-match natural-language
1709    /// agent queries like "what do you know about Alex?" — that's
1710    /// the load-bearing v0.5.0 finding from the 2026-05-14
1711    /// thesis-test in Claude Desktop.
1712    ///
1713    /// This test pins the de-jargoning by forbidding the old
1714    /// vocabulary from appearing in any user-facing text. Future
1715    /// contributors who reach for jargon trip the test and have to
1716    /// pick plain-English phrasing instead.
1717    #[test]
1718    fn tool_descriptions_avoid_internal_jargon() {
1719        // Case-insensitive substring match. Drawn from the
1720        // pre-Priority-4 descriptions; expand only if a new term
1721        // creeps in.
1722        const FORBIDDEN: &[&str] = &[
1723            "SPO",
1724            "Steward",
1725            "Steward-flagged",
1726            "LEFT JOIN",
1727            "candidate pair",
1728            "candidate_pair",
1729            "tagged_with",
1730        ];
1731
1732        fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
1733            haystack.to_lowercase().contains(&needle.to_lowercase())
1734        }
1735
1736        // 1. Each tool description.
1737        for t in build_tools() {
1738            for term in FORBIDDEN {
1739                assert!(
1740                    !contains_case_insensitive(&t.description, term),
1741                    "tool {:?} description contains forbidden jargon \
1742                     {:?} — rewrite in plain English (see v0.5.0 \
1743                     Priority 4)",
1744                    t.name,
1745                    term,
1746                );
1747            }
1748        }
1749
1750        // 2. The server-level instructions (what tool-search sees
1751        // first).
1752        let server_info = harness_server_info();
1753        let instructions = server_info
1754            .instructions
1755            .as_deref()
1756            .expect("get_info() must set instructions");
1757        for term in FORBIDDEN {
1758            assert!(
1759                !contains_case_insensitive(instructions, term),
1760                "get_info().instructions contains forbidden jargon \
1761                 {:?} — rewrite in plain English",
1762                term,
1763            );
1764        }
1765    }
1766
1767    /// Build a `ServerInfo` for the jargon test without spinning up
1768    /// the full harness (which needs tokio + tempdir). The
1769    /// `ServerHandler::get_info()` method doesn't take `&self` state
1770    /// in any meaningful way for our impl — it returns a static
1771    /// `ServerInfo` literal — so we construct a minimal-input server
1772    /// just to call it.
1773    fn harness_server_info() -> rmcp::model::ServerInfo {
1774        let runtime = rt();
1775        let h = Harness::new(&runtime);
1776        let info = ServerHandler::get_info(&h.server);
1777        h.shutdown(&runtime);
1778        info
1779    }
1780
1781    // ---- memory_inspect_cluster (v0.5.0 Priority 3) ----
1782
1783    #[test]
1784    fn inspect_cluster_unknown_id_returns_invalid_params() {
1785        // NotFound from solo_query::inspect_cluster is mapped through
1786        // `solo_to_mcp` to `invalid_params` (MCP has no separate
1787        // not-found error shape). Error message should name the id.
1788        let runtime = rt();
1789        let h = Harness::new(&runtime);
1790        runtime.block_on(async {
1791            let err = h
1792                .server
1793                .dispatch_tool(
1794                    "memory_inspect_cluster",
1795                    json!({ "cluster_id": "no-such-cluster" }),
1796                )
1797                .await
1798                .expect_err("unknown cluster must error");
1799            let s = format!("{err:?}");
1800            assert!(
1801                s.contains("no-such-cluster") || s.to_lowercase().contains("not found"),
1802                "expected error to mention the missing cluster id; got: {s}"
1803            );
1804        });
1805        h.shutdown(&runtime);
1806    }
1807
1808    #[test]
1809    fn inspect_cluster_rejects_empty_id() {
1810        let runtime = rt();
1811        let h = Harness::new(&runtime);
1812        runtime.block_on(async {
1813            let err = h
1814                .server
1815                .dispatch_tool(
1816                    "memory_inspect_cluster",
1817                    json!({ "cluster_id": "   " }),
1818                )
1819                .await
1820                .expect_err("blank cluster_id must error");
1821            let s = format!("{err:?}");
1822            assert!(
1823                s.to_lowercase().contains("cluster_id")
1824                    || s.to_lowercase().contains("must not be empty"),
1825                "got: {s}"
1826            );
1827        });
1828        h.shutdown(&runtime);
1829    }
1830
1831    // ---- Document tools (v0.7.0 P5) ----
1832    //
1833    // The five document handlers each have two arg-shape tests:
1834    //   - arg-struct parses from JSON (serde round-trip; defaults work).
1835    //   - dispatch arm routes to the handler (we observe behaviour via
1836    //     a known empty-DB response — bad routing surfaces as
1837    //     "unknown tool" or wrong shape).
1838    //
1839    // Functional coverage (ingest → search → inspect → forget) lives in
1840    // `crates/solo-cli/tests/mcp_smoke.rs` where a real subprocess + real
1841    // writer-with-embedder is wired up. The in-process Harness here uses
1842    // `WriterActor::spawn` which doesn't carry an embedder, so ingest /
1843    // search themselves return an error — but the dispatch + arg-parse
1844    // paths exercise correctly.
1845
1846    #[test]
1847    fn ingest_document_args_parse_with_required_path() {
1848        let v: IngestDocumentArgs =
1849            serde_json::from_value(json!({ "path": "/tmp/notes.md" })).expect("parses");
1850        assert_eq!(v.path, "/tmp/notes.md");
1851        // path is required — missing must reject at deserialization.
1852        let err = serde_json::from_value::<IngestDocumentArgs>(json!({})).unwrap_err();
1853        assert!(format!("{err}").contains("path"));
1854    }
1855
1856    #[test]
1857    fn search_docs_args_parse_with_default_limit() {
1858        let v: SearchDocsArgs =
1859            serde_json::from_value(json!({ "query": "backups" })).expect("parses");
1860        assert_eq!(v.query, "backups");
1861        assert_eq!(v.limit, 5, "default limit must be 5");
1862        let v: SearchDocsArgs =
1863            serde_json::from_value(json!({ "query": "backups", "limit": 20 })).expect("parses");
1864        assert_eq!(v.limit, 20);
1865    }
1866
1867    #[test]
1868    fn inspect_document_args_parse_with_required_doc_id() {
1869        let v: InspectDocumentArgs =
1870            serde_json::from_value(json!({ "doc_id": "abc" })).expect("parses");
1871        assert_eq!(v.doc_id, "abc");
1872        let err = serde_json::from_value::<InspectDocumentArgs>(json!({})).unwrap_err();
1873        assert!(format!("{err}").contains("doc_id"));
1874    }
1875
1876    #[test]
1877    fn list_documents_args_parse_with_all_defaults() {
1878        let v: ListDocumentsArgs = serde_json::from_value(json!({})).expect("parses");
1879        assert_eq!(v.limit, 20, "default limit must be 20");
1880        assert_eq!(v.offset, 0, "default offset must be 0");
1881        assert!(!v.include_forgotten, "default include_forgotten must be false");
1882        let v: ListDocumentsArgs = serde_json::from_value(
1883            json!({ "limit": 5, "offset": 10, "include_forgotten": true }),
1884        )
1885        .expect("parses");
1886        assert_eq!(v.limit, 5);
1887        assert_eq!(v.offset, 10);
1888        assert!(v.include_forgotten);
1889    }
1890
1891    #[test]
1892    fn forget_document_args_parse_with_required_doc_id() {
1893        let v: ForgetDocumentArgs =
1894            serde_json::from_value(json!({ "doc_id": "abc" })).expect("parses");
1895        assert_eq!(v.doc_id, "abc");
1896        let err = serde_json::from_value::<ForgetDocumentArgs>(json!({})).unwrap_err();
1897        assert!(format!("{err}").contains("doc_id"));
1898    }
1899
1900    #[test]
1901    fn ingest_document_rejects_empty_path() {
1902        // Reaches the dispatch arm → handle_ingest_document → empty
1903        // guard fires before the writer is touched. Proves routing.
1904        let runtime = rt();
1905        let h = Harness::new(&runtime);
1906        runtime.block_on(async {
1907            let err = h
1908                .server
1909                .dispatch_tool("memory_ingest_document", json!({ "path": "" }))
1910                .await
1911                .expect_err("empty path must error");
1912            let s = format!("{err:?}");
1913            assert!(
1914                s.to_lowercase().contains("path")
1915                    || s.to_lowercase().contains("must not be empty"),
1916                "got: {s}"
1917            );
1918        });
1919        h.shutdown(&runtime);
1920    }
1921
1922    #[test]
1923    fn search_docs_rejects_empty_query() {
1924        // Empty query trips solo_query::run_doc_search's validation
1925        // → InvalidInput → invalid_params.
1926        let runtime = rt();
1927        let h = Harness::new(&runtime);
1928        runtime.block_on(async {
1929            let err = h
1930                .server
1931                .dispatch_tool("memory_search_docs", json!({ "query": "   " }))
1932                .await
1933                .expect_err("empty query must error");
1934            let s = format!("{err:?}");
1935            assert!(
1936                s.to_lowercase().contains("must not be empty")
1937                    || s.to_lowercase().contains("invalid"),
1938                "got: {s}"
1939            );
1940        });
1941        h.shutdown(&runtime);
1942    }
1943
1944    #[test]
1945    fn inspect_document_unknown_id_returns_invalid_params() {
1946        // Valid UUID format but no row exists → handler returns
1947        // invalid_params with the missing id in the message.
1948        let runtime = rt();
1949        let h = Harness::new(&runtime);
1950        runtime.block_on(async {
1951            let err = h
1952                .server
1953                .dispatch_tool(
1954                    "memory_inspect_document",
1955                    json!({ "doc_id": "00000000-0000-7000-8000-000000000000" }),
1956                )
1957                .await
1958                .expect_err("unknown doc must error");
1959            let s = format!("{err:?}");
1960            assert!(
1961                s.to_lowercase().contains("not found"),
1962                "expected 'not found' message; got: {s}"
1963            );
1964        });
1965        h.shutdown(&runtime);
1966    }
1967
1968    #[test]
1969    fn inspect_document_rejects_malformed_id() {
1970        let runtime = rt();
1971        let h = Harness::new(&runtime);
1972        runtime.block_on(async {
1973            let err = h
1974                .server
1975                .dispatch_tool(
1976                    "memory_inspect_document",
1977                    json!({ "doc_id": "not-a-uuid" }),
1978                )
1979                .await
1980                .expect_err("malformed doc_id must error");
1981            let s = format!("{err:?}");
1982            assert!(s.contains("invalid doc_id"), "got: {s}");
1983        });
1984        h.shutdown(&runtime);
1985    }
1986
1987    #[test]
1988    fn list_documents_returns_empty_array_on_empty_db() {
1989        let runtime = rt();
1990        let h = Harness::new(&runtime);
1991        runtime.block_on(async {
1992            let r = h
1993                .server
1994                .dispatch_tool("memory_list_documents", json!({}))
1995                .await
1996                .expect("list succeeds");
1997            let text = first_text(&r);
1998            let v: serde_json::Value =
1999                serde_json::from_str(&text).expect("parses as json");
2000            assert!(v.is_array(), "expected array, got: {text}");
2001            assert_eq!(v.as_array().unwrap().len(), 0);
2002        });
2003        h.shutdown(&runtime);
2004    }
2005
2006    #[test]
2007    fn list_documents_passes_through_limit_offset_include_args() {
2008        let runtime = rt();
2009        let h = Harness::new(&runtime);
2010        runtime.block_on(async {
2011            let r = h
2012                .server
2013                .dispatch_tool(
2014                    "memory_list_documents",
2015                    json!({ "limit": 5, "offset": 10, "include_forgotten": true }),
2016                )
2017                .await
2018                .expect("list with args succeeds");
2019            let text = first_text(&r);
2020            let v: serde_json::Value =
2021                serde_json::from_str(&text).expect("parses as json");
2022            assert!(v.is_array());
2023        });
2024        h.shutdown(&runtime);
2025    }
2026
2027    #[test]
2028    fn forget_document_rejects_malformed_id() {
2029        let runtime = rt();
2030        let h = Harness::new(&runtime);
2031        runtime.block_on(async {
2032            let err = h
2033                .server
2034                .dispatch_tool(
2035                    "memory_forget_document",
2036                    json!({ "doc_id": "not-a-uuid" }),
2037                )
2038                .await
2039                .expect_err("malformed doc_id must error");
2040            let s = format!("{err:?}");
2041            assert!(s.contains("invalid doc_id"), "got: {s}");
2042        });
2043        h.shutdown(&runtime);
2044    }
2045}
2046
2047// fetch_recall_rows + RecallHit + RecallRow used to live here. Recall
2048// pipeline moved to solo_query::recall in commit (consolidate-recall);
2049// transports just call solo_query::run_recall and format the result.