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