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 fourteen tools to MCP clients (Claude Desktop, Cursor, etc.):
6//!
7//! Episode tools (v0.1+, with v0.9.2 additions):
8//!   - `memory_remember(content, source_type?, source_id?, salience?)` —
9//!     store an episode. Returns the new MemoryId. `salience` (v0.9.2+)
10//!     is optional in [0.0, 1.0] and defaults to 0.5.
11//!   - `memory_remember_batch(items)` (v0.9.2+) — atomically store N
12//!     episodes in one writer-actor transaction. Each item has the
13//!     same fields as `memory_remember`. Returns an ordered array of
14//!     MemoryIds; either all items persist or none do.
15//!   - `memory_recall(query, limit?)` — vector search. Returns the top-K
16//!     matches with content + tier + status.
17//!   - `memory_forget(memory_id, reason?)` — soft-delete an episode.
18//!   - `memory_inspect(memory_id)` — return the full episode record.
19//!
20//! Derived-layer tools (v0.4.0+):
21//!   - `memory_themes(window_days?, limit?)` — list cluster themes.
22//!   - `memory_facts_about(subject, ...)` — query the structured-fact
23//!     knowledge graph (subject-predicate-object triples).
24//!   - `memory_contradictions(limit?)` — disagreements flagged during
25//!     consolidation.
26//!
27//! Derived-layer tools (v0.5.0+):
28//!   - `memory_inspect_cluster(cluster_id, full_content?)` — drill
29//!     into one cluster's abstraction + source episodes (truncated).
30//!
31//! Document tools (v0.7.0+):
32//!   - `memory_ingest_document(path)` — read a file from disk, split it
33//!     into chunks, embed each, and store under documents/document_chunks.
34//!   - `memory_search_docs(query, limit?)` — vector search restricted to
35//!     document chunks; returns chunk content + parent-doc context.
36//!   - `memory_inspect_document(doc_id)` — show one document's metadata
37//!     plus a previewed list of its chunks.
38//!   - `memory_list_documents(limit?, offset?, include_forgotten?)` —
39//!     paginate over ingested documents, newest first.
40//!   - `memory_forget_document(doc_id)` — soft-delete a document; chunks
41//!     stop appearing in `memory_search_docs` and tombstone in HNSW.
42//!
43//! ## Transport
44//!
45//! `serve_stdio` wires the server to stdin/stdout for use as a subprocess
46//! ("`claude_desktop_config.json` or `~/.cursor/mcp.json` invokes
47//! `solo mcp-stdio`"). The function awaits a graceful shutdown when stdin
48//! closes (parent disconnects) — same lifecycle as `solo daemon`'s
49//! Ctrl+C path.
50//!
51//! ## What's deferred
52//!
53//! - SSE/HTTP transports — `rmcp` ships them, but v0.1 ships stdio only.
54//! - `prompts/` and `resources/` capabilities — not needed for the
55//!   four-tool surface; ServerHandler defaults return empty lists.
56//! - Tool argument validation beyond JSON Schema typing — we trust rmcp
57//!   to deserialize per the schema, then serde-deserialize into our
58//!   typed param structs. Bad inputs surface as clear errors.
59
60use std::sync::Arc;
61
62use rmcp::handler::server::ServerHandler;
63use rmcp::model::{
64    CallToolRequestParams as CallToolRequestParam, CallToolResult, Content, Implementation,
65    InitializeRequestParams, InitializeResult, ListToolsResult,
66    PaginatedRequestParams as PaginatedRequestParam, ProtocolVersion,
67    ServerCapabilities, ServerInfo, Tool,
68};
69use rmcp::service::{RequestContext, RoleServer};
70use rmcp::{ErrorData as McpError, ServiceExt};
71use serde::{Deserialize, Serialize};
72use solo_core::{
73    Confidence, DocumentId, EncodingContext, Episode, MemoryId, Tier,
74};
75use solo_storage::{TenantHandle, TenantRegistry};
76use std::str::FromStr;
77
78/// The MCP server. Cheap to clone — every field is `Arc`-cloneable.
79///
80/// v0.8.0 P2: an MCP session resolves to **one tenant**. The session's
81/// `tenant_handle` is resolved at `initialize` time (today: from the
82/// CLI invocation via `solo mcp-stdio --tenant <id>`; future versions
83/// may resolve per-bearer-token via OIDC). Subsequent `tools/call`
84/// invocations route through the cached handle without re-resolving.
85/// Operators that need multi-tenant MCP spawn one `solo mcp-stdio`
86/// subprocess per tenant.
87#[derive(Clone)]
88pub struct SoloMcpServer {
89    inner: Arc<Inner>,
90}
91
92struct Inner {
93    /// Multi-tenant registry shared across all sessions. Held so that a
94    /// future MCP capability that lists/inspects other tenants has a
95    /// path to them (out of scope for v0.8.0 P2). P3 (auth) will use
96    /// this to re-resolve the tenant from a bearer-token claim.
97    #[allow(dead_code)]
98    registry: Arc<TenantRegistry>,
99    /// The tenant this MCP session speaks for. Resolved at session
100    /// construction time.
101    tenant: Arc<TenantHandle>,
102    /// Read-path aliases for the canonical `"user"` subject. Sourced
103    /// from `solo.config.toml` `[identity] user_aliases`; threaded
104    /// through to `solo_query::facts_about` so a query for `"alex"`
105    /// also surfaces rows historically extracted as `"user"`. Empty
106    /// vec = behave as today (no expansion).
107    user_aliases: Vec<String>,
108    /// v0.8.0 P4 audit-log principal for this MCP session. MCP is
109    /// bearer-only (no OIDC story in the spec), so the principal is
110    /// effectively `"bearer"` when the daemon was started with
111    /// `--bearer-token-file` and `None` otherwise. Persisted here so
112    /// every tool dispatch threads it into the audit emit without
113    /// reconstructing it per call.
114    audit_principal: Option<String>,
115}
116
117/// v0.9.0 P2: outcome of inspecting the tenant's `[llm]` config + the
118/// peer's `sampling` capability at MCP `initialize` time.
119///
120/// Separating the decision from the actual slot write makes the
121/// gating logic unit-testable without needing a real
122/// `rmcp::Peer<RoleServer>` (whose constructors are private).
123/// `SoloMcpServer::initialize` performs the match and routes to the
124/// side-effect path; tests pin the table directly.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum InitializeDecision {
127    /// Tenant's LLM backend doesn't require an MCP peer; the slot was
128    /// populated eagerly at registry-open time (or stays `None` for
129    /// `LlmConfig::None`). MCP initialize succeeds without writing the
130    /// slot.
131    Allow,
132    /// Tenant's LLM backend is `mcp_sampling` AND the peer advertised
133    /// the `sampling` capability. `populate_sampling_steward` writes a
134    /// peer-bound Steward into the slot.
135    PopulateSamplingSteward,
136    /// Tenant's LLM backend is `mcp_sampling` but the peer did NOT
137    /// advertise the `sampling` capability. MCP initialize must refuse
138    /// with the locked BLOCKER 2 error message.
139    RejectMissingSamplingCapability,
140}
141
142/// v0.9.0 P2: decide the initialize outcome given the tenant's
143/// `[llm]` config and whether the peer advertised the `sampling`
144/// capability.
145///
146/// Pure function — no side effects, no rmcp peer required. Pinned by
147/// `initialize_decision_*` tests.
148pub fn initialize_decision(
149    llm_settings: &Option<solo_storage::LlmSettings>,
150    peer_sampling_supported: bool,
151) -> InitializeDecision {
152    match llm_settings {
153        Some(settings) if settings.requires_mcp_peer() => {
154            if peer_sampling_supported {
155                InitializeDecision::PopulateSamplingSteward
156            } else {
157                InitializeDecision::RejectMissingSamplingCapability
158            }
159        }
160        _ => InitializeDecision::Allow,
161    }
162}
163
164/// v0.9.0 P2: locked error message body for both the daemon-startup
165/// rejection guard and the MCP `initialize` capability gate (plan §3
166/// Decision 4 / BLOCKER 2 resolution). Returned verbatim to the
167/// operator so the commented-out TOML snippets are copy-pasteable.
168///
169/// Lives at module scope so the daemon startup path (in `solo-cli`)
170/// and the `SoloMcpServer::initialize` hook share one source of truth
171/// — a future audit-revision can grep the locked phrasing without
172/// chasing two divergent copies.
173pub fn sampling_capability_missing_error_message() -> String {
174    [
175        "LLM backend `mcp_sampling` requires a connected MCP client that",
176        "advertises the `sampling` capability at initialize. Either the",
177        "current MCP client does not support sampling, or this Solo",
178        "process is running in daemon-only mode (no peer to call back).",
179        "",
180        "Pick one of:",
181        "",
182        "  # Anthropic (hosted):",
183        "  [llm]",
184        "  mode = \"anthropic\"",
185        "  api_key_env = \"ANTHROPIC_API_KEY\"",
186        "  model = \"claude-sonnet-4-6\"",
187        "",
188        "  # OpenAI (hosted):",
189        "  [llm]",
190        "  mode = \"openai\"",
191        "  api_key_env = \"OPENAI_API_KEY\"",
192        "  model = \"gpt-5o\"",
193        "",
194        "  # Ollama (local daemon):",
195        "  [llm]",
196        "  mode = \"ollama\"",
197        "  base_url = \"http://localhost:11434\"",
198        "  model = \"qwen3-coder:30b\"",
199        "",
200        "  # None (cluster-only; abstractions skipped):",
201        "  [llm]",
202        "  mode = \"none\"",
203        "",
204        "See docs/releases/v0.9.0.md \u{00a7}LLM-backend selection for details.",
205    ]
206    .join("\n")
207}
208
209/// v0.8.1 P2: env var name MCP clients set when launching the server
210/// process to attribute audit rows on the stdio transport. Closes the
211/// v0.8.0 known-issue gap where MCP audit rows always carried
212/// `principal_subject = NULL` on the daemon path.
213///
214/// Precedence (when the future HTTP-MCP transport lands):
215///   1. `Authorization: Bearer <token>` header on the HTTP-MCP request
216///      (resolved through `AuthConfig::Bearer` validator).
217///   2. `SOLO_MCP_PRINCIPAL_TOKEN` env var on the spawned process.
218///
219/// For the v0.8.x stdio-only world only the env-var path applies; the
220/// header path is a no-op (no HTTP transport wired). The constant lives
221/// at module scope so external callers (CLI subcommand, tests) reference
222/// it by name rather than re-typing the string literal.
223pub const ENV_MCP_PRINCIPAL_TOKEN: &str = "SOLO_MCP_PRINCIPAL_TOKEN";
224
225/// v0.8.1 P2: resolve the MCP-session principal at `initialize`-time.
226///
227/// Reads `SOLO_MCP_PRINCIPAL_TOKEN` env var (stdio path); future HTTP-MCP
228/// callers will pass the bearer header value in via the explicit
229/// `header_value` arg. The header beats the env when both are present.
230///
231/// Returns `Some(subject)` on resolution success; `None` when neither
232/// source carries a non-empty value. Empty / whitespace-only values are
233/// treated as absent so an accidentally-set `SOLO_MCP_PRINCIPAL_TOKEN=""`
234/// in a launcher script doesn't pin every audit row to a blank principal.
235///
236/// The current implementation treats the env var value as the principal
237/// subject directly. A future hardening pass can validate against the
238/// daemon's `[auth] bearer.token` config to refuse mismatched tokens —
239/// today the env var is operator-trusted (same trust model as
240/// `SOLO_PASSPHRASE`).
241pub fn resolve_mcp_principal(header_value: Option<&str>) -> Option<String> {
242    // HTTP-MCP path wins when configured.
243    if let Some(h) = header_value {
244        if let Some(token) = h.strip_prefix("Bearer ") {
245            let trimmed = token.trim();
246            if !trimmed.is_empty() {
247                // Header carries the raw bearer token. Same shape as the
248                // stdio env-var path: the *value* is the principal
249                // subject in v0.8.1; v0.8.2+ may validate against a
250                // configured token set and surface the JWT `sub` claim
251                // instead.
252                return Some(trimmed.to_string());
253            }
254        }
255    }
256    // Stdio env-var fallback.
257    match std::env::var(ENV_MCP_PRINCIPAL_TOKEN) {
258        Ok(v) => {
259            let trimmed = v.trim();
260            if trimmed.is_empty() {
261                None
262            } else {
263                Some(trimmed.to_string())
264            }
265        }
266        Err(_) => None,
267    }
268}
269
270impl SoloMcpServer {
271    /// Build a server speaking for `tenant` (v0.8.0 P2 — one MCP session
272    /// ↔ one tenant). The registry is held so future capabilities can
273    /// reach across tenants if needed; today every handler routes
274    /// through `self.inner.tenant`.
275    ///
276    /// v0.8.1 P2: auto-resolves the audit principal from the
277    /// `SOLO_MCP_PRINCIPAL_TOKEN` env var (see [`resolve_mcp_principal`]).
278    /// When neither the env var nor a header is set, the principal stays
279    /// `None` — preserving v0.8.0 behavior for single-user setups.
280    pub fn new_for_tenant(
281        registry: Arc<TenantRegistry>,
282        tenant: Arc<TenantHandle>,
283        user_aliases: Vec<String>,
284    ) -> Self {
285        let principal = resolve_mcp_principal(None);
286        Self::new_for_tenant_with_principal(registry, tenant, user_aliases, principal)
287    }
288
289    /// v0.8.0 P4: like [`Self::new_for_tenant`], but records an explicit
290    /// audit principal subject for every tool dispatch. MCP is
291    /// bearer-only at v0.8.0 — the orchestration layer (today: the
292    /// daemon's `--bearer-token-file` path) decides whether a session
293    /// counts as "bearer-authenticated" and passes `Some("bearer")`;
294    /// CLI / unauth paths pass `None`.
295    ///
296    /// v0.8.1 P2: when the caller passes `audit_principal = None`, the
297    /// env-var auto-resolution still runs (in `new_for_tenant`). Callers
298    /// who want to *explicitly* suppress env-var resolution can call
299    /// this method with `None` after `std::env::remove_var(...)`, or use
300    /// the dedicated test constructor that bypasses env reads.
301    pub fn new_for_tenant_with_principal(
302        registry: Arc<TenantRegistry>,
303        tenant: Arc<TenantHandle>,
304        user_aliases: Vec<String>,
305        audit_principal: Option<String>,
306    ) -> Self {
307        Self {
308            inner: Arc::new(Inner {
309                registry,
310                tenant,
311                user_aliases,
312                audit_principal,
313            }),
314        }
315    }
316}
317
318/// Convenience: run the server over stdio and await its termination.
319/// Returns when stdin closes (parent disconnect) or the runtime exits.
320pub async fn serve_stdio(server: SoloMcpServer) -> anyhow::Result<()> {
321    use rmcp::transport::io::stdio;
322    let (stdin, stdout) = stdio();
323    let running = server.serve((stdin, stdout)).await?;
324    running.waiting().await?;
325    Ok(())
326}
327
328// ---------------------------------------------------------------------------
329// Tool argument schemas
330// ---------------------------------------------------------------------------
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct RememberArgs {
334    pub content: String,
335    #[serde(default)]
336    pub source_type: Option<String>,
337    #[serde(default)]
338    pub source_id: Option<String>,
339    /// v0.9.2 — optional salience in [0.0, 1.0]. `None` → 0.5 (preserves
340    /// pre-v0.9.2 behaviour). Out-of-range values are rejected by
341    /// [`Self::validate_salience`] before reaching the writer.
342    #[serde(default)]
343    pub salience: Option<f32>,
344}
345
346/// v0.9.2 — one item in a `memory_remember_batch` request.
347///
348/// Mirrors [`RememberArgs`] field-for-field minus the wrapper-tool
349/// invariant: callers pass an array of these inside [`RememberBatchArgs`].
350/// All items in a batch are persisted in a single `BEGIN IMMEDIATE`
351/// transaction (per dev-log 0120 §3 Decision A) so partial-failure
352/// scenarios are impossible from the client's perspective — either
353/// every item lands or none do.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct RememberItem {
356    pub content: String,
357    #[serde(default)]
358    pub source_type: Option<String>,
359    #[serde(default)]
360    pub source_id: Option<String>,
361    /// Optional salience in [0.0, 1.0]; `None` → 0.5. See
362    /// [`RememberArgs::salience`].
363    #[serde(default)]
364    pub salience: Option<f32>,
365}
366
367/// v0.9.2 — args for the new `memory_remember_batch` MCP tool.
368///
369/// Wraps `Vec<RememberItem>`. The handler validates `items.is_empty()`
370/// and `items.len() > MAX_REMEMBER_BATCH_SIZE` before any embedding
371/// work; per-item content/salience is validated immediately afterwards.
372#[derive(Debug, Clone, Serialize, Deserialize)]
373pub struct RememberBatchArgs {
374    pub items: Vec<RememberItem>,
375}
376
377/// Validate that an optional salience value is well-formed (NaN-free
378/// and inside `[0.0, 1.0]`). Centralised so both `memory_remember` and
379/// `memory_remember_batch` share the same rejection shape.
380fn validate_salience(salience: Option<f32>) -> std::result::Result<(), McpError> {
381    if let Some(s) = salience {
382        if !s.is_finite() || !(0.0..=1.0).contains(&s) {
383            return Err(McpError::invalid_params(
384                format!("salience must be in [0.0, 1.0]; got {s}"),
385                None,
386            ));
387        }
388    }
389    Ok(())
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct RecallArgs {
394    pub query: String,
395    #[serde(default = "default_limit")]
396    pub limit: usize,
397}
398
399fn default_limit() -> usize {
400    5
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct ForgetArgs {
405    pub memory_id: String,
406    #[serde(default = "default_forget_reason")]
407    pub reason: String,
408}
409
410fn default_forget_reason() -> String {
411    "user-initiated via MCP".into()
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct InspectArgs {
416    pub memory_id: String,
417}
418
419// Path 1 derived-layer tools (v0.4.0+) — query the Steward's outputs.
420// `solo_query::derived` is the single source of truth; these handlers
421// just translate JSON args to function args and serialise the result
422// vec to JSON for the MCP wire.
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
425pub struct ThemesArgs {
426    /// Optional time window in days; `None` = unfiltered, return up
427    /// to `limit` most-recent themes across all time. `Some(7)` =
428    /// "themes from the last week".
429    #[serde(default)]
430    pub window_days: Option<i64>,
431    #[serde(default = "default_limit")]
432    pub limit: usize,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct FactsAboutArgs {
437    /// Subject id to query — required (predicate-only scans
438    /// intentionally not supported).
439    pub subject: String,
440    #[serde(default)]
441    pub predicate: Option<String>,
442    #[serde(default)]
443    pub since_ms: Option<i64>,
444    #[serde(default)]
445    pub until_ms: Option<i64>,
446    /// v0.5.1 Priority 8 — widen the query to also match rows where
447    /// `subject` appears as the object (e.g. surface "Sam pushes back
448    /// on PRs about Maya" under `facts_about(subject="maya")`).
449    /// Default `false` preserves v0.5.0 behaviour.
450    #[serde(default)]
451    pub include_as_object: bool,
452    #[serde(default = "default_limit")]
453    pub limit: usize,
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize)]
457pub struct ContradictionsArgs {
458    #[serde(default = "default_limit")]
459    pub limit: usize,
460}
461
462/// Args for `memory_inspect_cluster` (v0.5.0 Priority 3). `cluster_id`
463/// is required; `full_content` is opt-in for the rare power-user case
464/// where 200-char-per-episode truncation is too aggressive.
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct InspectClusterArgs {
467    pub cluster_id: String,
468    /// If `true`, episode `content` fields are returned verbatim. If
469    /// `false` or omitted (the default), each episode's content is
470    /// truncated to `solo_query::EPISODE_TRUNCATE_CHARS` chars with a
471    /// trailing `…`.
472    #[serde(default)]
473    pub full_content: bool,
474}
475
476// Document tools (v0.7.0+). Five args structs paired with five handlers.
477// Wire shapes per `docs/dev-log/0083-v0.7.0-implementation-plan.md` §2 P5.
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct IngestDocumentArgs {
481    /// Server-side filesystem path to the file to ingest. Must be
482    /// readable by the Solo process. The writer parses the file by
483    /// extension, splits it into ~500-token chunks, embeds each, and
484    /// stores them under `documents` + `document_chunks`.
485    pub path: String,
486}
487
488#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct SearchDocsArgs {
490    pub query: String,
491    #[serde(default = "default_search_docs_limit")]
492    pub limit: usize,
493}
494
495fn default_search_docs_limit() -> usize {
496    5
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct InspectDocumentArgs {
501    pub doc_id: String,
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct ListDocumentsArgs {
506    #[serde(default = "default_list_documents_limit")]
507    pub limit: usize,
508    #[serde(default)]
509    pub offset: usize,
510    /// If `true`, also include documents the user has forgotten. Default
511    /// `false` matches the agent-UX expectation that recall + listing
512    /// ignore soft-deleted rows.
513    #[serde(default)]
514    pub include_forgotten: bool,
515}
516
517fn default_list_documents_limit() -> usize {
518    20
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize)]
522pub struct ForgetDocumentArgs {
523    pub doc_id: String,
524}
525
526// ---------------------------------------------------------------------------
527// ServerHandler implementation
528// ---------------------------------------------------------------------------
529
530impl ServerHandler for SoloMcpServer {
531    fn get_info(&self) -> ServerInfo {
532        // rmcp 1.x: ServerInfo is non-exhaustive AND lives in another crate,
533        // so neither struct-literal nor functional-update syntax (..) is
534        // allowed from outside. Build via mut on a Default::default().
535        let capabilities = ServerCapabilities::builder()
536            .enable_tools()
537            .build();
538        let mut info = ServerInfo::default();
539        info.protocol_version = ProtocolVersion::default();
540        info.capabilities = capabilities;
541        // v0.9.1 P1 Fix 1 — `Implementation::from_build_env()` reads
542        // `CARGO_PKG_NAME` + `CARGO_PKG_VERSION` from rmcp's OWN build
543        // environment (the helper lives in rmcp, so the proc-macro
544        // expansion captures rmcp's manifest, not ours). On v0.9.0 every
545        // Solo MCP daemon self-identified as `{name: "rmcp", version: "1.7.0"}`.
546        // Pinned by `tests::server_info_identity_is_solo_not_rmcp_or_solo_api`.
547        // The literal `"solo"` (not `env!("CARGO_PKG_NAME")`) is deliberate:
548        // this crate is `solo-api`, but the operator-facing identity is
549        // the binary name `solo`.
550        info.server_info = Implementation::new(
551            "solo".to_string(),
552            env!("CARGO_PKG_VERSION").to_string(),
553        );
554        info.instructions = Some(
555            "Solo gives you persistent memory across conversations \
556                 with this user — what they've told you before, the \
557                 people and projects in their life, and where their \
558                 stated beliefs have shifted, plus a library of \
559                 documents the user has ingested (notes, runbooks, \
560                 PDFs). Reach for these tools whenever the user \
561                 references something from earlier (\"like I \
562                 mentioned\", \"the project I'm working on\", \"my \
563                 friend Alex\", \"the notes I uploaded last week\") \
564                 or asks a question that hinges on personal context \
565                 or document content you don't have in the current \
566                 chat. \
567                 \n\nTools to write or look up specific moments: \
568                 memory_remember (save something worth keeping), \
569                 memory_recall (search past conversations by topic), \
570                 memory_inspect (show one saved item by id), \
571                 memory_forget (delete one saved item). \
572                 \n\nTools for the bigger picture (populated as the \
573                 user uses Solo over time): memory_themes (recent \
574                 topics they've been thinking about), \
575                 memory_facts_about (what you know about a person, \
576                 project, or place — \"what do you know about \
577                 Alex?\"), memory_contradictions (places where the \
578                 user has said two things that disagree — surface \
579                 these before answering), memory_inspect_cluster \
580                 (the raw conversations behind one summary). \
581                 \n\nTools for the user's documents: \
582                 memory_ingest_document (read a file from disk and \
583                 add it to Solo's library), memory_search_docs \
584                 (search across ingested documents by topic — use \
585                 when the user asks about something they wrote down \
586                 or saved as a file), memory_inspect_document (show \
587                 one document's metadata plus a preview of its \
588                 chunks), memory_list_documents (browse documents \
589                 by recency), memory_forget_document (drop a \
590                 document from the library)."
591                .into(),
592        );
593        info
594    }
595
596    /// v0.9.0 P2: override `initialize` so we can:
597    ///
598    ///   1. Cache the client's `InitializeRequestParams` on the peer
599    ///      (delegates to rmcp's default for this).
600    ///   2. If the tenant's `[llm] mode = "mcp_sampling"`:
601    ///      a. Refuse to initialize when the peer didn't advertise the
602    ///         `sampling` capability — surfaces the BLOCKER 2-locked
603    ///         error message so the user sees commented-out
604    ///         alternative TOML blocks.
605    ///      b. Otherwise build a `SamplingLlmClient`-backed Steward and
606    ///         write it into `tenant.steward_slot()` so the writer
607    ///         actor's next consolidate-tick reads a populated slot.
608    ///   3. For any other `[llm]` mode, return the configured tools
609    ///      surface unchanged (the slot was eagerly populated at
610    ///      registry-open time by the static StewardFactory).
611    async fn initialize(
612        &self,
613        request: InitializeRequestParams,
614        context: RequestContext<RoleServer>,
615    ) -> std::result::Result<InitializeResult, McpError> {
616        // Defer to rmcp's default for peer-info caching (matches the
617        // `if peer_info().is_none()` shape).
618        if context.peer.peer_info().is_none() {
619            context.peer.set_peer_info(request.clone());
620        }
621
622        let llm_settings =
623            self.inner.tenant.config().llm.as_ref().cloned();
624        let peer_sampling_supported =
625            request.capabilities.sampling.is_some();
626        match initialize_decision(&llm_settings, peer_sampling_supported) {
627            InitializeDecision::Allow => {}
628            InitializeDecision::PopulateSamplingSteward => {
629                // Build the sampling-backed Steward against the live
630                // peer + the per-tenant write handle, then write it
631                // into the slot.
632                self.populate_sampling_steward(&context).await;
633            }
634            InitializeDecision::RejectMissingSamplingCapability => {
635                return Err(McpError::invalid_request(
636                    sampling_capability_missing_error_message(),
637                    None,
638                ));
639            }
640        }
641
642        Ok(self.get_info())
643    }
644
645    async fn list_tools(
646        &self,
647        _request: Option<PaginatedRequestParam>,
648        _context: RequestContext<RoleServer>,
649    ) -> std::result::Result<ListToolsResult, McpError> {
650        Ok(ListToolsResult {
651            tools: build_tools(),
652            next_cursor: None,
653            ..Default::default()
654        })
655    }
656
657    async fn call_tool(
658        &self,
659        request: CallToolRequestParam,
660        _context: RequestContext<RoleServer>,
661    ) -> std::result::Result<CallToolResult, McpError> {
662        let CallToolRequestParam { name, arguments, .. } = request;
663        let args_value = serde_json::Value::Object(arguments.unwrap_or_default());
664        self.dispatch_tool(&name, args_value).await
665    }
666}
667
668impl SoloMcpServer {
669    /// v0.9.0 P2: build a sampling-backed `Arc<Steward>` for the
670    /// current MCP session and write it into the tenant's
671    /// `steward_slot`. Called from [`Self::initialize`] when:
672    ///
673    ///   * `tenant.config().llm.requires_mcp_peer()` is true, AND
674    ///   * the peer advertised the `sampling` capability.
675    ///
676    /// Implementation notes:
677    ///
678    ///   * `StewardConfig::from_env()` is parsed best-effort; if the
679    ///     env vars are malformed, we fall back to `default()` and
680    ///     log a warning. This matches `daemon.rs`'s tolerance — a
681    ///     bad env var shouldn't block an MCP session from initialising.
682    ///
683    ///   * The slot is OVERWRITTEN unconditionally — a fresh MCP
684    ///     session always wins. If a prior session's
685    ///     `SamplingLlmClient` had outstanding requests, they error out
686    ///     on the rmcp layer when their peer drops.
687    ///
688    ///   * The cached `audit_principal` is the one the MCP server
689    ///     constructed for this session via `resolve_mcp_principal`.
690    ///     Every `peer.create_message` call from this Steward routes
691    ///     that principal through to the per-tenant
692    ///     `AuditOperation::LlmSamplingCall` row.
693    async fn populate_sampling_steward(
694        &self,
695        context: &RequestContext<RoleServer>,
696    ) {
697        let steward_config = solo_steward::StewardConfig::from_env()
698            .unwrap_or_else(|e| {
699                tracing::warn!(
700                    error = %e,
701                    "v0.9.0 P2: StewardConfig::from_env failed at MCP \
702                     initialize; falling back to defaults"
703                );
704                solo_steward::StewardConfig::default()
705            });
706        // v0.9.0 P5 (M3 wiring): read `[sampling]` from the tenant's
707        // already-parsed `SoloConfig`. `SamplingConfig::default()` lands
708        // when the block is omitted (5s window / 10 max-batch); operator
709        // overrides flow through to `build_sampling_steward` and into
710        // `SamplingCoordinator::with_settings`.
711        let sampling_config = self.inner.tenant.config().sampling.clone();
712        let peer = context.peer.clone();
713        let write_handle = self.inner.tenant.write().clone();
714        let steward = crate::llm::build_sampling_steward(
715            peer,
716            write_handle,
717            self.inner.audit_principal.clone(),
718            steward_config,
719            sampling_config.clone(),
720        );
721        let slot = self.inner.tenant.steward_slot();
722        let mut guard = slot.write().await;
723        *guard = Some(steward);
724        tracing::info!(
725            tenant = %self.inner.tenant.tenant_id(),
726            coalesce_window_ms = sampling_config.coalesce_window_ms,
727            coalesce_max_requests = sampling_config.coalesce_max_requests,
728            "v0.9.0 P5: MCP-sampling Steward attached to tenant.steward_slot \
729             (PeerSamplingClient → SamplingCoordinator → SamplingLlmClient)"
730        );
731    }
732
733    /// Direct tool-dispatch path used by both `call_tool` (the
734    /// ServerHandler trait method, behind the rmcp protocol layer) and
735    /// in-process tests that don't want to spin up a full transport pair.
736    /// Bypasses `RequestContext` (which requires a `Peer` not constructible
737    /// outside rmcp internals).
738    pub async fn dispatch_tool(
739        &self,
740        name: &str,
741        args_value: serde_json::Value,
742    ) -> std::result::Result<CallToolResult, McpError> {
743        match name {
744            "memory_remember" => {
745                let args: RememberArgs = parse_args(&args_value)?;
746                self.handle_remember(args).await
747            }
748            "memory_remember_batch" => {
749                let args: RememberBatchArgs = parse_args(&args_value)?;
750                self.handle_remember_batch(args).await
751            }
752            "memory_recall" => {
753                let args: RecallArgs = parse_args(&args_value)?;
754                self.handle_recall(args).await
755            }
756            "memory_forget" => {
757                let args: ForgetArgs = parse_args(&args_value)?;
758                self.handle_forget(args).await
759            }
760            "memory_inspect" => {
761                let args: InspectArgs = parse_args(&args_value)?;
762                self.handle_inspect(args).await
763            }
764            "memory_themes" => {
765                let args: ThemesArgs = parse_args(&args_value)?;
766                self.handle_themes(args).await
767            }
768            "memory_facts_about" => {
769                let args: FactsAboutArgs = parse_args(&args_value)?;
770                self.handle_facts_about(args).await
771            }
772            "memory_contradictions" => {
773                let args: ContradictionsArgs = parse_args(&args_value)?;
774                self.handle_contradictions(args).await
775            }
776            "memory_inspect_cluster" => {
777                let args: InspectClusterArgs = parse_args(&args_value)?;
778                self.handle_inspect_cluster(args).await
779            }
780            "memory_ingest_document" => {
781                let args: IngestDocumentArgs = parse_args(&args_value)?;
782                self.handle_ingest_document(args).await
783            }
784            "memory_search_docs" => {
785                let args: SearchDocsArgs = parse_args(&args_value)?;
786                self.handle_search_docs(args).await
787            }
788            "memory_inspect_document" => {
789                let args: InspectDocumentArgs = parse_args(&args_value)?;
790                self.handle_inspect_document(args).await
791            }
792            "memory_list_documents" => {
793                let args: ListDocumentsArgs = parse_args(&args_value)?;
794                self.handle_list_documents(args).await
795            }
796            "memory_forget_document" => {
797                let args: ForgetDocumentArgs = parse_args(&args_value)?;
798                self.handle_forget_document(args).await
799            }
800            other => Err(McpError::invalid_params(
801                format!("unknown tool `{other}`"),
802                None,
803            )),
804        }
805    }
806
807    /// List the tools this server exposes. Mirrors `ServerHandler::list_tools`
808    /// without requiring a RequestContext.
809    pub fn dispatch_list_tools(&self) -> Vec<Tool> {
810        build_tools()
811    }
812}
813
814fn parse_args<T: serde::de::DeserializeOwned>(
815    v: &serde_json::Value,
816) -> std::result::Result<T, McpError> {
817    serde_json::from_value(v.clone()).map_err(|e| {
818        McpError::invalid_params(format!("invalid tool arguments: {e}"), None)
819    })
820}
821
822fn solo_to_mcp(e: solo_core::Error) -> McpError {
823    use solo_core::Error;
824    match e {
825        Error::NotFound(msg) => McpError::invalid_params(msg, None),
826        Error::InvalidInput(msg) => McpError::invalid_params(msg, None),
827        Error::Conflict(msg) => McpError::invalid_params(msg, None),
828        other => McpError::internal_error(other.to_string(), None),
829    }
830}
831
832// ---------------------------------------------------------------------------
833// Tool definitions (JSON Schema)
834// ---------------------------------------------------------------------------
835
836fn build_tools() -> Vec<Tool> {
837    vec![
838        Tool::new(
839            "memory_remember",
840            "Save something the user has told you — a fact, a \
841             preference, a name, a date, a context — so you can pick \
842             it up next conversation. Use whenever the user mentions \
843             something they'd reasonably expect you to recall later \
844             (\"I just started at Quotient\", \"my partner is Maya\"). \
845             Returns the saved item's id.",
846            json_schema_object(serde_json::json!({
847                "type": "object",
848                "properties": {
849                    "content": {
850                        "type": "string",
851                        "description": "The text to remember.",
852                    },
853                    "source_type": {
854                        "type": "string",
855                        "description": "Optional source-type tag (default: \"user_message\"). See docs/mcp/source-types.md for convention values.",
856                    },
857                    "source_id": {
858                        "type": "string",
859                        "description": "Optional upstream id for traceability.",
860                    },
861                    "salience": {
862                        "type": "number",
863                        "description": "Optional salience in [0.0, 1.0]; defaults to 0.5. Higher values bias toward recall ranking + retention. v0.9.2+.",
864                        "minimum": 0.0,
865                        "maximum": 1.0,
866                    },
867                },
868                "required": ["content"],
869            })),
870        ),
871        // v0.9.2 — atomic batched-remember for agentic clients. Wraps
872        // every item in one BEGIN IMMEDIATE tx so a single
873        // `memory_remember_batch` call either persists all N items or
874        // none. Designed for the solo-jarvis turn-flush pattern (per
875        // dev-log 0120 §1).
876        Tool::new(
877            "memory_remember_batch",
878            "Save several items atomically in one transaction — either \
879             every item lands or none does. Use this when you have a \
880             collection of related episodes from one logical step (a \
881             conversation turn, a tool-output bundle, an ingest batch) \
882             and partial success would leave the user's memory in a \
883             confusing half-state. Each item carries the same fields as \
884             memory_remember (content + optional source_type, source_id, \
885             salience). Returns an ordered array of memory_ids matching \
886             the input items. v0.9.2+.",
887            json_schema_object(serde_json::json!({
888                "type": "object",
889                "properties": {
890                    "items": {
891                        "type": "array",
892                        "description": "Items to remember atomically. Max 200 per call.",
893                        "minItems": 1,
894                        "maxItems": 200,
895                        "items": {
896                            "type": "object",
897                            "properties": {
898                                "content": {
899                                    "type": "string",
900                                    "description": "The text to remember.",
901                                },
902                                "source_type": {
903                                    "type": "string",
904                                    "description": "Optional source-type tag (default: \"user_message\"). See docs/mcp/source-types.md.",
905                                },
906                                "source_id": {
907                                    "type": "string",
908                                    "description": "Optional upstream id for traceability.",
909                                },
910                                "salience": {
911                                    "type": "number",
912                                    "description": "Optional salience in [0.0, 1.0]; defaults to 0.5.",
913                                    "minimum": 0.0,
914                                    "maximum": 1.0,
915                                },
916                            },
917                            "required": ["content"],
918                        },
919                    },
920                },
921                "required": ["items"],
922            })),
923        ),
924        Tool::new(
925            "memory_recall",
926            "Search past conversations with this user by topic or \
927             phrase. Returns up to `limit` of the closest matches, \
928             best match first. Use when the user references \
929             something they said before (\"that book I told you \
930             about\", \"the bug we were debugging last week\"). \
931             Skips items the user has deleted.",
932            json_schema_object(serde_json::json!({
933                "type": "object",
934                "properties": {
935                    "query": {
936                        "type": "string",
937                        "description": "The query text.",
938                    },
939                    "limit": {
940                        "type": "integer",
941                        "description": "Maximum results (default 5).",
942                        "minimum": 1,
943                        "maximum": 100,
944                    },
945                },
946                "required": ["query"],
947            })),
948        ),
949        Tool::new(
950            "memory_forget",
951            "Delete one saved item by id. Use when the user asks you \
952             to forget something specific (\"forget that I said \
953             X\"). The item stops appearing in future recalls. \
954             Reversible only via backups.",
955            json_schema_object(serde_json::json!({
956                "type": "object",
957                "properties": {
958                    "memory_id": {
959                        "type": "string",
960                        "description": "MemoryId to forget (UUID v7).",
961                    },
962                    "reason": {
963                        "type": "string",
964                        "description": "Optional free-form reason (logged, not yet persisted).",
965                    },
966                },
967                "required": ["memory_id"],
968            })),
969        ),
970        Tool::new(
971            "memory_inspect",
972            "Show the full record for one saved item — when it was \
973             saved, where it came from, and the full text. Use after \
974             memory_recall when you want the complete content of a \
975             specific hit (recall results may be truncated).",
976            json_schema_object(serde_json::json!({
977                "type": "object",
978                "properties": {
979                    "memory_id": {
980                        "type": "string",
981                        "description": "MemoryId to inspect (UUID v7).",
982                    },
983                },
984                "required": ["memory_id"],
985            })),
986        ),
987        // Path 1 derived-layer tools (v0.4.0+) — query the Steward's
988        // outputs. These four are populated by `solo consolidate` and
989        // were previously unreadable except via direct SQL.
990        Tool::new(
991            "memory_themes",
992            "Recent topics the user has been thinking about. Use to \
993             orient yourself at the start of a conversation, or when \
994             the user asks \"what have I been up to\" / \"what was I \
995             working on last week\". Pass `window_days` to scope \
996             (e.g. 7 for last week); omit for all-time.",
997            json_schema_object(serde_json::json!({
998                "type": "object",
999                "properties": {
1000                    "window_days": {
1001                        "type": "integer",
1002                        "description": "Optional time window in days. Omit for unfiltered.",
1003                        "minimum": 1,
1004                    },
1005                    "limit": {
1006                        "type": "integer",
1007                        "description": "Maximum results (default 5).",
1008                        "minimum": 1,
1009                        "maximum": 100,
1010                    },
1011                },
1012            })),
1013        ),
1014        Tool::new(
1015            "memory_facts_about",
1016            "Look up what you remember about a person, project, or \
1017             topic — names, dates, preferences, relationships. Use \
1018             when the user asks \"what do you know about Alex?\", \
1019             \"when did I start at Quotient?\", \"who is Maya?\", or \
1020             whenever you need grounded facts about someone or \
1021             something before answering. Subject is required (the \
1022             person/place/thing you're asking about); narrow further \
1023             with `predicate` (\"works_at\", \"lives_in\") or a date \
1024             range. Set `include_as_object=true` to also surface \
1025             facts where the subject appears on the receiving side of \
1026             a relationship (e.g. \"Sam pushes back on PRs about \
1027             Maya\" surfaces under facts_about(subject=\"Maya\", \
1028             include_as_object=true)). (Backed by \
1029             subject-predicate-object triples distilled from past \
1030             conversations.) Clients should set a 30s timeout on this \
1031             call; if exceeded, retry once or fall back to \
1032             `memory_recall`.",
1033            json_schema_object(serde_json::json!({
1034                "type": "object",
1035                "properties": {
1036                    "subject": {
1037                        "type": "string",
1038                        "description": "Subject id to query (e.g. 'Sam').",
1039                    },
1040                    "predicate": {
1041                        "type": "string",
1042                        "description": "Optional predicate filter (e.g. 'works_at').",
1043                    },
1044                    "since_ms": {
1045                        "type": "integer",
1046                        "description": "Optional valid_from_ms lower bound (epoch ms).",
1047                    },
1048                    "until_ms": {
1049                        "type": "integer",
1050                        "description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through.",
1051                    },
1052                    "include_as_object": {
1053                        "type": "boolean",
1054                        "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.",
1055                        "default": false,
1056                    },
1057                    "limit": {
1058                        "type": "integer",
1059                        "description": "Maximum results (default 5).",
1060                        "minimum": 1,
1061                        "maximum": 100,
1062                    },
1063                },
1064                "required": ["subject"],
1065            })),
1066        ),
1067        Tool::new(
1068            "memory_contradictions",
1069            "Find places where the user's stated beliefs or facts \
1070             disagree across conversations — flag disagreements \
1071             before answering. Use whenever you're about to rely on \
1072             a remembered fact that could have changed (jobs, \
1073             relationships, preferences, opinions); a disagreement \
1074             here means the user has told you both X and not-X over \
1075             time and you should ask which is current instead of \
1076             guessing. Each result shows both conflicting statements \
1077             with the topic.",
1078            json_schema_object(serde_json::json!({
1079                "type": "object",
1080                "properties": {
1081                    "limit": {
1082                        "type": "integer",
1083                        "description": "Maximum results (default 5).",
1084                        "minimum": 1,
1085                        "maximum": 100,
1086                    },
1087                },
1088            })),
1089        ),
1090        Tool::new(
1091            "memory_inspect_cluster",
1092            "Show the raw conversations behind one summary. Returns \
1093             the one-line topic (the LLM-generated summary) and the \
1094             source conversations the topic was built from. Use \
1095             after memory_themes when the user asks \"show me the \
1096             raw context behind this\" or \"why does Solo think \
1097             that about cluster Y\". Source items are truncated to \
1098             200 chars unless `full_content` is set.",
1099            json_schema_object(serde_json::json!({
1100                "type": "object",
1101                "properties": {
1102                    "cluster_id": {
1103                        "type": "string",
1104                        "description": "Cluster id to inspect (from memory_themes hits).",
1105                    },
1106                    "full_content": {
1107                        "type": "boolean",
1108                        "description": "If true, episode content is returned verbatim. Default false (truncate to 200 chars + ellipsis).",
1109                    },
1110                },
1111                "required": ["cluster_id"],
1112            })),
1113        ),
1114        // Document tools (v0.7.0+). RAG over user-supplied files —
1115        // markdown notes, PDFs, runbooks, code, etc. Same vector space
1116        // as episodes; same embedder; same HNSW index.
1117        Tool::new(
1118            "memory_ingest_document",
1119            "Read a file from disk and add it to the user's document \
1120             library so it becomes searchable alongside past \
1121             conversations. Use when the user asks you to remember a \
1122             whole file (\"add my notes/runbook.md\", \"ingest this \
1123             PDF\"). The file is split into ~500-token chunks and \
1124             each chunk is embedded; chunks then surface through \
1125             memory_search_docs. Returns the new document id, chunk \
1126             count, and a `deduped` flag (true if the same content \
1127             was already ingested under another id).",
1128            json_schema_object(serde_json::json!({
1129                "type": "object",
1130                "properties": {
1131                    "path": {
1132                        "type": "string",
1133                        "description": "Server-side absolute path to the file to ingest. The file must be readable by the Solo process.",
1134                    },
1135                },
1136                "required": ["path"],
1137            })),
1138        ),
1139        Tool::new(
1140            "memory_search_docs",
1141            "Search across the user's ingested documents by topic or \
1142             phrase. Returns up to `limit` matching chunks, best \
1143             match first, each with the parent document's title + \
1144             source path so you can cite where the answer came from. \
1145             Use when the user asks a question that hinges on \
1146             material they've added as a file (\"what does my \
1147             runbook say about backups?\", \"find the section in the \
1148             notes about the new policy\"). Forgotten documents are \
1149             skipped.",
1150            json_schema_object(serde_json::json!({
1151                "type": "object",
1152                "properties": {
1153                    "query": {
1154                        "type": "string",
1155                        "description": "The query text.",
1156                    },
1157                    "limit": {
1158                        "type": "integer",
1159                        "description": "Maximum results (default 5).",
1160                        "minimum": 1,
1161                        "maximum": 100,
1162                    },
1163                },
1164                "required": ["query"],
1165            })),
1166        ),
1167        Tool::new(
1168            "memory_inspect_document",
1169            "Show one document's metadata plus a preview of every \
1170             chunk it was split into. Use after memory_search_docs \
1171             when the user wants the bigger picture for one hit \
1172             (\"show me the whole document this came from\"), or \
1173             after memory_list_documents to drill into one entry. \
1174             Each chunk preview is truncated to 200 chars.",
1175            json_schema_object(serde_json::json!({
1176                "type": "object",
1177                "properties": {
1178                    "doc_id": {
1179                        "type": "string",
1180                        "description": "Document id to inspect (UUID v7).",
1181                    },
1182                },
1183                "required": ["doc_id"],
1184            })),
1185        ),
1186        Tool::new(
1187            "memory_list_documents",
1188            "List the user's ingested documents, newest first. Use \
1189             when the user asks \"what documents have I added?\" or \
1190             \"show me my files\". Returns a paginated index — pass \
1191             `offset` to page further back. Forgotten documents are \
1192             hidden by default; set `include_forgotten=true` to see \
1193             them too.",
1194            json_schema_object(serde_json::json!({
1195                "type": "object",
1196                "properties": {
1197                    "limit": {
1198                        "type": "integer",
1199                        "description": "Maximum results per page (default 20).",
1200                        "minimum": 1,
1201                        "maximum": 100,
1202                    },
1203                    "offset": {
1204                        "type": "integer",
1205                        "description": "Number of rows to skip (for paging). Default 0.",
1206                        "minimum": 0,
1207                    },
1208                    "include_forgotten": {
1209                        "type": "boolean",
1210                        "description": "If true, also include documents the user has forgotten. Default false.",
1211                    },
1212                },
1213            })),
1214        ),
1215        Tool::new(
1216            "memory_forget_document",
1217            "Drop one document from the user's library by id. Use \
1218             when the user asks you to forget a specific file \
1219             (\"forget my old runbook\"). The document's chunks stop \
1220             appearing in memory_search_docs and the vectors are \
1221             tombstoned in the index. The chunk rows themselves are \
1222             kept for forensic value (a future restore command can \
1223             undo this).",
1224            json_schema_object(serde_json::json!({
1225                "type": "object",
1226                "properties": {
1227                    "doc_id": {
1228                        "type": "string",
1229                        "description": "Document id to forget (UUID v7).",
1230                    },
1231                },
1232                "required": ["doc_id"],
1233            })),
1234        ),
1235    ]
1236}
1237
1238fn json_schema_object(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
1239    match value {
1240        serde_json::Value::Object(map) => map,
1241        _ => panic!("json_schema_object: input must be an object"),
1242    }
1243}
1244
1245/// Names of every tool this server exposes, in registration order.
1246///
1247/// Exposed for cross-crate consumers (notably `solo doctor
1248/// --check-mcp-compat`) that want the name list without paying the
1249/// cost of building full `rmcp::Tool` records (which allocate JSON
1250/// schemas). The registration order matches `build_tools()` so any
1251/// drift between the two would be caught by the cross-provider regex
1252/// test which iterates `build_tools()`.
1253pub fn tool_names() -> Vec<&'static str> {
1254    vec![
1255        "memory_remember",
1256        // v0.9.2 — batched-remember for agentic clients (solo-jarvis).
1257        "memory_remember_batch",
1258        "memory_recall",
1259        "memory_forget",
1260        "memory_inspect",
1261        "memory_themes",
1262        "memory_facts_about",
1263        "memory_contradictions",
1264        "memory_inspect_cluster",
1265        // Document tools added in v0.7.0:
1266        "memory_ingest_document",
1267        "memory_search_docs",
1268        "memory_inspect_document",
1269        "memory_list_documents",
1270        "memory_forget_document",
1271    ]
1272}
1273
1274// ---------------------------------------------------------------------------
1275// Tool handlers
1276// ---------------------------------------------------------------------------
1277
1278impl SoloMcpServer {
1279    async fn handle_remember(
1280        &self,
1281        args: RememberArgs,
1282    ) -> std::result::Result<CallToolResult, McpError> {
1283        let content = args.content.trim_end().to_string();
1284        if content.is_empty() {
1285            return Err(McpError::invalid_params(
1286                "memory_remember: content must not be empty".to_string(),
1287                None,
1288            ));
1289        }
1290        validate_salience(args.salience)?;
1291        let embedding: solo_core::Embedding = self
1292            .inner
1293            .tenant
1294            .embedder()
1295            .embed(&content)
1296            .await
1297            .map_err(solo_to_mcp)?;
1298        let episode = Episode {
1299            memory_id: MemoryId::new(),
1300            ts_ms: chrono::Utc::now().timestamp_millis(),
1301            source_type: args.source_type.unwrap_or_else(|| "user_message".into()),
1302            source_id: args.source_id,
1303            content,
1304            encoding_context: EncodingContext::default(),
1305            provenance: None,
1306            confidence: Confidence::new(0.9).unwrap(),
1307            strength: 0.5,
1308            // v0.9.2: caller-supplied salience overrides the default. The
1309            // `validate_salience` call above has already rejected NaN /
1310            // out-of-range values.
1311            salience: args.salience.unwrap_or(0.5),
1312            tier: Tier::Hot,
1313        };
1314        let mid = self
1315            .inner
1316            .tenant
1317            .write()
1318            .remember_as(self.inner.audit_principal.clone(), episode, embedding)
1319            .await
1320            .map_err(solo_to_mcp)?;
1321        Ok(CallToolResult::success(vec![Content::text(format!(
1322            "remembered {mid}"
1323        ))]))
1324    }
1325
1326    /// v0.9.2 — handler for `memory_remember_batch`.
1327    ///
1328    /// Pipeline (mirrors `handle_remember` over N items):
1329    ///   1. Validate batch (non-empty, ≤ `MAX_REMEMBER_BATCH_SIZE`,
1330    ///      per-item content non-empty, per-item salience in [0.0, 1.0]).
1331    ///   2. Embed all items sequentially via the tenant's embedder.
1332    ///      We don't `join_all` here because the in-process embedder
1333    ///      paths today (stub, local-Anthropic, OpenAI) are individually
1334    ///      fast and serial is robust against rate-limit surprises (per
1335    ///      dev-log 0120 §8 R2 mitigation: existing embedder
1336    ///      throttling guards parallel fan-out; serial gives identical
1337    ///      semantics with simpler error paths). Parallel fan-out is a
1338    ///      v0.9.3 optimization once the batch tool has live traffic.
1339    ///   3. Build `Vec<(Episode, Embedding)>` with default Confidence /
1340    ///      strength / tier — same shape as single-Remember.
1341    ///   4. Dispatch via `WriteHandle::remember_batch_as`, which wraps
1342    ///      every INSERT in ONE `BEGIN IMMEDIATE` tx (ADR-0003 invariant
1343    ///      preserved).
1344    ///   5. Reply is `Vec<MemoryId>` in input order; serialise to JSON.
1345    async fn handle_remember_batch(
1346        &self,
1347        args: RememberBatchArgs,
1348    ) -> std::result::Result<CallToolResult, McpError> {
1349        // 1. Batch-shape validation. The writer-actor will re-check
1350        //    `MAX_REMEMBER_BATCH_SIZE` (dev-log 0120 §3 Decision F) and
1351        //    reject with `InvalidInput` — we mirror the check here to
1352        //    avoid the round-trip into the writer + the embedder calls
1353        //    when the request is obviously over-cap.
1354        if args.items.is_empty() {
1355            return Err(McpError::invalid_params(
1356                "memory_remember_batch: items must not be empty".to_string(),
1357                None,
1358            ));
1359        }
1360        if args.items.len() > solo_storage::MAX_REMEMBER_BATCH_SIZE {
1361            return Err(McpError::invalid_params(
1362                format!(
1363                    "memory_remember_batch: {} items exceeds MAX_REMEMBER_BATCH_SIZE = {}",
1364                    args.items.len(),
1365                    solo_storage::MAX_REMEMBER_BATCH_SIZE,
1366                ),
1367                None,
1368            ));
1369        }
1370        for (i, item) in args.items.iter().enumerate() {
1371            if item.content.trim_end().is_empty() {
1372                return Err(McpError::invalid_params(
1373                    format!("memory_remember_batch: items[{i}].content must not be empty"),
1374                    None,
1375                ));
1376            }
1377            validate_salience(item.salience).map_err(|e| {
1378                // Re-wrap with the index so the caller can pinpoint
1379                // which item tripped the validator.
1380                McpError::invalid_params(
1381                    format!("memory_remember_batch: items[{i}].{}", e.message),
1382                    None,
1383                )
1384            })?;
1385        }
1386
1387        // 2. Embed each item. Serial fan-out (see doc comment above).
1388        let embedder = self.inner.tenant.embedder();
1389        let now_ms = chrono::Utc::now().timestamp_millis();
1390        let mut pairs: Vec<(Episode, solo_core::Embedding)> = Vec::with_capacity(args.items.len());
1391        for item in args.items.into_iter() {
1392            let content = item.content.trim_end().to_string();
1393            let embedding = embedder.embed(&content).await.map_err(solo_to_mcp)?;
1394            let episode = Episode {
1395                memory_id: MemoryId::new(),
1396                ts_ms: now_ms,
1397                source_type: item.source_type.unwrap_or_else(|| "user_message".into()),
1398                source_id: item.source_id,
1399                content,
1400                encoding_context: EncodingContext::default(),
1401                provenance: None,
1402                confidence: Confidence::new(0.9).unwrap(),
1403                strength: 0.5,
1404                salience: item.salience.unwrap_or(0.5),
1405                tier: Tier::Hot,
1406            };
1407            pairs.push((episode, embedding));
1408        }
1409
1410        // 3. Dispatch into the writer-actor. The batch lands as one tx.
1411        let memory_ids = self
1412            .inner
1413            .tenant
1414            .write()
1415            .remember_batch_as(self.inner.audit_principal.clone(), pairs)
1416            .await
1417            .map_err(solo_to_mcp)?;
1418
1419        // 4. Reply: JSON-serialised array of memory ids in input order.
1420        //    Stringified so MCP clients see UUID strings (matches single
1421        //    `memory_remember`'s reply shape — both speak strings on
1422        //    the wire).
1423        let ids_as_strings: Vec<String> =
1424            memory_ids.iter().map(|m| m.to_string()).collect();
1425        let body = serde_json::to_string(&ids_as_strings).map_err(|e| {
1426            McpError::internal_error(format!("serialize batch reply: {e}"), None)
1427        })?;
1428        Ok(CallToolResult::success(vec![Content::text(body)]))
1429    }
1430
1431    async fn handle_recall(
1432        &self,
1433        args: RecallArgs,
1434    ) -> std::result::Result<CallToolResult, McpError> {
1435        // Pipeline lives in solo-query; the transport just formats the
1436        // result. solo_query::run_recall validates empty queries
1437        // (returns InvalidInput → invalid_params via solo_to_mcp).
1438        let result = solo_query::run_recall(
1439            self.inner.tenant.as_ref(),
1440            self.inner.audit_principal.clone(),
1441            &args.query,
1442            args.limit,
1443        )
1444        .await
1445        .map_err(solo_to_mcp)?;
1446
1447        if result.hits.is_empty() {
1448            return Ok(CallToolResult::success(vec![Content::text(format!(
1449                "no matches (index has {} vectors)",
1450                result.index_len
1451            ))]));
1452        }
1453        let body = serde_json::to_string_pretty(&result.hits).unwrap_or_else(|_| String::new());
1454        Ok(CallToolResult::success(vec![Content::text(body)]))
1455    }
1456
1457    async fn handle_forget(
1458        &self,
1459        args: ForgetArgs,
1460    ) -> std::result::Result<CallToolResult, McpError> {
1461        let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
1462            McpError::invalid_params(format!("invalid memory_id: {e}"), None)
1463        })?;
1464        self.inner
1465            .tenant
1466            .write()
1467            .forget_as(self.inner.audit_principal.clone(), mid, args.reason)
1468            .await
1469            .map_err(solo_to_mcp)?;
1470        Ok(CallToolResult::success(vec![Content::text(format!(
1471            "forgotten {mid}"
1472        ))]))
1473    }
1474
1475    async fn handle_inspect(
1476        &self,
1477        args: InspectArgs,
1478    ) -> std::result::Result<CallToolResult, McpError> {
1479        let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
1480            McpError::invalid_params(format!("invalid memory_id: {e}"), None)
1481        })?;
1482        // Pipeline lives in solo-query::inspect; transports just format.
1483        let row = solo_query::inspect_one(
1484            self.inner.tenant.read(),
1485            self.inner.tenant.audit(),
1486            self.inner.audit_principal.clone(),
1487            mid,
1488        )
1489        .await
1490        .map_err(solo_to_mcp)?;
1491        let body = serde_json::to_string_pretty(&row).unwrap_or_else(|_| String::new());
1492        Ok(CallToolResult::success(vec![Content::text(body)]))
1493    }
1494
1495    // Path 1 derived-layer handlers (v0.4.0+). Each one delegates to a
1496    // single solo-query::derived pipeline and serialises the result Vec
1497    // to pretty JSON for the MCP wire. Empty result → JSON empty array
1498    // `[]` (not a special-case "no matches" string) so MCP clients can
1499    // parse uniformly.
1500
1501    async fn handle_themes(
1502        &self,
1503        args: ThemesArgs,
1504    ) -> std::result::Result<CallToolResult, McpError> {
1505        let hits = solo_query::themes(
1506            self.inner.tenant.read(),
1507            self.inner.tenant.audit(),
1508            self.inner.audit_principal.clone(),
1509            args.window_days,
1510            args.limit,
1511        )
1512        .await
1513        .map_err(solo_to_mcp)?;
1514        let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1515        Ok(CallToolResult::success(vec![Content::text(body)]))
1516    }
1517
1518    async fn handle_facts_about(
1519        &self,
1520        args: FactsAboutArgs,
1521    ) -> std::result::Result<CallToolResult, McpError> {
1522        if args.subject.trim().is_empty() {
1523            return Err(McpError::invalid_params(
1524                "memory_facts_about: subject must not be empty".to_string(),
1525                None,
1526            ));
1527        }
1528        let hits = solo_query::facts_about(
1529            self.inner.tenant.read(),
1530            self.inner.tenant.audit(),
1531            self.inner.audit_principal.clone(),
1532            &args.subject,
1533            &self.inner.user_aliases,
1534            args.include_as_object,
1535            args.predicate.as_deref(),
1536            args.since_ms,
1537            args.until_ms,
1538            args.limit,
1539        )
1540        .await
1541        .map_err(solo_to_mcp)?;
1542        let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1543        Ok(CallToolResult::success(vec![Content::text(body)]))
1544    }
1545
1546    async fn handle_contradictions(
1547        &self,
1548        args: ContradictionsArgs,
1549    ) -> std::result::Result<CallToolResult, McpError> {
1550        let hits = solo_query::contradictions(
1551            self.inner.tenant.read(),
1552            self.inner.tenant.audit(),
1553            self.inner.audit_principal.clone(),
1554            args.limit,
1555        )
1556        .await
1557        .map_err(solo_to_mcp)?;
1558        let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1559        Ok(CallToolResult::success(vec![Content::text(body)]))
1560    }
1561
1562    async fn handle_inspect_cluster(
1563        &self,
1564        args: InspectClusterArgs,
1565    ) -> std::result::Result<CallToolResult, McpError> {
1566        if args.cluster_id.trim().is_empty() {
1567            return Err(McpError::invalid_params(
1568                "memory_inspect_cluster: cluster_id must not be empty".to_string(),
1569                None,
1570            ));
1571        }
1572        // `solo_to_mcp` maps `Error::NotFound` → `invalid_params` for
1573        // MCP (the protocol does not have a separate "not found" error
1574        // shape; clients see the message verbatim, which includes the
1575        // cluster_id).
1576        let record = solo_query::inspect_cluster(
1577            self.inner.tenant.read(),
1578            self.inner.tenant.audit(),
1579            self.inner.audit_principal.clone(),
1580            &args.cluster_id,
1581            args.full_content,
1582        )
1583        .await
1584        .map_err(solo_to_mcp)?;
1585        let body = serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
1586        Ok(CallToolResult::success(vec![Content::text(body)]))
1587    }
1588
1589    // Document handlers (v0.7.0+). Each wraps the corresponding writer
1590    // / query API; the MCP wire shape is plain JSON serialisation of
1591    // the returned report / records.
1592
1593    async fn handle_ingest_document(
1594        &self,
1595        args: IngestDocumentArgs,
1596    ) -> std::result::Result<CallToolResult, McpError> {
1597        if args.path.trim().is_empty() {
1598            return Err(McpError::invalid_params(
1599                "memory_ingest_document: path must not be empty".to_string(),
1600                None,
1601            ));
1602        }
1603        let path = std::path::PathBuf::from(args.path);
1604        // Defaults match what the daemon uses today (target 500 tokens,
1605        // 50-token overlap). Future: thread a per-call override through
1606        // the args struct if a use case appears.
1607        let chunk_config = solo_storage::document::ChunkConfig::default();
1608        let report = self
1609            .inner
1610            .tenant
1611            .write()
1612            .ingest_document_as(self.inner.audit_principal.clone(), path, chunk_config)
1613            .await
1614            .map_err(solo_to_mcp)?;
1615        let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| String::new());
1616        Ok(CallToolResult::success(vec![Content::text(body)]))
1617    }
1618
1619    async fn handle_search_docs(
1620        &self,
1621        args: SearchDocsArgs,
1622    ) -> std::result::Result<CallToolResult, McpError> {
1623        // `solo_query::run_doc_search` validates empty queries (returns
1624        // InvalidInput → invalid_params via solo_to_mcp) and clamps
1625        // limit upstream of the embedder call.
1626        let hits = solo_query::run_doc_search(
1627            self.inner.tenant.as_ref(),
1628            self.inner.audit_principal.clone(),
1629            &args.query,
1630            args.limit,
1631        )
1632        .await
1633        .map_err(solo_to_mcp)?;
1634        let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1635        Ok(CallToolResult::success(vec![Content::text(body)]))
1636    }
1637
1638    async fn handle_inspect_document(
1639        &self,
1640        args: InspectDocumentArgs,
1641    ) -> std::result::Result<CallToolResult, McpError> {
1642        let doc_id = DocumentId::from_str(&args.doc_id).map_err(|e| {
1643            McpError::invalid_params(format!("invalid doc_id: {e}"), None)
1644        })?;
1645        let result_opt = solo_query::inspect_document(
1646            self.inner.tenant.read(),
1647            self.inner.tenant.audit(),
1648            self.inner.audit_principal.clone(),
1649            &doc_id,
1650        )
1651        .await
1652        .map_err(solo_to_mcp)?;
1653        match result_opt {
1654            Some(record) => {
1655                let body =
1656                    serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
1657                Ok(CallToolResult::success(vec![Content::text(body)]))
1658            }
1659            None => Err(McpError::invalid_params(
1660                format!("document {doc_id} not found"),
1661                None,
1662            )),
1663        }
1664    }
1665
1666    async fn handle_list_documents(
1667        &self,
1668        args: ListDocumentsArgs,
1669    ) -> std::result::Result<CallToolResult, McpError> {
1670        let rows = solo_query::list_documents(
1671            self.inner.tenant.read(),
1672            self.inner.tenant.audit(),
1673            self.inner.audit_principal.clone(),
1674            args.limit,
1675            args.offset,
1676            args.include_forgotten,
1677        )
1678        .await
1679        .map_err(solo_to_mcp)?;
1680        let body = serde_json::to_string_pretty(&rows).unwrap_or_else(|_| String::new());
1681        Ok(CallToolResult::success(vec![Content::text(body)]))
1682    }
1683
1684    async fn handle_forget_document(
1685        &self,
1686        args: ForgetDocumentArgs,
1687    ) -> std::result::Result<CallToolResult, McpError> {
1688        let doc_id = DocumentId::from_str(&args.doc_id).map_err(|e| {
1689            McpError::invalid_params(format!("invalid doc_id: {e}"), None)
1690        })?;
1691        let report = self
1692            .inner
1693            .tenant
1694            .write()
1695            .forget_document_as(self.inner.audit_principal.clone(), doc_id)
1696            .await
1697            .map_err(solo_to_mcp)?;
1698        let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| String::new());
1699        Ok(CallToolResult::success(vec![Content::text(body)]))
1700    }
1701}
1702
1703#[cfg(test)]
1704mod dispatch_tests {
1705    //! In-process integration tests for the MCP tool surface. We invoke
1706    //! `SoloMcpServer::dispatch_tool` directly (bypasses the rmcp
1707    //! protocol framing + `RequestContext`, which requires a `Peer`
1708    //! that's not constructible outside rmcp internals). The server is
1709    //! constructed against a real WriterActor + ReaderPool +
1710    //! StubEmbedder + StubVectorIndex from `solo_storage::test_support`.
1711    //!
1712    //! Tests live inline in this module rather than `tests/` because an
1713    //! external integration-test exe in `target/debug/deps/mcp_dispatch-*`
1714    //! tripped Windows UAC ERROR_ELEVATION_REQUIRED on the dev machine.
1715    //! The lib test binary doesn't have that issue.
1716    use super::*;
1717    use serde_json::json;
1718    use solo_core::VectorIndex;
1719    use solo_storage::test_support::StubVectorIndex;
1720    use solo_storage::{
1721        EmbedderConfig, IdentityConfig, KeyMaterial, ReaderPool, SoloConfig,
1722        StubEmbedder, TenantHandle, TenantRegistry, WriterActor, WriterSpawn,
1723    };
1724    use std::sync::Arc as StdArc;
1725
1726    fn fake_config(dim: u32) -> SoloConfig {
1727        SoloConfig {
1728            schema_version: 1,
1729            salt_hex: "00000000000000000000000000000000".to_string(),
1730            embedder: EmbedderConfig {
1731                name: "stub".to_string(),
1732                version: "v1".to_string(),
1733                dim,
1734                dtype: "f32".to_string(),
1735            },
1736            identity: IdentityConfig::default(),
1737            documents: solo_storage::DocumentConfig::default(),
1738            auth: None,
1739            audit: solo_storage::AuditSettings::default(),
1740            redaction: solo_storage::RedactionConfig::default(),
1741            llm: None,
1742            triples: solo_storage::TriplesConfig::default(),
1743            sampling: solo_storage::SamplingConfig::default(),
1744        }
1745    }
1746
1747    struct Harness {
1748        server: SoloMcpServer,
1749        _tmp: tempfile::TempDir,
1750        write_handle_extra: Option<solo_storage::WriteHandle>,
1751        join: Option<std::thread::JoinHandle<()>>,
1752    }
1753
1754    impl Harness {
1755        fn new(runtime: &tokio::runtime::Runtime) -> Self {
1756            let tmp = tempfile::TempDir::new().unwrap();
1757            let dim = 16usize;
1758            let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1759            let embedder: StdArc<dyn solo_core::Embedder> = StdArc::new(StubEmbedder::new("stub", "v1", dim));
1760
1761            let conn = solo_storage::test_support::open_test_db_at(&tmp.path().join("test.db"));
1762            let WriterSpawn { handle, join } = WriterActor::spawn(conn, hnsw.clone());
1763
1764            // ReaderPool's deadpool::Pool needs a live tokio runtime for
1765            // both build + drop; build inside block_on.
1766            let path = tmp.path().join("test.db");
1767            let pool: ReaderPool =
1768                runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1769
1770            let tenant_id = solo_core::TenantId::default_tenant();
1771            let tenant_handle = StdArc::new(
1772                TenantHandle::from_parts_for_tests(
1773                    tenant_id.clone(),
1774                    fake_config(dim as u32),
1775                    path.clone(),
1776                    tmp.path().to_path_buf(),
1777                    0, // embedder_id; tests using full embedder_id path build their own
1778                    hnsw,
1779                    embedder.clone(),
1780                    handle.clone(),
1781                    std::thread::spawn(|| {}),
1782                    pool,
1783                ),
1784            );
1785            let key = KeyMaterial::from_bytes_for_tests([0u8; 32]);
1786            let registry = StdArc::new(TenantRegistry::for_tests_with_single_tenant(
1787                tmp.path().to_path_buf(),
1788                key,
1789                embedder,
1790                tenant_handle.clone(),
1791            ));
1792            let server = SoloMcpServer::new_for_tenant(registry, tenant_handle, Vec::new());
1793            Harness {
1794                server,
1795                _tmp: tmp,
1796                write_handle_extra: Some(handle),
1797                join: Some(join),
1798            }
1799        }
1800
1801        fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1802            // The whole shutdown runs inside block_on so deadpool-sqlite's
1803            // drop (which schedules cleanup on the active runtime) sees a
1804            // live reactor. Without this, dropping the SoloMcpServer
1805            // (which holds the ReaderPool through its Arc<Inner>) panics
1806            // with "no reactor running".
1807            let join = self.join.take();
1808            let extra = self.write_handle_extra.take();
1809            runtime.block_on(async move {
1810                drop(extra);
1811                drop(self.server);
1812                drop(self._tmp);
1813                if let Some(join) = join {
1814                    let (tx, rx) = std::sync::mpsc::channel();
1815                    std::thread::spawn(move || {
1816                        let _ = tx.send(join.join());
1817                    });
1818                    tokio::task::spawn_blocking(move || {
1819                        rx.recv_timeout(std::time::Duration::from_secs(5))
1820                    })
1821                    .await
1822                    .expect("blocking task")
1823                    .expect("writer thread did not exit within 5s")
1824                    .expect("writer thread panicked");
1825                }
1826            });
1827        }
1828    }
1829
1830    fn rt() -> tokio::runtime::Runtime {
1831        tokio::runtime::Builder::new_multi_thread()
1832            .worker_threads(2)
1833            .enable_all()
1834            .build()
1835            .unwrap()
1836    }
1837
1838    /// Pull the first Content::text body out of a CallToolResult. Use
1839    /// serde_json roundtrip as a robust extractor — `Content`'s public
1840    /// API doesn't directly expose the inner text without going through
1841    /// pattern-matching on RawContent.
1842    fn first_text(r: &rmcp::model::CallToolResult) -> String {
1843        let first = r.content.first().expect("at least one content item");
1844        let v = serde_json::to_value(first).expect("content serialises");
1845        v.get("text")
1846            .and_then(|t| t.as_str())
1847            .map(|s| s.to_string())
1848            .unwrap_or_else(|| format!("{v}"))
1849    }
1850
1851    #[test]
1852    fn tools_list_returns_fourteen_canonical_tools() {
1853        let runtime = rt();
1854        let h = Harness::new(&runtime);
1855        let tools = h.server.dispatch_list_tools();
1856        let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1857        assert_eq!(
1858            names,
1859            vec![
1860                "memory_remember",
1861                // v0.9.2 — batched-remember for agentic clients.
1862                "memory_remember_batch",
1863                "memory_recall",
1864                "memory_forget",
1865                "memory_inspect",
1866                // Derived-layer tools added in v0.4.0:
1867                "memory_themes",
1868                "memory_facts_about",
1869                "memory_contradictions",
1870                // Added in v0.5.0 (Priority 3):
1871                "memory_inspect_cluster",
1872                // Document tools added in v0.7.0:
1873                "memory_ingest_document",
1874                "memory_search_docs",
1875                "memory_inspect_document",
1876                "memory_list_documents",
1877                "memory_forget_document",
1878            ]
1879        );
1880        for t in &tools {
1881            // rmcp 1.x: Tool.description is Option<Cow<'static, str>>.
1882            let desc = t.description.as_deref().unwrap_or("");
1883            assert!(!desc.is_empty(), "{} description empty", t.name);
1884            let _schema = t.schema_as_json_value();
1885            // `required` is intentionally absent on memory_themes +
1886            // memory_contradictions + memory_list_documents (all args
1887            // optional with defaults). memory_facts_about has required
1888            // = ["subject"], etc. We don't assert per-tool 'required'
1889            // shape here; the schema's `properties` field is the more
1890            // important signal and is always present.
1891        }
1892        h.shutdown(&runtime);
1893    }
1894
1895    #[test]
1896    fn themes_returns_json_array_on_empty_db() {
1897        let runtime = rt();
1898        let h = Harness::new(&runtime);
1899        runtime.block_on(async {
1900            let r = h
1901                .server
1902                .dispatch_tool("memory_themes", json!({}))
1903                .await
1904                .expect("themes succeeds");
1905            let text = first_text(&r);
1906            // Empty derived layer → empty array JSON. Parses cleanly.
1907            let v: serde_json::Value =
1908                serde_json::from_str(&text).expect("parses as json");
1909            assert!(v.is_array(), "expected array, got: {text}");
1910            assert_eq!(v.as_array().unwrap().len(), 0);
1911        });
1912        h.shutdown(&runtime);
1913    }
1914
1915    #[test]
1916    fn themes_passes_through_window_and_limit_args() {
1917        let runtime = rt();
1918        let h = Harness::new(&runtime);
1919        runtime.block_on(async {
1920            // Should not crash with optional + integer args present.
1921            let r = h
1922                .server
1923                .dispatch_tool(
1924                    "memory_themes",
1925                    json!({ "window_days": 7, "limit": 20 }),
1926                )
1927                .await
1928                .expect("themes with args succeeds");
1929            let text = first_text(&r);
1930            let v: serde_json::Value =
1931                serde_json::from_str(&text).expect("parses as json");
1932            assert!(v.is_array());
1933        });
1934        h.shutdown(&runtime);
1935    }
1936
1937    #[test]
1938    fn facts_about_rejects_empty_subject() {
1939        let runtime = rt();
1940        let h = Harness::new(&runtime);
1941        runtime.block_on(async {
1942            let err = h
1943                .server
1944                .dispatch_tool(
1945                    "memory_facts_about",
1946                    json!({ "subject": "   " }),
1947                )
1948                .await
1949                .expect_err("empty subject must error");
1950            // McpError doesn't expose a clean kind/message accessor; just
1951            // verify the error fires (validation path reached).
1952            let s = format!("{err:?}");
1953            assert!(
1954                s.to_lowercase().contains("subject")
1955                    || s.to_lowercase().contains("invalid"),
1956                "got: {s}"
1957            );
1958        });
1959        h.shutdown(&runtime);
1960    }
1961
1962    #[test]
1963    fn facts_about_returns_array_for_unknown_subject() {
1964        let runtime = rt();
1965        let h = Harness::new(&runtime);
1966        runtime.block_on(async {
1967            let r = h
1968                .server
1969                .dispatch_tool(
1970                    "memory_facts_about",
1971                    json!({ "subject": "NobodyKnowsThisSubject" }),
1972                )
1973                .await
1974                .expect("facts_about with unknown subject succeeds");
1975            let text = first_text(&r);
1976            let v: serde_json::Value =
1977                serde_json::from_str(&text).expect("parses as json");
1978            assert_eq!(v.as_array().unwrap().len(), 0);
1979        });
1980        h.shutdown(&runtime);
1981    }
1982
1983    #[test]
1984    fn facts_about_accepts_include_as_object_arg() {
1985        // Asserts the v0.5.1 P8 arg is parsed (serde default lets it
1986        // be omitted) and forwarded to the query lib without choking
1987        // the dispatcher. We don't seed triples — what we need to
1988        // verify is that the optional bool flows through. Both with
1989        // and without the arg, dispatch succeeds and returns an
1990        // empty array. (Functional coverage of the object-position
1991        // widening lives in the query-crate tests.)
1992        let runtime = rt();
1993        let h = Harness::new(&runtime);
1994        runtime.block_on(async {
1995            // With include_as_object=true.
1996            let r = h
1997                .server
1998                .dispatch_tool(
1999                    "memory_facts_about",
2000                    json!({ "subject": "Maya", "include_as_object": true }),
2001                )
2002                .await
2003                .expect("dispatch with include_as_object=true succeeds");
2004            let v: serde_json::Value = serde_json::from_str(&first_text(&r))
2005                .expect("parses as json");
2006            assert_eq!(v.as_array().unwrap().len(), 0);
2007
2008            // Omitted entirely — must default to false (no error).
2009            let r = h
2010                .server
2011                .dispatch_tool(
2012                    "memory_facts_about",
2013                    json!({ "subject": "Maya" }),
2014                )
2015                .await
2016                .expect("dispatch without include_as_object succeeds (default false)");
2017            let v: serde_json::Value = serde_json::from_str(&first_text(&r))
2018                .expect("parses as json");
2019            assert_eq!(v.as_array().unwrap().len(), 0);
2020        });
2021        h.shutdown(&runtime);
2022    }
2023
2024    #[test]
2025    fn contradictions_returns_json_array_on_empty_db() {
2026        let runtime = rt();
2027        let h = Harness::new(&runtime);
2028        runtime.block_on(async {
2029            let r = h
2030                .server
2031                .dispatch_tool("memory_contradictions", json!({}))
2032                .await
2033                .expect("contradictions succeeds");
2034            let text = first_text(&r);
2035            let v: serde_json::Value =
2036                serde_json::from_str(&text).expect("parses as json");
2037            assert!(v.is_array());
2038            assert_eq!(v.as_array().unwrap().len(), 0);
2039        });
2040        h.shutdown(&runtime);
2041    }
2042
2043    #[test]
2044    fn remember_then_recall_round_trip() {
2045        let runtime = rt();
2046        let h = Harness::new(&runtime);
2047        // Use &h.server directly (no clone) so the only outstanding
2048        // reference at shutdown time is the harness's own. The clone
2049        // path triggered a 5-second writer-thread timeout because the
2050        // local clone held an Arc<Inner> with its own WriteHandle past
2051        // h.shutdown().
2052        runtime.block_on(async {
2053            let r = h
2054                .server
2055                .dispatch_tool("memory_remember", json!({ "content": "the cat sat on the mat" }))
2056                .await
2057                .expect("remember succeeds");
2058            let text = first_text(&r);
2059            assert!(text.starts_with("remembered "), "got: {text}");
2060
2061            let r = h
2062                .server
2063                .dispatch_tool(
2064                    "memory_recall",
2065                    json!({ "query": "the cat sat on the mat", "limit": 5 }),
2066                )
2067                .await
2068                .expect("recall succeeds");
2069            let text = first_text(&r);
2070            assert!(text.contains("the cat sat on the mat"), "got: {text}");
2071        });
2072        h.shutdown(&runtime);
2073    }
2074
2075    #[test]
2076    fn forget_excludes_row_from_subsequent_recall() {
2077        let runtime = rt();
2078        let h = Harness::new(&runtime);
2079
2080        runtime.block_on(async {
2081            let r = h
2082                .server
2083                .dispatch_tool("memory_remember", json!({ "content": "to be forgotten" }))
2084                .await
2085                .unwrap();
2086            let text = first_text(&r);
2087            let mid = text.strip_prefix("remembered ").unwrap().to_string();
2088
2089            h.server
2090                .dispatch_tool(
2091                    "memory_forget",
2092                    json!({ "memory_id": mid, "reason": "test" }),
2093                )
2094                .await
2095                .expect("forget succeeds");
2096
2097            let r = h
2098                .server
2099                .dispatch_tool(
2100                    "memory_recall",
2101                    json!({ "query": "to be forgotten", "limit": 5 }),
2102                )
2103                .await
2104                .unwrap();
2105            let text = first_text(&r);
2106            assert!(
2107                !text.contains(r#""content": "to be forgotten""#),
2108                "forgotten row should be excluded; got: {text}"
2109            );
2110        });
2111        h.shutdown(&runtime);
2112    }
2113
2114    #[test]
2115    fn empty_remember_returns_invalid_params() {
2116        let runtime = rt();
2117        let h = Harness::new(&runtime);
2118        runtime.block_on(async {
2119            let err = h
2120                .server
2121                .dispatch_tool("memory_remember", json!({ "content": "" }))
2122                .await
2123                .unwrap_err();
2124            assert!(format!("{err:?}").contains("must not be empty"));
2125        });
2126        h.shutdown(&runtime);
2127    }
2128
2129    #[test]
2130    fn empty_recall_query_returns_invalid_params() {
2131        let runtime = rt();
2132        let h = Harness::new(&runtime);
2133        runtime.block_on(async {
2134            let err = h
2135                .server
2136                .dispatch_tool("memory_recall", json!({ "query": "   " }))
2137                .await
2138                .unwrap_err();
2139            assert!(format!("{err:?}").contains("must not be empty"));
2140        });
2141        h.shutdown(&runtime);
2142    }
2143
2144    #[test]
2145    fn inspect_with_invalid_id_returns_invalid_params() {
2146        let runtime = rt();
2147        let h = Harness::new(&runtime);
2148        runtime.block_on(async {
2149            let err = h
2150                .server
2151                .dispatch_tool("memory_inspect", json!({ "memory_id": "not-a-uuid" }))
2152                .await
2153                .unwrap_err();
2154            assert!(format!("{err:?}").contains("invalid memory_id"));
2155        });
2156        h.shutdown(&runtime);
2157    }
2158
2159    #[test]
2160    fn forget_unknown_id_returns_invalid_params() {
2161        let runtime = rt();
2162        let h = Harness::new(&runtime);
2163        runtime.block_on(async {
2164            // Valid UUID format but not in episodes — handle_forget
2165            // surfaces NotFound, mapped to invalid_params per
2166            // solo_to_mcp.
2167            let err = h
2168                .server
2169                .dispatch_tool(
2170                    "memory_forget",
2171                    json!({ "memory_id": "00000000-0000-7000-8000-000000000000" }),
2172                )
2173                .await
2174                .unwrap_err();
2175            assert!(format!("{err:?}").contains("not found"));
2176        });
2177        h.shutdown(&runtime);
2178    }
2179
2180    #[test]
2181    fn unknown_tool_name_returns_invalid_params() {
2182        let runtime = rt();
2183        let h = Harness::new(&runtime);
2184        runtime.block_on(async {
2185            let err = h
2186                .server
2187                .dispatch_tool("memory.summon", json!({}))
2188                .await
2189                .unwrap_err();
2190            assert!(format!("{err:?}").contains("unknown tool"));
2191        });
2192        h.shutdown(&runtime);
2193    }
2194
2195    /// Regression guard for v0.4.1's MCP tool name fix, generalised
2196    /// in v0.5.0 Priority 4 to cover **all three** major LLM
2197    /// providers, not just Anthropic.
2198    ///
2199    /// Each provider enforces its own tool-name regex on the
2200    /// function-calling wire. A tool name has to satisfy ALL of them
2201    /// to be portable across clients:
2202    ///
2203    ///   - **Anthropic**: `^[a-zA-Z0-9_-]{1,64}$` (what shipped in
2204    ///     v0.4.1; failing this rejects the entire toolset on Claude
2205    ///     Desktop / Cursor / Claude Code with
2206    ///     `FrontendRemoteMcpToolDefinition.name: String should
2207    ///     match pattern ...`).
2208    ///   - **OpenAI** function-calling: `^[a-zA-Z_][a-zA-Z0-9_-]*$`
2209    ///     with length ≤ 64 (must start with letter or underscore).
2210    ///   - **Gemini** function-calling: documented as a-z, A-Z, 0-9,
2211    ///     underscores and dashes; some sources also allow dots. We
2212    ///     use the conservative intersection — must start with
2213    ///     letter or underscore, alphanumeric + underscore only (no
2214    ///     hyphen, no dot), length ≤ 63. This is the strictest of
2215    ///     the three patterns, so any tool that passes it also
2216    ///     passes the other two. Sources differ on whether Gemini
2217    ///     accepts dots or hyphens; the strictest reading guards us
2218    ///     against the future where one provider tightens the regex
2219    ///     (which is the failure mode v0.4.1 hit on Anthropic). See
2220    ///     <https://github.com/google-gemini/deprecated-generative-ai-python/blob/main/docs/api/google/generativeai/protos/FunctionDeclaration.md>
2221    ///     and <https://ai.google.dev/gemini-api/docs/function-calling>.
2222    ///
2223    /// Lesson banked v0.3 #8: rmcp framing tests pass dot-named
2224    /// tools fine because rmcp's own client-side validation is
2225    /// permissive. Only the downstream provider API enforces the
2226    /// regex. This test gates the names at `cargo test` time so any
2227    /// future tool-name change has to pass all three provider
2228    /// regexes before reaching real clients.
2229    #[test]
2230    fn tool_names_match_cross_provider_regex() {
2231        /// Anthropic API name regex: `^[a-zA-Z0-9_-]{1,64}$`.
2232        fn passes_anthropic(name: &str) -> bool {
2233            let len = name.len();
2234            if !(1..=64).contains(&len) {
2235                return false;
2236            }
2237            name.chars()
2238                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
2239        }
2240
2241        /// OpenAI function-calling name regex:
2242        /// `^[a-zA-Z_][a-zA-Z0-9_-]*$`, length ≤ 64.
2243        fn passes_openai(name: &str) -> bool {
2244            let len = name.len();
2245            if !(1..=64).contains(&len) {
2246                return false;
2247            }
2248            let mut chars = name.chars();
2249            let first = match chars.next() {
2250                Some(c) => c,
2251                None => return false,
2252            };
2253            if !(first.is_ascii_alphabetic() || first == '_') {
2254                return false;
2255            }
2256            chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
2257        }
2258
2259        /// Gemini function-calling name regex (conservative
2260        /// reading): `^[a-zA-Z_][a-zA-Z0-9_]*$`, length ≤ 63. No
2261        /// hyphen, no dot — strictest of the three so any name that
2262        /// passes this passes the other two.
2263        fn passes_gemini(name: &str) -> bool {
2264            let len = name.len();
2265            if !(1..=63).contains(&len) {
2266                return false;
2267            }
2268            let mut chars = name.chars();
2269            let first = match chars.next() {
2270                Some(c) => c,
2271                None => return false,
2272            };
2273            if !(first.is_ascii_alphabetic() || first == '_') {
2274                return false;
2275            }
2276            chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
2277        }
2278
2279        let tools = build_tools();
2280        assert_eq!(
2281            tools.len(),
2282            14,
2283            "expected 14 tools in v0.9.2 (8 v0.5.x + 5 document tools + remember_batch)"
2284        );
2285        // Sanity-check that tool_names() agrees with build_tools().
2286        let tool_name_strings: Vec<String> =
2287            tools.iter().map(|t| t.name.to_string()).collect();
2288        let public_names: Vec<String> =
2289            super::tool_names().iter().map(|s| s.to_string()).collect();
2290        assert_eq!(
2291            tool_name_strings, public_names,
2292            "tool_names() drifted from build_tools() — keep them in sync"
2293        );
2294
2295        for t in tools {
2296            assert!(
2297                passes_anthropic(&t.name),
2298                "tool name {:?} fails Anthropic regex \
2299                 ^[a-zA-Z0-9_-]{{1,64}}$ — see v0.3 lesson #8",
2300                t.name
2301            );
2302            assert!(
2303                passes_openai(&t.name),
2304                "tool name {:?} fails OpenAI function-calling regex \
2305                 ^[a-zA-Z_][a-zA-Z0-9_-]*$ (len ≤ 64)",
2306                t.name
2307            );
2308            assert!(
2309                passes_gemini(&t.name),
2310                "tool name {:?} fails Gemini function-calling regex \
2311                 ^[a-zA-Z_][a-zA-Z0-9_]*$ (len ≤ 63, strict)",
2312                t.name
2313            );
2314        }
2315    }
2316
2317    /// Regression guard for the v0.5.0 Priority 4 jargon pass.
2318    ///
2319    /// Tool descriptions and `get_info().instructions` are the first
2320    /// (and often only) thing a calling LLM reads when its
2321    /// tool-search mechanism decides whether Solo's tools are
2322    /// relevant. Earlier descriptions leaned on Solo-internal
2323    /// vocabulary (`SPO`, `Steward`, `LEFT JOIN`, `candidate pair`,
2324    /// `tagged_with`) which doesn't pattern-match natural-language
2325    /// agent queries like "what do you know about Alex?" — that's
2326    /// the load-bearing v0.5.0 finding from the 2026-05-14
2327    /// thesis-test in Claude Desktop.
2328    ///
2329    /// This test pins the de-jargoning by forbidding the old
2330    /// vocabulary from appearing in any user-facing text. Future
2331    /// contributors who reach for jargon trip the test and have to
2332    /// pick plain-English phrasing instead.
2333    #[test]
2334    fn tool_descriptions_avoid_internal_jargon() {
2335        // Case-insensitive substring match. Drawn from the
2336        // pre-Priority-4 descriptions; expand only if a new term
2337        // creeps in.
2338        const FORBIDDEN: &[&str] = &[
2339            "SPO",
2340            "Steward",
2341            "Steward-flagged",
2342            "LEFT JOIN",
2343            "candidate pair",
2344            "candidate_pair",
2345            "tagged_with",
2346        ];
2347
2348        fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
2349            haystack.to_lowercase().contains(&needle.to_lowercase())
2350        }
2351
2352        // 1. Each tool description.
2353        for t in build_tools() {
2354            let desc = t.description.as_deref().unwrap_or("");
2355            for term in FORBIDDEN {
2356                assert!(
2357                    !contains_case_insensitive(desc, term),
2358                    "tool {:?} description contains forbidden jargon \
2359                     {:?} — rewrite in plain English (see v0.5.0 \
2360                     Priority 4)",
2361                    t.name,
2362                    term,
2363                );
2364            }
2365        }
2366
2367        // 2. The server-level instructions (what tool-search sees
2368        // first).
2369        let server_info = harness_server_info();
2370        let instructions = server_info
2371            .instructions
2372            .as_deref()
2373            .expect("get_info() must set instructions");
2374        for term in FORBIDDEN {
2375            assert!(
2376                !contains_case_insensitive(instructions, term),
2377                "get_info().instructions contains forbidden jargon \
2378                 {:?} — rewrite in plain English",
2379                term,
2380            );
2381        }
2382    }
2383
2384    /// Build a `ServerInfo` for the jargon test without spinning up
2385    /// the full harness (which needs tokio + tempdir). The
2386    /// `ServerHandler::get_info()` method doesn't take `&self` state
2387    /// in any meaningful way for our impl — it returns a static
2388    /// `ServerInfo` literal — so we construct a minimal-input server
2389    /// just to call it.
2390    fn harness_server_info() -> rmcp::model::ServerInfo {
2391        let runtime = rt();
2392        let h = Harness::new(&runtime);
2393        let info = ServerHandler::get_info(&h.server);
2394        h.shutdown(&runtime);
2395        info
2396    }
2397
2398    /// Regression guard for the v0.9.0 → v0.9.1 P1 Fix 1 MCP
2399    /// `serverInfo` identity regression.
2400    ///
2401    /// In v0.9.0, P0a's rmcp 0.1.5 → 1.7 bump replaced the explicit
2402    /// `Implementation::new("solo", "<version>")` constructor with
2403    /// `Implementation::from_build_env()`. That helper reads
2404    /// `CARGO_PKG_NAME` + `CARGO_PKG_VERSION` from **rmcp's own** build
2405    /// environment (the proc-macro expansion captures rmcp's
2406    /// `Cargo.toml`, not the consumer's). Every Solo MCP daemon on
2407    /// v0.9.0 self-identified as `{name: "rmcp", version: "1.7.0"}`
2408    /// instead of `{name: "solo", version: "<workspace.version>"}`.
2409    ///
2410    /// Pins:
2411    ///   - `name == "solo"` (the operator-facing binary name, not
2412    ///     `"solo-api"` which would come from
2413    ///     `env!("CARGO_PKG_NAME")` against this crate's manifest);
2414    ///   - `version == env!("CARGO_PKG_VERSION")` from solo-api's own
2415    ///     compile environment (this is the workspace.package version
2416    ///     via inheritance, so it stays in sync with `solo --version`
2417    ///     and `solo-cli`'s identity).
2418    #[test]
2419    fn server_info_identity_is_solo_not_rmcp_or_solo_api() {
2420        let info = harness_server_info();
2421        let name = info.server_info.name.as_str();
2422        let version = info.server_info.version.as_str();
2423        assert_eq!(
2424            name, "solo",
2425            "MCP serverInfo.name must be \"solo\" (not \"rmcp\" or \
2426             \"solo-api\"). got name={name:?} version={version:?}"
2427        );
2428        assert_eq!(
2429            version,
2430            env!("CARGO_PKG_VERSION"),
2431            "MCP serverInfo.version must match solo-api's compile-time \
2432             CARGO_PKG_VERSION (i.e. the workspace.package version); \
2433             a mismatch means we regressed back to rmcp's build env. \
2434             got version={version:?}"
2435        );
2436    }
2437
2438    // ---- memory_inspect_cluster (v0.5.0 Priority 3) ----
2439
2440    #[test]
2441    fn inspect_cluster_unknown_id_returns_invalid_params() {
2442        // NotFound from solo_query::inspect_cluster is mapped through
2443        // `solo_to_mcp` to `invalid_params` (MCP has no separate
2444        // not-found error shape). Error message should name the id.
2445        let runtime = rt();
2446        let h = Harness::new(&runtime);
2447        runtime.block_on(async {
2448            let err = h
2449                .server
2450                .dispatch_tool(
2451                    "memory_inspect_cluster",
2452                    json!({ "cluster_id": "no-such-cluster" }),
2453                )
2454                .await
2455                .expect_err("unknown cluster must error");
2456            let s = format!("{err:?}");
2457            assert!(
2458                s.contains("no-such-cluster") || s.to_lowercase().contains("not found"),
2459                "expected error to mention the missing cluster id; got: {s}"
2460            );
2461        });
2462        h.shutdown(&runtime);
2463    }
2464
2465    #[test]
2466    fn inspect_cluster_rejects_empty_id() {
2467        let runtime = rt();
2468        let h = Harness::new(&runtime);
2469        runtime.block_on(async {
2470            let err = h
2471                .server
2472                .dispatch_tool(
2473                    "memory_inspect_cluster",
2474                    json!({ "cluster_id": "   " }),
2475                )
2476                .await
2477                .expect_err("blank cluster_id must error");
2478            let s = format!("{err:?}");
2479            assert!(
2480                s.to_lowercase().contains("cluster_id")
2481                    || s.to_lowercase().contains("must not be empty"),
2482                "got: {s}"
2483            );
2484        });
2485        h.shutdown(&runtime);
2486    }
2487
2488    // ---- Document tools (v0.7.0 P5) ----
2489    //
2490    // The five document handlers each have two arg-shape tests:
2491    //   - arg-struct parses from JSON (serde round-trip; defaults work).
2492    //   - dispatch arm routes to the handler (we observe behaviour via
2493    //     a known empty-DB response — bad routing surfaces as
2494    //     "unknown tool" or wrong shape).
2495    //
2496    // Functional coverage (ingest → search → inspect → forget) lives in
2497    // `crates/solo-cli/tests/mcp_smoke.rs` where a real subprocess + real
2498    // writer-with-embedder is wired up. The in-process Harness here uses
2499    // `WriterActor::spawn` which doesn't carry an embedder, so ingest /
2500    // search themselves return an error — but the dispatch + arg-parse
2501    // paths exercise correctly.
2502
2503    #[test]
2504    fn ingest_document_args_parse_with_required_path() {
2505        let v: IngestDocumentArgs =
2506            serde_json::from_value(json!({ "path": "/tmp/notes.md" })).expect("parses");
2507        assert_eq!(v.path, "/tmp/notes.md");
2508        // path is required — missing must reject at deserialization.
2509        let err = serde_json::from_value::<IngestDocumentArgs>(json!({})).unwrap_err();
2510        assert!(format!("{err}").contains("path"));
2511    }
2512
2513    #[test]
2514    fn search_docs_args_parse_with_default_limit() {
2515        let v: SearchDocsArgs =
2516            serde_json::from_value(json!({ "query": "backups" })).expect("parses");
2517        assert_eq!(v.query, "backups");
2518        assert_eq!(v.limit, 5, "default limit must be 5");
2519        let v: SearchDocsArgs =
2520            serde_json::from_value(json!({ "query": "backups", "limit": 20 })).expect("parses");
2521        assert_eq!(v.limit, 20);
2522    }
2523
2524    #[test]
2525    fn inspect_document_args_parse_with_required_doc_id() {
2526        let v: InspectDocumentArgs =
2527            serde_json::from_value(json!({ "doc_id": "abc" })).expect("parses");
2528        assert_eq!(v.doc_id, "abc");
2529        let err = serde_json::from_value::<InspectDocumentArgs>(json!({})).unwrap_err();
2530        assert!(format!("{err}").contains("doc_id"));
2531    }
2532
2533    #[test]
2534    fn list_documents_args_parse_with_all_defaults() {
2535        let v: ListDocumentsArgs = serde_json::from_value(json!({})).expect("parses");
2536        assert_eq!(v.limit, 20, "default limit must be 20");
2537        assert_eq!(v.offset, 0, "default offset must be 0");
2538        assert!(!v.include_forgotten, "default include_forgotten must be false");
2539        let v: ListDocumentsArgs = serde_json::from_value(
2540            json!({ "limit": 5, "offset": 10, "include_forgotten": true }),
2541        )
2542        .expect("parses");
2543        assert_eq!(v.limit, 5);
2544        assert_eq!(v.offset, 10);
2545        assert!(v.include_forgotten);
2546    }
2547
2548    #[test]
2549    fn forget_document_args_parse_with_required_doc_id() {
2550        let v: ForgetDocumentArgs =
2551            serde_json::from_value(json!({ "doc_id": "abc" })).expect("parses");
2552        assert_eq!(v.doc_id, "abc");
2553        let err = serde_json::from_value::<ForgetDocumentArgs>(json!({})).unwrap_err();
2554        assert!(format!("{err}").contains("doc_id"));
2555    }
2556
2557    #[test]
2558    fn ingest_document_rejects_empty_path() {
2559        // Reaches the dispatch arm → handle_ingest_document → empty
2560        // guard fires before the writer is touched. Proves routing.
2561        let runtime = rt();
2562        let h = Harness::new(&runtime);
2563        runtime.block_on(async {
2564            let err = h
2565                .server
2566                .dispatch_tool("memory_ingest_document", json!({ "path": "" }))
2567                .await
2568                .expect_err("empty path must error");
2569            let s = format!("{err:?}");
2570            assert!(
2571                s.to_lowercase().contains("path")
2572                    || s.to_lowercase().contains("must not be empty"),
2573                "got: {s}"
2574            );
2575        });
2576        h.shutdown(&runtime);
2577    }
2578
2579    #[test]
2580    fn search_docs_rejects_empty_query() {
2581        // Empty query trips solo_query::run_doc_search's validation
2582        // → InvalidInput → invalid_params.
2583        let runtime = rt();
2584        let h = Harness::new(&runtime);
2585        runtime.block_on(async {
2586            let err = h
2587                .server
2588                .dispatch_tool("memory_search_docs", json!({ "query": "   " }))
2589                .await
2590                .expect_err("empty query must error");
2591            let s = format!("{err:?}");
2592            assert!(
2593                s.to_lowercase().contains("must not be empty")
2594                    || s.to_lowercase().contains("invalid"),
2595                "got: {s}"
2596            );
2597        });
2598        h.shutdown(&runtime);
2599    }
2600
2601    #[test]
2602    fn inspect_document_unknown_id_returns_invalid_params() {
2603        // Valid UUID format but no row exists → handler returns
2604        // invalid_params with the missing id in the message.
2605        let runtime = rt();
2606        let h = Harness::new(&runtime);
2607        runtime.block_on(async {
2608            let err = h
2609                .server
2610                .dispatch_tool(
2611                    "memory_inspect_document",
2612                    json!({ "doc_id": "00000000-0000-7000-8000-000000000000" }),
2613                )
2614                .await
2615                .expect_err("unknown doc must error");
2616            let s = format!("{err:?}");
2617            assert!(
2618                s.to_lowercase().contains("not found"),
2619                "expected 'not found' message; got: {s}"
2620            );
2621        });
2622        h.shutdown(&runtime);
2623    }
2624
2625    #[test]
2626    fn inspect_document_rejects_malformed_id() {
2627        let runtime = rt();
2628        let h = Harness::new(&runtime);
2629        runtime.block_on(async {
2630            let err = h
2631                .server
2632                .dispatch_tool(
2633                    "memory_inspect_document",
2634                    json!({ "doc_id": "not-a-uuid" }),
2635                )
2636                .await
2637                .expect_err("malformed doc_id must error");
2638            let s = format!("{err:?}");
2639            assert!(s.contains("invalid doc_id"), "got: {s}");
2640        });
2641        h.shutdown(&runtime);
2642    }
2643
2644    #[test]
2645    fn list_documents_returns_empty_array_on_empty_db() {
2646        let runtime = rt();
2647        let h = Harness::new(&runtime);
2648        runtime.block_on(async {
2649            let r = h
2650                .server
2651                .dispatch_tool("memory_list_documents", json!({}))
2652                .await
2653                .expect("list succeeds");
2654            let text = first_text(&r);
2655            let v: serde_json::Value =
2656                serde_json::from_str(&text).expect("parses as json");
2657            assert!(v.is_array(), "expected array, got: {text}");
2658            assert_eq!(v.as_array().unwrap().len(), 0);
2659        });
2660        h.shutdown(&runtime);
2661    }
2662
2663    #[test]
2664    fn list_documents_passes_through_limit_offset_include_args() {
2665        let runtime = rt();
2666        let h = Harness::new(&runtime);
2667        runtime.block_on(async {
2668            let r = h
2669                .server
2670                .dispatch_tool(
2671                    "memory_list_documents",
2672                    json!({ "limit": 5, "offset": 10, "include_forgotten": true }),
2673                )
2674                .await
2675                .expect("list with args succeeds");
2676            let text = first_text(&r);
2677            let v: serde_json::Value =
2678                serde_json::from_str(&text).expect("parses as json");
2679            assert!(v.is_array());
2680        });
2681        h.shutdown(&runtime);
2682    }
2683
2684    #[test]
2685    fn forget_document_rejects_malformed_id() {
2686        let runtime = rt();
2687        let h = Harness::new(&runtime);
2688        runtime.block_on(async {
2689            let err = h
2690                .server
2691                .dispatch_tool(
2692                    "memory_forget_document",
2693                    json!({ "doc_id": "not-a-uuid" }),
2694                )
2695                .await
2696                .expect_err("malformed doc_id must error");
2697            let s = format!("{err:?}");
2698            assert!(s.contains("invalid doc_id"), "got: {s}");
2699        });
2700        h.shutdown(&runtime);
2701    }
2702
2703    // -----------------------------------------------------------------
2704    // v0.9.2 — `memory_remember_batch` + `salience` MCP layer tests.
2705    // -----------------------------------------------------------------
2706
2707    /// salience round-trip through `memory_remember`: an explicit
2708    /// in-range value reaches the writer; an absent value defaults
2709    /// to 0.5; an out-of-range value is rejected with invalid_params.
2710    #[test]
2711    fn remember_with_explicit_salience_round_trips() {
2712        let runtime = rt();
2713        let h = Harness::new(&runtime);
2714        runtime.block_on(async {
2715            let r = h
2716                .server
2717                .dispatch_tool(
2718                    "memory_remember",
2719                    json!({ "content": "with salience", "salience": 0.83 }),
2720                )
2721                .await
2722                .expect("remember w/ salience succeeds");
2723            let text = first_text(&r);
2724            // Confirmation includes the new MemoryId.
2725            assert!(text.starts_with("remembered "), "got: {text}");
2726        });
2727        h.shutdown(&runtime);
2728    }
2729
2730    #[test]
2731    fn remember_with_out_of_range_salience_returns_invalid_params() {
2732        let runtime = rt();
2733        let h = Harness::new(&runtime);
2734        runtime.block_on(async {
2735            let err = h
2736                .server
2737                .dispatch_tool(
2738                    "memory_remember",
2739                    json!({ "content": "out of range", "salience": 1.5 }),
2740                )
2741                .await
2742                .unwrap_err();
2743            let s = format!("{err:?}");
2744            assert!(s.contains("salience must be"), "got: {s}");
2745        });
2746        h.shutdown(&runtime);
2747    }
2748
2749    /// Salience boundary: 0.0 and 1.0 are both valid (inclusive range).
2750    #[test]
2751    fn remember_with_boundary_salience_succeeds() {
2752        let runtime = rt();
2753        let h = Harness::new(&runtime);
2754        runtime.block_on(async {
2755            for s in [0.0_f64, 1.0_f64] {
2756                let r = h
2757                    .server
2758                    .dispatch_tool(
2759                        "memory_remember",
2760                        json!({ "content": format!("boundary-{s}"), "salience": s }),
2761                    )
2762                    .await
2763                    .expect("boundary salience succeeds");
2764                assert!(first_text(&r).starts_with("remembered "));
2765            }
2766        });
2767        h.shutdown(&runtime);
2768    }
2769
2770    /// Happy-path batch: 3 items go in, 3 memory_ids come out in order.
2771    #[test]
2772    fn remember_batch_returns_ids_in_order() {
2773        let runtime = rt();
2774        let h = Harness::new(&runtime);
2775        runtime.block_on(async {
2776            let items = json!([
2777                { "content": "batch-a" },
2778                { "content": "batch-b", "source_type": "user_preference", "salience": 0.9 },
2779                { "content": "batch-c", "salience": 0.1 },
2780            ]);
2781            let r = h
2782                .server
2783                .dispatch_tool(
2784                    "memory_remember_batch",
2785                    json!({ "items": items }),
2786                )
2787                .await
2788                .expect("batch succeeds");
2789            let text = first_text(&r);
2790            let parsed: serde_json::Value =
2791                serde_json::from_str(&text).expect("reply is JSON");
2792            let arr = parsed.as_array().expect("reply is array");
2793            assert_eq!(arr.len(), 3, "3 items in → 3 ids out: {text}");
2794            // Each entry must be a UUID-shaped string.
2795            for v in arr {
2796                let s = v.as_str().unwrap_or_else(|| panic!("non-string id: {v}"));
2797                assert_eq!(s.len(), 36, "UUID-shaped id expected: {s}");
2798            }
2799            // Distinct ids.
2800            let mut ids: Vec<&str> = arr.iter().map(|v| v.as_str().unwrap()).collect();
2801            ids.sort();
2802            ids.dedup();
2803            assert_eq!(ids.len(), 3, "ids must be distinct: {text}");
2804        });
2805        h.shutdown(&runtime);
2806    }
2807
2808    /// Empty items → invalid_params before any embedding work.
2809    #[test]
2810    fn remember_batch_empty_items_returns_invalid_params() {
2811        let runtime = rt();
2812        let h = Harness::new(&runtime);
2813        runtime.block_on(async {
2814            let err = h
2815                .server
2816                .dispatch_tool(
2817                    "memory_remember_batch",
2818                    json!({ "items": [] }),
2819                )
2820                .await
2821                .unwrap_err();
2822            let s = format!("{err:?}");
2823            assert!(s.contains("must not be empty"), "got: {s}");
2824        });
2825        h.shutdown(&runtime);
2826    }
2827
2828    /// Per-item validation: empty content trips invalid_params with the
2829    /// index of the offending item baked into the message.
2830    #[test]
2831    fn remember_batch_rejects_per_item_empty_content() {
2832        let runtime = rt();
2833        let h = Harness::new(&runtime);
2834        runtime.block_on(async {
2835            let items = json!([
2836                { "content": "ok-1" },
2837                { "content": "   " },
2838                { "content": "ok-3" },
2839            ]);
2840            let err = h
2841                .server
2842                .dispatch_tool(
2843                    "memory_remember_batch",
2844                    json!({ "items": items }),
2845                )
2846                .await
2847                .unwrap_err();
2848            let s = format!("{err:?}");
2849            assert!(s.contains("items[1]"), "must mention items[1]: {s}");
2850            assert!(s.contains("must not be empty"), "got: {s}");
2851        });
2852        h.shutdown(&runtime);
2853    }
2854
2855    /// Per-item validation: out-of-range salience trips invalid_params
2856    /// with the item index in the message.
2857    #[test]
2858    fn remember_batch_rejects_per_item_salience_out_of_range() {
2859        let runtime = rt();
2860        let h = Harness::new(&runtime);
2861        runtime.block_on(async {
2862            let items = json!([
2863                { "content": "ok-1", "salience": 0.5 },
2864                { "content": "out-of-range", "salience": -0.1 },
2865            ]);
2866            let err = h
2867                .server
2868                .dispatch_tool(
2869                    "memory_remember_batch",
2870                    json!({ "items": items }),
2871                )
2872                .await
2873                .unwrap_err();
2874            let s = format!("{err:?}");
2875            assert!(s.contains("items[1]"), "must mention items[1]: {s}");
2876            assert!(s.contains("salience must be"), "got: {s}");
2877        });
2878        h.shutdown(&runtime);
2879    }
2880
2881    /// Over-cap batch is rejected at the MCP layer so we never embed
2882    /// 201+ items. Pinned at the same constant as the writer-actor.
2883    #[test]
2884    fn remember_batch_over_cap_returns_invalid_params() {
2885        let runtime = rt();
2886        let h = Harness::new(&runtime);
2887        runtime.block_on(async {
2888            let items: Vec<serde_json::Value> =
2889                (0..(solo_storage::MAX_REMEMBER_BATCH_SIZE + 1))
2890                    .map(|i| json!({ "content": format!("over-{i}") }))
2891                    .collect();
2892            let err = h
2893                .server
2894                .dispatch_tool(
2895                    "memory_remember_batch",
2896                    json!({ "items": items }),
2897                )
2898                .await
2899                .unwrap_err();
2900            let s = format!("{err:?}");
2901            assert!(
2902                s.contains("MAX_REMEMBER_BATCH_SIZE"),
2903                "must mention the cap: {s}"
2904            );
2905        });
2906        h.shutdown(&runtime);
2907    }
2908}
2909
2910// ===========================================================================
2911// v0.8.1 P2: MCP audit principal extraction
2912// ===========================================================================
2913//
2914// These tests live in their own module because they manipulate the
2915// `SOLO_MCP_PRINCIPAL_TOKEN` env var, which is process-global mutable
2916// state. Serialised via a static `Mutex` so cargo test's multi-threaded
2917// runner doesn't race. Pattern mirrors the env-guard discipline in
2918// `solo_cli::commands::common::ollama_overrides_tests`.
2919
2920#[cfg(test)]
2921mod principal_extraction_tests {
2922    use super::*;
2923    use std::sync::Mutex;
2924
2925    /// Serialise tests that mutate `SOLO_MCP_PRINCIPAL_TOKEN`. Poisoned
2926    /// guards are recovered via `into_inner` so one panicking test
2927    /// doesn't sink the rest of the suite.
2928    static ENV_LOCK: Mutex<()> = Mutex::new(());
2929
2930    /// RAII guard that unsets the env var on drop, so a panicking test
2931    /// doesn't leak state into the next case.
2932    struct EnvGuard;
2933    impl Drop for EnvGuard {
2934        fn drop(&mut self) {
2935            // SAFETY: every caller holds ENV_LOCK across construct + drop.
2936            unsafe { std::env::remove_var(ENV_MCP_PRINCIPAL_TOKEN) };
2937        }
2938    }
2939
2940    fn set_principal_env(val: &str) -> EnvGuard {
2941        // SAFETY: ENV_LOCK held by caller.
2942        unsafe { std::env::set_var(ENV_MCP_PRINCIPAL_TOKEN, val) };
2943        EnvGuard
2944    }
2945
2946    fn clear_principal_env() -> EnvGuard {
2947        // SAFETY: ENV_LOCK held by caller.
2948        unsafe { std::env::remove_var(ENV_MCP_PRINCIPAL_TOKEN) };
2949        EnvGuard
2950    }
2951
2952    /// Stdio path: setting `SOLO_MCP_PRINCIPAL_TOKEN` produces a
2953    /// non-None principal at construction time.
2954    #[test]
2955    fn stdio_env_var_resolves_to_principal() {
2956        let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2957        let _g = set_principal_env("alice-token");
2958        let resolved = resolve_mcp_principal(None);
2959        assert_eq!(resolved.as_deref(), Some("alice-token"));
2960    }
2961
2962    /// Stdio path: absent env var ⇒ `None` (regression — must preserve
2963    /// v0.8.0 behaviour for users without auth).
2964    #[test]
2965    fn stdio_no_env_var_resolves_to_none() {
2966        let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2967        let _g = clear_principal_env();
2968        assert_eq!(resolve_mcp_principal(None), None);
2969    }
2970
2971    /// Stdio path: whitespace-only env var ⇒ `None` (don't pin every
2972    /// audit row to an empty/blank principal because of a launcher
2973    /// typo).
2974    #[test]
2975    fn stdio_whitespace_env_var_resolves_to_none() {
2976        let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2977        let _g = set_principal_env("   \t  ");
2978        assert_eq!(resolve_mcp_principal(None), None);
2979    }
2980
2981    /// HTTP-MCP path: `Authorization: Bearer <token>` header resolves
2982    /// to the token as principal.
2983    #[test]
2984    fn http_header_resolves_to_bearer_token_principal() {
2985        let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2986        let _g = clear_principal_env();
2987        let resolved = resolve_mcp_principal(Some("Bearer api-token-xyz"));
2988        assert_eq!(resolved.as_deref(), Some("api-token-xyz"));
2989    }
2990
2991    /// Precedence: when both env var AND header carry a token, the
2992    /// header wins (consistent with the rest of the auth stack — JWT
2993    /// claim beats `X-Solo-Tenant` header).
2994    #[test]
2995    fn http_header_beats_env_var() {
2996        let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2997        let _g = set_principal_env("env-token");
2998        let resolved = resolve_mcp_principal(Some("Bearer header-token"));
2999        assert_eq!(
3000            resolved.as_deref(),
3001            Some("header-token"),
3002            "header MUST win over env var per documented precedence"
3003        );
3004    }
3005
3006    /// HTTP-MCP path: malformed header (no `Bearer ` prefix) ⇒ falls
3007    /// through to env-var path.
3008    #[test]
3009    fn http_malformed_header_falls_through_to_env() {
3010        let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
3011        let _g = set_principal_env("env-fallback");
3012        let resolved = resolve_mcp_principal(Some("Basic dXNlcjpwYXNz"));
3013        assert_eq!(resolved.as_deref(), Some("env-fallback"));
3014    }
3015
3016    /// HTTP-MCP path: empty bearer header (`Bearer ` with no token)
3017    /// falls through to env-var path. Matches the spirit of the
3018    /// whitespace-env-var rejection — don't credit a half-formed
3019    /// header.
3020    #[test]
3021    fn http_empty_bearer_header_falls_through_to_env() {
3022        let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
3023        let _g = set_principal_env("env-fallback");
3024        let resolved = resolve_mcp_principal(Some("Bearer   "));
3025        assert_eq!(resolved.as_deref(), Some("env-fallback"));
3026    }
3027
3028    /// Across N consecutive calls of `resolve_mcp_principal`, the
3029    /// resolved principal is stable for the same env-var setting
3030    /// (regression guard: an accidental thread-local cache would
3031    /// break the "stable across N tool calls in one session" contract
3032    /// the brief calls out).
3033    #[test]
3034    fn stable_across_multiple_resolutions() {
3035        let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
3036        let _g = set_principal_env("stable-token");
3037        for _ in 0..5 {
3038            assert_eq!(
3039                resolve_mcp_principal(None).as_deref(),
3040                Some("stable-token")
3041            );
3042        }
3043    }
3044}
3045
3046/// v0.9.0 P2 tests for the MCP-initialize-time LLM-config gate.
3047///
3048/// Pure-function tests of [`initialize_decision`]: no rmcp Peer is
3049/// constructed (the type's constructors are private), no MCP handshake
3050/// is driven. The wire-up between `initialize_decision` and the
3051/// side-effect path lives in [`SoloMcpServer::initialize`] and is
3052/// covered indirectly by the audit-row tests in
3053/// [`crate::llm::sampling::tests`] — those exercise the same
3054/// `SamplingLlmClient` + `WriteCommand::EmitLlmSamplingAudit` path
3055/// that `populate_sampling_steward` constructs.
3056#[cfg(test)]
3057mod initialize_decision_tests {
3058    use super::*;
3059    use solo_storage::LlmSettings;
3060
3061    /// `[llm]` absent → always Allow (matches v0.8.x behaviour).
3062    #[test]
3063    fn no_llm_block_allows_initialize_regardless_of_sampling_capability() {
3064        assert_eq!(initialize_decision(&None, false), InitializeDecision::Allow);
3065        assert_eq!(initialize_decision(&None, true), InitializeDecision::Allow);
3066    }
3067
3068    /// `[llm] mode = "none"` → always Allow.
3069    #[test]
3070    fn llm_none_allows_initialize_regardless_of_sampling_capability() {
3071        let s = Some(LlmSettings::None);
3072        assert_eq!(initialize_decision(&s, false), InitializeDecision::Allow);
3073        assert_eq!(initialize_decision(&s, true), InitializeDecision::Allow);
3074    }
3075
3076    /// `[llm] mode = "anthropic"` → always Allow.
3077    #[test]
3078    fn llm_anthropic_allows_initialize_regardless_of_sampling_capability() {
3079        let s = Some(LlmSettings::Anthropic {
3080            api_key_env: "ANTHROPIC_API_KEY".into(),
3081            model: "claude-sonnet-4-6".into(),
3082        });
3083        assert_eq!(initialize_decision(&s, false), InitializeDecision::Allow);
3084        assert_eq!(initialize_decision(&s, true), InitializeDecision::Allow);
3085    }
3086
3087    /// `[llm] mode = "ollama"` → always Allow.
3088    #[test]
3089    fn llm_ollama_allows_initialize_regardless_of_sampling_capability() {
3090        let s = Some(LlmSettings::Ollama {
3091            base_url: "http://localhost:11434".into(),
3092            model: "qwen3-coder:30b".into(),
3093        });
3094        assert_eq!(initialize_decision(&s, false), InitializeDecision::Allow);
3095        assert_eq!(initialize_decision(&s, true), InitializeDecision::Allow);
3096    }
3097
3098    /// `[llm] mode = "mcp_sampling"` + peer with sampling capability →
3099    /// populate the slot.
3100    #[test]
3101    fn llm_mcp_sampling_with_sampling_capability_populates_slot() {
3102        let s = Some(LlmSettings::McpSampling);
3103        assert_eq!(
3104            initialize_decision(&s, true),
3105            InitializeDecision::PopulateSamplingSteward
3106        );
3107    }
3108
3109    /// `[llm] mode = "mcp_sampling"` + peer WITHOUT sampling
3110    /// capability → reject initialize with the locked BLOCKER 2 error.
3111    #[test]
3112    fn llm_mcp_sampling_without_sampling_capability_rejects() {
3113        let s = Some(LlmSettings::McpSampling);
3114        assert_eq!(
3115            initialize_decision(&s, false),
3116            InitializeDecision::RejectMissingSamplingCapability
3117        );
3118    }
3119
3120    /// The locked BLOCKER 2 error message body is byte-stable: a future
3121    /// audit-revision can grep these strings and confirm they still
3122    /// land.
3123    #[test]
3124    fn sampling_capability_missing_error_message_contains_all_alternatives() {
3125        let msg = sampling_capability_missing_error_message();
3126        // Banner + four alternative blocks.
3127        assert!(msg.contains("LLM backend `mcp_sampling`"));
3128        assert!(msg.contains("mode = \"anthropic\""));
3129        assert!(msg.contains("api_key_env = \"ANTHROPIC_API_KEY\""));
3130        assert!(msg.contains("mode = \"openai\""));
3131        assert!(msg.contains("api_key_env = \"OPENAI_API_KEY\""));
3132        assert!(msg.contains("mode = \"ollama\""));
3133        assert!(msg.contains("base_url = \"http://localhost:11434\""));
3134        assert!(msg.contains("mode = \"none\""));
3135        // Footer pointer at the release-prep doc.
3136        assert!(msg.contains("docs/releases/v0.9.0.md"));
3137    }
3138}
3139
3140// fetch_recall_rows + RecallHit + RecallRow used to live here. Recall
3141// pipeline moved to solo_query::recall in commit (consolidate-recall);
3142// transports just call solo_query::run_recall and format the result.