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 four tools to MCP clients (Claude Desktop, Cursor, etc.):
6//!
7//!   - `memory.remember(content, source_type?, source_id?)` — store an
8//!     episode. Returns the new MemoryId.
9//!   - `memory.recall(query, limit?)` — vector search. Returns the top-K
10//!     matches with content + tier + status.
11//!   - `memory.forget(memory_id, reason?)` — soft-delete an episode.
12//!   - `memory.inspect(memory_id)` — return the full episode record.
13//!
14//! ## Transport
15//!
16//! `serve_stdio` wires the server to stdin/stdout for use as a subprocess
17//! ("`claude_desktop_config.json` or `~/.cursor/mcp.json` invokes
18//! `solo mcp-stdio`"). The function awaits a graceful shutdown when stdin
19//! closes (parent disconnects) — same lifecycle as `solo daemon`'s
20//! Ctrl+C path.
21//!
22//! ## What's deferred
23//!
24//! - SSE/HTTP transports — `rmcp` ships them, but v0.1 ships stdio only.
25//! - `prompts/` and `resources/` capabilities — not needed for the
26//!   four-tool surface; ServerHandler defaults return empty lists.
27//! - Tool argument validation beyond JSON Schema typing — we trust rmcp
28//!   to deserialize per the schema, then serde-deserialize into our
29//!   typed param structs. Bad inputs surface as clear errors.
30
31use std::sync::Arc;
32
33use rmcp::handler::server::ServerHandler;
34use rmcp::model::{
35    CallToolRequestParam, CallToolResult, Content, Implementation, ListToolsResult,
36    PaginatedRequestParam, ProtocolVersion, ServerCapabilities, ServerInfo, Tool,
37    ToolsCapability,
38};
39use rmcp::service::{RequestContext, RoleServer};
40use rmcp::{Error as McpError, ServiceExt};
41use serde::{Deserialize, Serialize};
42use solo_core::{
43    Confidence, Embedder, EncodingContext, Episode, MemoryId, Tier,
44    VectorIndex,
45};
46use solo_storage::{ReaderPool, WriteHandle};
47use std::str::FromStr;
48
49/// The MCP server. Cheap to clone — every field is `Arc`-cloneable.
50#[derive(Clone)]
51pub struct SoloMcpServer {
52    inner: Arc<Inner>,
53}
54
55struct Inner {
56    write: WriteHandle,
57    pool: ReaderPool,
58    embedder: Arc<dyn Embedder>,
59    hnsw: Arc<dyn VectorIndex + Send + Sync>,
60}
61
62impl SoloMcpServer {
63    pub fn new(
64        write: WriteHandle,
65        pool: ReaderPool,
66        embedder: Arc<dyn Embedder>,
67        hnsw: Arc<dyn VectorIndex + Send + Sync>,
68    ) -> Self {
69        Self {
70            inner: Arc::new(Inner {
71                write,
72                pool,
73                embedder,
74                hnsw,
75            }),
76        }
77    }
78}
79
80/// Convenience: run the server over stdio and await its termination.
81/// Returns when stdin closes (parent disconnect) or the runtime exits.
82pub async fn serve_stdio(server: SoloMcpServer) -> anyhow::Result<()> {
83    use rmcp::transport::io::stdio;
84    let (stdin, stdout) = stdio();
85    let running = server.serve((stdin, stdout)).await?;
86    running.waiting().await?;
87    Ok(())
88}
89
90// ---------------------------------------------------------------------------
91// Tool argument schemas
92// ---------------------------------------------------------------------------
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct RememberArgs {
96    pub content: String,
97    #[serde(default)]
98    pub source_type: Option<String>,
99    #[serde(default)]
100    pub source_id: Option<String>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct RecallArgs {
105    pub query: String,
106    #[serde(default = "default_limit")]
107    pub limit: usize,
108}
109
110fn default_limit() -> usize {
111    5
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ForgetArgs {
116    pub memory_id: String,
117    #[serde(default = "default_forget_reason")]
118    pub reason: String,
119}
120
121fn default_forget_reason() -> String {
122    "user-initiated via MCP".into()
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct InspectArgs {
127    pub memory_id: String,
128}
129
130// Path 1 derived-layer tools (v0.4.0+) — query the Steward's outputs.
131// `solo_query::derived` is the single source of truth; these handlers
132// just translate JSON args to function args and serialise the result
133// vec to JSON for the MCP wire.
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ThemesArgs {
137    /// Optional time window in days; `None` = unfiltered, return up
138    /// to `limit` most-recent themes across all time. `Some(7)` =
139    /// "themes from the last week".
140    #[serde(default)]
141    pub window_days: Option<i64>,
142    #[serde(default = "default_limit")]
143    pub limit: usize,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct FactsAboutArgs {
148    /// Subject id to query — required (predicate-only scans
149    /// intentionally not supported).
150    pub subject: String,
151    #[serde(default)]
152    pub predicate: Option<String>,
153    #[serde(default)]
154    pub since_ms: Option<i64>,
155    #[serde(default)]
156    pub until_ms: Option<i64>,
157    #[serde(default = "default_limit")]
158    pub limit: usize,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct ContradictionsArgs {
163    #[serde(default = "default_limit")]
164    pub limit: usize,
165}
166
167// ---------------------------------------------------------------------------
168// ServerHandler implementation
169// ---------------------------------------------------------------------------
170
171impl ServerHandler for SoloMcpServer {
172    fn get_info(&self) -> ServerInfo {
173        ServerInfo {
174            protocol_version: ProtocolVersion::default(),
175            capabilities: ServerCapabilities {
176                tools: Some(ToolsCapability {
177                    list_changed: Some(false),
178                }),
179                ..Default::default()
180            },
181            server_info: Implementation {
182                name: "solo".into(),
183                version: env!("CARGO_PKG_VERSION").into(),
184            },
185            instructions: Some(
186                "Solo: local-first personal memory for LLMs. \
187                 Episode tools: memory.remember (store), \
188                 memory.recall (vector search), memory.forget \
189                 (soft-delete), memory.inspect (full record by id). \
190                 Derived-layer tools (queries against the Steward's \
191                 outputs from `solo consolidate`): memory.themes \
192                 (cluster abstractions), memory.facts_about \
193                 (subject-predicate-object knowledge graph), \
194                 memory.contradictions (flagged disagreements between \
195                 facts)."
196                    .into(),
197            ),
198        }
199    }
200
201    async fn list_tools(
202        &self,
203        _request: PaginatedRequestParam,
204        _context: RequestContext<RoleServer>,
205    ) -> std::result::Result<ListToolsResult, McpError> {
206        Ok(ListToolsResult {
207            tools: build_tools(),
208            next_cursor: None,
209        })
210    }
211
212    async fn call_tool(
213        &self,
214        request: CallToolRequestParam,
215        _context: RequestContext<RoleServer>,
216    ) -> std::result::Result<CallToolResult, McpError> {
217        let CallToolRequestParam { name, arguments } = request;
218        let args_value = serde_json::Value::Object(arguments.unwrap_or_default());
219        self.dispatch_tool(&name, args_value).await
220    }
221}
222
223impl SoloMcpServer {
224    /// Direct tool-dispatch path used by both `call_tool` (the
225    /// ServerHandler trait method, behind the rmcp protocol layer) and
226    /// in-process tests that don't want to spin up a full transport pair.
227    /// Bypasses `RequestContext` (which requires a `Peer` not constructible
228    /// outside rmcp internals).
229    pub async fn dispatch_tool(
230        &self,
231        name: &str,
232        args_value: serde_json::Value,
233    ) -> std::result::Result<CallToolResult, McpError> {
234        match name {
235            "memory.remember" => {
236                let args: RememberArgs = parse_args(&args_value)?;
237                self.handle_remember(args).await
238            }
239            "memory.recall" => {
240                let args: RecallArgs = parse_args(&args_value)?;
241                self.handle_recall(args).await
242            }
243            "memory.forget" => {
244                let args: ForgetArgs = parse_args(&args_value)?;
245                self.handle_forget(args).await
246            }
247            "memory.inspect" => {
248                let args: InspectArgs = parse_args(&args_value)?;
249                self.handle_inspect(args).await
250            }
251            "memory.themes" => {
252                let args: ThemesArgs = parse_args(&args_value)?;
253                self.handle_themes(args).await
254            }
255            "memory.facts_about" => {
256                let args: FactsAboutArgs = parse_args(&args_value)?;
257                self.handle_facts_about(args).await
258            }
259            "memory.contradictions" => {
260                let args: ContradictionsArgs = parse_args(&args_value)?;
261                self.handle_contradictions(args).await
262            }
263            other => Err(McpError::invalid_params(
264                format!("unknown tool `{other}`"),
265                None,
266            )),
267        }
268    }
269
270    /// List the tools this server exposes. Mirrors `ServerHandler::list_tools`
271    /// without requiring a RequestContext.
272    pub fn dispatch_list_tools(&self) -> Vec<Tool> {
273        build_tools()
274    }
275}
276
277fn parse_args<T: serde::de::DeserializeOwned>(
278    v: &serde_json::Value,
279) -> std::result::Result<T, McpError> {
280    serde_json::from_value(v.clone()).map_err(|e| {
281        McpError::invalid_params(format!("invalid tool arguments: {e}"), None)
282    })
283}
284
285fn solo_to_mcp(e: solo_core::Error) -> McpError {
286    use solo_core::Error;
287    match e {
288        Error::NotFound(msg) => McpError::invalid_params(msg, None),
289        Error::InvalidInput(msg) => McpError::invalid_params(msg, None),
290        Error::Conflict(msg) => McpError::invalid_params(msg, None),
291        other => McpError::internal_error(other.to_string(), None),
292    }
293}
294
295// ---------------------------------------------------------------------------
296// Tool definitions (JSON Schema)
297// ---------------------------------------------------------------------------
298
299fn build_tools() -> Vec<Tool> {
300    vec![
301        Tool::new(
302            "memory.remember",
303            "Store a new episodic memory. Returns the new MemoryId (UUID v7).",
304            json_schema_object(serde_json::json!({
305                "type": "object",
306                "properties": {
307                    "content": {
308                        "type": "string",
309                        "description": "The text to remember.",
310                    },
311                    "source_type": {
312                        "type": "string",
313                        "description": "Optional source-type tag (default: \"user_message\").",
314                    },
315                    "source_id": {
316                        "type": "string",
317                        "description": "Optional upstream id for traceability.",
318                    },
319                },
320                "required": ["content"],
321            })),
322        ),
323        Tool::new(
324            "memory.recall",
325            "Vector-search the memory store. Returns up to `limit` results \
326             ordered by cosine distance (smaller = more similar). Excludes \
327             forgotten memories.",
328            json_schema_object(serde_json::json!({
329                "type": "object",
330                "properties": {
331                    "query": {
332                        "type": "string",
333                        "description": "The query text.",
334                    },
335                    "limit": {
336                        "type": "integer",
337                        "description": "Maximum results (default 5).",
338                        "minimum": 1,
339                        "maximum": 100,
340                    },
341                },
342                "required": ["query"],
343            })),
344        ),
345        Tool::new(
346            "memory.forget",
347            "Soft-delete a memory by id. The HNSW vector stays in the graph \
348             but the SQL row's status flips to 'forgotten' so future recalls \
349             exclude it.",
350            json_schema_object(serde_json::json!({
351                "type": "object",
352                "properties": {
353                    "memory_id": {
354                        "type": "string",
355                        "description": "MemoryId to forget (UUID v7).",
356                    },
357                    "reason": {
358                        "type": "string",
359                        "description": "Optional free-form reason (logged, not yet persisted).",
360                    },
361                },
362                "required": ["memory_id"],
363            })),
364        ),
365        Tool::new(
366            "memory.inspect",
367            "Return the full record for a memory_id (timestamps, source, \
368             status, scoring values, content).",
369            json_schema_object(serde_json::json!({
370                "type": "object",
371                "properties": {
372                    "memory_id": {
373                        "type": "string",
374                        "description": "MemoryId to inspect (UUID v7).",
375                    },
376                },
377                "required": ["memory_id"],
378            })),
379        ),
380        // Path 1 derived-layer tools (v0.4.0+) — query the Steward's
381        // outputs. These four are populated by `solo consolidate` and
382        // were previously unreadable except via direct SQL.
383        Tool::new(
384            "memory.themes",
385            "List recent cluster themes (the Steward's grouping of \
386             related episodes) with their LLM-generated abstractions. \
387             Use this to ask 'what has the user been thinking about \
388             lately' before deciding whether to drill into specific \
389             episodes via memory.recall. Returns up to `limit` results \
390             ordered by most-recent cluster first; pass `window_days` \
391             to scope to e.g. the last week.",
392            json_schema_object(serde_json::json!({
393                "type": "object",
394                "properties": {
395                    "window_days": {
396                        "type": "integer",
397                        "description": "Optional time window in days. Omit for unfiltered.",
398                        "minimum": 1,
399                    },
400                    "limit": {
401                        "type": "integer",
402                        "description": "Maximum results (default 5).",
403                        "minimum": 1,
404                        "maximum": 100,
405                    },
406                },
407            })),
408        ),
409        Tool::new(
410            "memory.facts_about",
411            "Query the structured-fact knowledge graph (subject-\
412             predicate-object triples extracted by the Steward) by \
413             subject + optional predicate + optional time window. Use \
414             this to ground answers on distilled facts rather than raw \
415             episodes. Subject is required; predicate-only scans are \
416             not supported.",
417            json_schema_object(serde_json::json!({
418                "type": "object",
419                "properties": {
420                    "subject": {
421                        "type": "string",
422                        "description": "Subject id to query (e.g. 'Sam').",
423                    },
424                    "predicate": {
425                        "type": "string",
426                        "description": "Optional predicate filter (e.g. 'works_at').",
427                    },
428                    "since_ms": {
429                        "type": "integer",
430                        "description": "Optional valid_from_ms lower bound (epoch ms).",
431                    },
432                    "until_ms": {
433                        "type": "integer",
434                        "description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through.",
435                    },
436                    "limit": {
437                        "type": "integer",
438                        "description": "Maximum results (default 5).",
439                        "minimum": 1,
440                        "maximum": 100,
441                    },
442                },
443                "required": ["subject"],
444            })),
445        ),
446        Tool::new(
447            "memory.contradictions",
448            "List Steward-flagged contradictions (pairs of triples that \
449             disagree). Each result includes both sides' triple SPO via \
450             LEFT JOIN for context. Use this to surface conflicts and \
451             ask the user to disambiguate before relying on memory \
452             content.",
453            json_schema_object(serde_json::json!({
454                "type": "object",
455                "properties": {
456                    "limit": {
457                        "type": "integer",
458                        "description": "Maximum results (default 5).",
459                        "minimum": 1,
460                        "maximum": 100,
461                    },
462                },
463            })),
464        ),
465    ]
466}
467
468fn json_schema_object(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
469    match value {
470        serde_json::Value::Object(map) => map,
471        _ => panic!("json_schema_object: input must be an object"),
472    }
473}
474
475// ---------------------------------------------------------------------------
476// Tool handlers
477// ---------------------------------------------------------------------------
478
479impl SoloMcpServer {
480    async fn handle_remember(
481        &self,
482        args: RememberArgs,
483    ) -> std::result::Result<CallToolResult, McpError> {
484        let content = args.content.trim_end().to_string();
485        if content.is_empty() {
486            return Err(McpError::invalid_params(
487                "memory.remember: content must not be empty".to_string(),
488                None,
489            ));
490        }
491        let embedding: solo_core::Embedding = self
492            .inner
493            .embedder
494            .embed(&content)
495            .await
496            .map_err(solo_to_mcp)?;
497        let episode = Episode {
498            memory_id: MemoryId::new(),
499            ts_ms: chrono::Utc::now().timestamp_millis(),
500            source_type: args.source_type.unwrap_or_else(|| "user_message".into()),
501            source_id: args.source_id,
502            content,
503            encoding_context: EncodingContext::default(),
504            provenance: None,
505            confidence: Confidence::new(0.9).unwrap(),
506            strength: 0.5,
507            salience: 0.5,
508            tier: Tier::Hot,
509        };
510        let mid = self
511            .inner
512            .write
513            .remember(episode, embedding)
514            .await
515            .map_err(solo_to_mcp)?;
516        Ok(CallToolResult::success(vec![Content::text(format!(
517            "remembered {mid}"
518        ))]))
519    }
520
521    async fn handle_recall(
522        &self,
523        args: RecallArgs,
524    ) -> std::result::Result<CallToolResult, McpError> {
525        // Pipeline lives in solo-query; the transport just formats the
526        // result. solo_query::run_recall validates empty queries
527        // (returns InvalidInput → invalid_params via solo_to_mcp).
528        let result = solo_query::run_recall(
529            &self.inner.embedder,
530            &self.inner.hnsw,
531            &self.inner.pool,
532            &args.query,
533            args.limit,
534        )
535        .await
536        .map_err(solo_to_mcp)?;
537
538        if result.hits.is_empty() {
539            return Ok(CallToolResult::success(vec![Content::text(format!(
540                "no matches (index has {} vectors)",
541                result.index_len
542            ))]));
543        }
544        let body = serde_json::to_string_pretty(&result.hits).unwrap_or_else(|_| String::new());
545        Ok(CallToolResult::success(vec![Content::text(body)]))
546    }
547
548    async fn handle_forget(
549        &self,
550        args: ForgetArgs,
551    ) -> std::result::Result<CallToolResult, McpError> {
552        let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
553            McpError::invalid_params(format!("invalid memory_id: {e}"), None)
554        })?;
555        self.inner
556            .write
557            .forget(mid, args.reason)
558            .await
559            .map_err(solo_to_mcp)?;
560        Ok(CallToolResult::success(vec![Content::text(format!(
561            "forgotten {mid}"
562        ))]))
563    }
564
565    async fn handle_inspect(
566        &self,
567        args: InspectArgs,
568    ) -> std::result::Result<CallToolResult, McpError> {
569        let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
570            McpError::invalid_params(format!("invalid memory_id: {e}"), None)
571        })?;
572        // Pipeline lives in solo-query::inspect; transports just format.
573        let row = solo_query::inspect_one(&self.inner.pool, mid)
574            .await
575            .map_err(solo_to_mcp)?;
576        let body = serde_json::to_string_pretty(&row).unwrap_or_else(|_| String::new());
577        Ok(CallToolResult::success(vec![Content::text(body)]))
578    }
579
580    // Path 1 derived-layer handlers (v0.4.0+). Each one delegates to a
581    // single solo-query::derived pipeline and serialises the result Vec
582    // to pretty JSON for the MCP wire. Empty result → JSON empty array
583    // `[]` (not a special-case "no matches" string) so MCP clients can
584    // parse uniformly.
585
586    async fn handle_themes(
587        &self,
588        args: ThemesArgs,
589    ) -> std::result::Result<CallToolResult, McpError> {
590        let hits = solo_query::themes(
591            &self.inner.pool,
592            args.window_days,
593            args.limit,
594        )
595        .await
596        .map_err(solo_to_mcp)?;
597        let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
598        Ok(CallToolResult::success(vec![Content::text(body)]))
599    }
600
601    async fn handle_facts_about(
602        &self,
603        args: FactsAboutArgs,
604    ) -> std::result::Result<CallToolResult, McpError> {
605        if args.subject.trim().is_empty() {
606            return Err(McpError::invalid_params(
607                "memory.facts_about: subject must not be empty".to_string(),
608                None,
609            ));
610        }
611        let hits = solo_query::facts_about(
612            &self.inner.pool,
613            &args.subject,
614            args.predicate.as_deref(),
615            args.since_ms,
616            args.until_ms,
617            args.limit,
618        )
619        .await
620        .map_err(solo_to_mcp)?;
621        let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
622        Ok(CallToolResult::success(vec![Content::text(body)]))
623    }
624
625    async fn handle_contradictions(
626        &self,
627        args: ContradictionsArgs,
628    ) -> std::result::Result<CallToolResult, McpError> {
629        let hits = solo_query::contradictions(&self.inner.pool, args.limit)
630            .await
631            .map_err(solo_to_mcp)?;
632        let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
633        Ok(CallToolResult::success(vec![Content::text(body)]))
634    }
635}
636
637#[cfg(test)]
638mod dispatch_tests {
639    //! In-process integration tests for the MCP tool surface. We invoke
640    //! `SoloMcpServer::dispatch_tool` directly (bypasses the rmcp
641    //! protocol framing + `RequestContext`, which requires a `Peer`
642    //! that's not constructible outside rmcp internals). The server is
643    //! constructed against a real WriterActor + ReaderPool +
644    //! StubEmbedder + StubVectorIndex from `solo_storage::test_support`.
645    //!
646    //! Tests live inline in this module rather than `tests/` because an
647    //! external integration-test exe in `target/debug/deps/mcp_dispatch-*`
648    //! tripped Windows UAC ERROR_ELEVATION_REQUIRED on the dev machine.
649    //! The lib test binary doesn't have that issue.
650    use super::*;
651    use serde_json::json;
652    use solo_core::VectorIndex;
653    use solo_storage::test_support::StubVectorIndex;
654    use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
655    use std::sync::Arc as StdArc;
656
657    struct Harness {
658        server: SoloMcpServer,
659        _tmp: tempfile::TempDir,
660        write_handle_extra: Option<solo_storage::WriteHandle>,
661        join: Option<std::thread::JoinHandle<()>>,
662    }
663
664    impl Harness {
665        fn new(runtime: &tokio::runtime::Runtime) -> Self {
666            let tmp = tempfile::TempDir::new().unwrap();
667            let dim = 16usize;
668            let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
669            let embedder: StdArc<dyn solo_core::Embedder> = StdArc::new(StubEmbedder::new("stub", "v1", dim));
670
671            let conn = solo_storage::test_support::open_test_db_at(&tmp.path().join("test.db"));
672            let WriterSpawn { handle, join } = WriterActor::spawn(conn, hnsw.clone());
673
674            // ReaderPool's deadpool::Pool needs a live tokio runtime for
675            // both build + drop; build inside block_on.
676            let path = tmp.path().join("test.db");
677            let pool: ReaderPool =
678                runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
679
680            let server = SoloMcpServer::new(handle.clone(), pool, embedder, hnsw);
681            Harness {
682                server,
683                _tmp: tmp,
684                write_handle_extra: Some(handle),
685                join: Some(join),
686            }
687        }
688
689        fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
690            // The whole shutdown runs inside block_on so deadpool-sqlite's
691            // drop (which schedules cleanup on the active runtime) sees a
692            // live reactor. Without this, dropping the SoloMcpServer
693            // (which holds the ReaderPool through its Arc<Inner>) panics
694            // with "no reactor running".
695            let join = self.join.take();
696            let extra = self.write_handle_extra.take();
697            runtime.block_on(async move {
698                drop(extra);
699                drop(self.server);
700                drop(self._tmp);
701                if let Some(join) = join {
702                    let (tx, rx) = std::sync::mpsc::channel();
703                    std::thread::spawn(move || {
704                        let _ = tx.send(join.join());
705                    });
706                    tokio::task::spawn_blocking(move || {
707                        rx.recv_timeout(std::time::Duration::from_secs(5))
708                    })
709                    .await
710                    .expect("blocking task")
711                    .expect("writer thread did not exit within 5s")
712                    .expect("writer thread panicked");
713                }
714            });
715        }
716    }
717
718    fn rt() -> tokio::runtime::Runtime {
719        tokio::runtime::Builder::new_multi_thread()
720            .worker_threads(2)
721            .enable_all()
722            .build()
723            .unwrap()
724    }
725
726    /// Pull the first Content::text body out of a CallToolResult. Use
727    /// serde_json roundtrip as a robust extractor — `Content`'s public
728    /// API doesn't directly expose the inner text without going through
729    /// pattern-matching on RawContent.
730    fn first_text(r: &rmcp::model::CallToolResult) -> String {
731        let first = r.content.first().expect("at least one content item");
732        let v = serde_json::to_value(first).expect("content serialises");
733        v.get("text")
734            .and_then(|t| t.as_str())
735            .map(|s| s.to_string())
736            .unwrap_or_else(|| format!("{v}"))
737    }
738
739    #[test]
740    fn tools_list_returns_seven_canonical_tools() {
741        let runtime = rt();
742        let h = Harness::new(&runtime);
743        let tools = h.server.dispatch_list_tools();
744        let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
745        assert_eq!(
746            names,
747            vec![
748                "memory.remember",
749                "memory.recall",
750                "memory.forget",
751                "memory.inspect",
752                // Derived-layer tools added in v0.4.0:
753                "memory.themes",
754                "memory.facts_about",
755                "memory.contradictions",
756            ]
757        );
758        for t in &tools {
759            assert!(!t.description.is_empty(), "{} description empty", t.name);
760            let _schema = t.schema_as_json_value();
761            // `required` is intentionally absent on memory.themes +
762            // memory.contradictions (all args optional with defaults).
763            // memory.facts_about does have required = ["subject"].
764            // We don't assert per-tool 'required' shape here; the
765            // schema's `properties` field is the more important
766            // signal and is always present.
767        }
768        h.shutdown(&runtime);
769    }
770
771    #[test]
772    fn themes_returns_json_array_on_empty_db() {
773        let runtime = rt();
774        let h = Harness::new(&runtime);
775        runtime.block_on(async {
776            let r = h
777                .server
778                .dispatch_tool("memory.themes", json!({}))
779                .await
780                .expect("themes succeeds");
781            let text = first_text(&r);
782            // Empty derived layer → empty array JSON. Parses cleanly.
783            let v: serde_json::Value =
784                serde_json::from_str(&text).expect("parses as json");
785            assert!(v.is_array(), "expected array, got: {text}");
786            assert_eq!(v.as_array().unwrap().len(), 0);
787        });
788        h.shutdown(&runtime);
789    }
790
791    #[test]
792    fn themes_passes_through_window_and_limit_args() {
793        let runtime = rt();
794        let h = Harness::new(&runtime);
795        runtime.block_on(async {
796            // Should not crash with optional + integer args present.
797            let r = h
798                .server
799                .dispatch_tool(
800                    "memory.themes",
801                    json!({ "window_days": 7, "limit": 20 }),
802                )
803                .await
804                .expect("themes with args succeeds");
805            let text = first_text(&r);
806            let v: serde_json::Value =
807                serde_json::from_str(&text).expect("parses as json");
808            assert!(v.is_array());
809        });
810        h.shutdown(&runtime);
811    }
812
813    #[test]
814    fn facts_about_rejects_empty_subject() {
815        let runtime = rt();
816        let h = Harness::new(&runtime);
817        runtime.block_on(async {
818            let err = h
819                .server
820                .dispatch_tool(
821                    "memory.facts_about",
822                    json!({ "subject": "   " }),
823                )
824                .await
825                .expect_err("empty subject must error");
826            // McpError doesn't expose a clean kind/message accessor; just
827            // verify the error fires (validation path reached).
828            let s = format!("{err:?}");
829            assert!(
830                s.to_lowercase().contains("subject")
831                    || s.to_lowercase().contains("invalid"),
832                "got: {s}"
833            );
834        });
835        h.shutdown(&runtime);
836    }
837
838    #[test]
839    fn facts_about_returns_array_for_unknown_subject() {
840        let runtime = rt();
841        let h = Harness::new(&runtime);
842        runtime.block_on(async {
843            let r = h
844                .server
845                .dispatch_tool(
846                    "memory.facts_about",
847                    json!({ "subject": "NobodyKnowsThisSubject" }),
848                )
849                .await
850                .expect("facts_about with unknown subject succeeds");
851            let text = first_text(&r);
852            let v: serde_json::Value =
853                serde_json::from_str(&text).expect("parses as json");
854            assert_eq!(v.as_array().unwrap().len(), 0);
855        });
856        h.shutdown(&runtime);
857    }
858
859    #[test]
860    fn contradictions_returns_json_array_on_empty_db() {
861        let runtime = rt();
862        let h = Harness::new(&runtime);
863        runtime.block_on(async {
864            let r = h
865                .server
866                .dispatch_tool("memory.contradictions", json!({}))
867                .await
868                .expect("contradictions succeeds");
869            let text = first_text(&r);
870            let v: serde_json::Value =
871                serde_json::from_str(&text).expect("parses as json");
872            assert!(v.is_array());
873            assert_eq!(v.as_array().unwrap().len(), 0);
874        });
875        h.shutdown(&runtime);
876    }
877
878    #[test]
879    fn remember_then_recall_round_trip() {
880        let runtime = rt();
881        let h = Harness::new(&runtime);
882        // Use &h.server directly (no clone) so the only outstanding
883        // reference at shutdown time is the harness's own. The clone
884        // path triggered a 5-second writer-thread timeout because the
885        // local clone held an Arc<Inner> with its own WriteHandle past
886        // h.shutdown().
887        runtime.block_on(async {
888            let r = h
889                .server
890                .dispatch_tool("memory.remember", json!({ "content": "the cat sat on the mat" }))
891                .await
892                .expect("remember succeeds");
893            let text = first_text(&r);
894            assert!(text.starts_with("remembered "), "got: {text}");
895
896            let r = h
897                .server
898                .dispatch_tool(
899                    "memory.recall",
900                    json!({ "query": "the cat sat on the mat", "limit": 5 }),
901                )
902                .await
903                .expect("recall succeeds");
904            let text = first_text(&r);
905            assert!(text.contains("the cat sat on the mat"), "got: {text}");
906        });
907        h.shutdown(&runtime);
908    }
909
910    #[test]
911    fn forget_excludes_row_from_subsequent_recall() {
912        let runtime = rt();
913        let h = Harness::new(&runtime);
914
915        runtime.block_on(async {
916            let r = h
917                .server
918                .dispatch_tool("memory.remember", json!({ "content": "to be forgotten" }))
919                .await
920                .unwrap();
921            let text = first_text(&r);
922            let mid = text.strip_prefix("remembered ").unwrap().to_string();
923
924            h.server
925                .dispatch_tool(
926                    "memory.forget",
927                    json!({ "memory_id": mid, "reason": "test" }),
928                )
929                .await
930                .expect("forget succeeds");
931
932            let r = h
933                .server
934                .dispatch_tool(
935                    "memory.recall",
936                    json!({ "query": "to be forgotten", "limit": 5 }),
937                )
938                .await
939                .unwrap();
940            let text = first_text(&r);
941            assert!(
942                !text.contains(r#""content": "to be forgotten""#),
943                "forgotten row should be excluded; got: {text}"
944            );
945        });
946        h.shutdown(&runtime);
947    }
948
949    #[test]
950    fn empty_remember_returns_invalid_params() {
951        let runtime = rt();
952        let h = Harness::new(&runtime);
953        runtime.block_on(async {
954            let err = h
955                .server
956                .dispatch_tool("memory.remember", json!({ "content": "" }))
957                .await
958                .unwrap_err();
959            assert!(format!("{err:?}").contains("must not be empty"));
960        });
961        h.shutdown(&runtime);
962    }
963
964    #[test]
965    fn empty_recall_query_returns_invalid_params() {
966        let runtime = rt();
967        let h = Harness::new(&runtime);
968        runtime.block_on(async {
969            let err = h
970                .server
971                .dispatch_tool("memory.recall", json!({ "query": "   " }))
972                .await
973                .unwrap_err();
974            assert!(format!("{err:?}").contains("must not be empty"));
975        });
976        h.shutdown(&runtime);
977    }
978
979    #[test]
980    fn inspect_with_invalid_id_returns_invalid_params() {
981        let runtime = rt();
982        let h = Harness::new(&runtime);
983        runtime.block_on(async {
984            let err = h
985                .server
986                .dispatch_tool("memory.inspect", json!({ "memory_id": "not-a-uuid" }))
987                .await
988                .unwrap_err();
989            assert!(format!("{err:?}").contains("invalid memory_id"));
990        });
991        h.shutdown(&runtime);
992    }
993
994    #[test]
995    fn forget_unknown_id_returns_invalid_params() {
996        let runtime = rt();
997        let h = Harness::new(&runtime);
998        runtime.block_on(async {
999            // Valid UUID format but not in episodes — handle_forget
1000            // surfaces NotFound, mapped to invalid_params per
1001            // solo_to_mcp.
1002            let err = h
1003                .server
1004                .dispatch_tool(
1005                    "memory.forget",
1006                    json!({ "memory_id": "00000000-0000-7000-8000-000000000000" }),
1007                )
1008                .await
1009                .unwrap_err();
1010            assert!(format!("{err:?}").contains("not found"));
1011        });
1012        h.shutdown(&runtime);
1013    }
1014
1015    #[test]
1016    fn unknown_tool_name_returns_invalid_params() {
1017        let runtime = rt();
1018        let h = Harness::new(&runtime);
1019        runtime.block_on(async {
1020            let err = h
1021                .server
1022                .dispatch_tool("memory.summon", json!({}))
1023                .await
1024                .unwrap_err();
1025            assert!(format!("{err:?}").contains("unknown tool"));
1026        });
1027        h.shutdown(&runtime);
1028    }
1029}
1030
1031// fetch_recall_rows + RecallHit + RecallRow used to live here. Recall
1032// pipeline moved to solo_query::recall in commit (consolidate-recall);
1033// transports just call solo_query::run_recall and format the result.