Skip to main content

solo_api/
mcp.rs

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