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