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 eight 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//! ## Transport
27//!
28//! `serve_stdio` wires the server to stdin/stdout for use as a subprocess
29//! ("`claude_desktop_config.json` or `~/.cursor/mcp.json` invokes
30//! `solo mcp-stdio`"). The function awaits a graceful shutdown when stdin
31//! closes (parent disconnects) — same lifecycle as `solo daemon`'s
32//! Ctrl+C path.
33//!
34//! ## What's deferred
35//!
36//! - SSE/HTTP transports — `rmcp` ships them, but v0.1 ships stdio only.
37//! - `prompts/` and `resources/` capabilities — not needed for the
38//!   four-tool surface; ServerHandler defaults return empty lists.
39//! - Tool argument validation beyond JSON Schema typing — we trust rmcp
40//!   to deserialize per the schema, then serde-deserialize into our
41//!   typed param structs. Bad inputs surface as clear errors.
42
43use std::sync::Arc;
44
45use rmcp::handler::server::ServerHandler;
46use rmcp::model::{
47    CallToolRequestParam, CallToolResult, Content, Implementation, ListToolsResult,
48    PaginatedRequestParam, ProtocolVersion, ServerCapabilities, ServerInfo, Tool,
49    ToolsCapability,
50};
51use rmcp::service::{RequestContext, RoleServer};
52use rmcp::{Error as McpError, ServiceExt};
53use serde::{Deserialize, Serialize};
54use solo_core::{
55    Confidence, Embedder, EncodingContext, Episode, MemoryId, Tier,
56    VectorIndex,
57};
58use solo_storage::{ReaderPool, WriteHandle};
59use std::str::FromStr;
60
61/// The MCP server. Cheap to clone — every field is `Arc`-cloneable.
62#[derive(Clone)]
63pub struct SoloMcpServer {
64    inner: Arc<Inner>,
65}
66
67struct Inner {
68    write: WriteHandle,
69    pool: ReaderPool,
70    embedder: Arc<dyn Embedder>,
71    hnsw: Arc<dyn VectorIndex + Send + Sync>,
72    /// Read-path aliases for the canonical `"user"` subject. Sourced
73    /// from `solo.config.toml` `[identity] user_aliases`; threaded
74    /// through to `solo_query::facts_about` so a query for `"alex"`
75    /// also surfaces rows historically extracted as `"user"`. Empty
76    /// vec = behave as today (no expansion).
77    user_aliases: Vec<String>,
78}
79
80impl SoloMcpServer {
81    /// Build a server without identity-config aliases (back-compat
82    /// constructor + tests). Equivalent to `new_with_identity` with an
83    /// empty alias list.
84    pub fn new(
85        write: WriteHandle,
86        pool: ReaderPool,
87        embedder: Arc<dyn Embedder>,
88        hnsw: Arc<dyn VectorIndex + Send + Sync>,
89    ) -> Self {
90        Self::new_with_identity(write, pool, embedder, hnsw, Vec::new())
91    }
92
93    /// Build a server, threading in `user_aliases` so `memory_facts_about`
94    /// resolves the canonical `"user"` subject to + from each alias.
95    /// Sourced from `solo.config.toml` `[identity] user_aliases`.
96    pub fn new_with_identity(
97        write: WriteHandle,
98        pool: ReaderPool,
99        embedder: Arc<dyn Embedder>,
100        hnsw: Arc<dyn VectorIndex + Send + Sync>,
101        user_aliases: Vec<String>,
102    ) -> Self {
103        Self {
104            inner: Arc::new(Inner {
105                write,
106                pool,
107                embedder,
108                hnsw,
109                user_aliases,
110            }),
111        }
112    }
113}
114
115/// Convenience: run the server over stdio and await its termination.
116/// Returns when stdin closes (parent disconnect) or the runtime exits.
117pub async fn serve_stdio(server: SoloMcpServer) -> anyhow::Result<()> {
118    use rmcp::transport::io::stdio;
119    let (stdin, stdout) = stdio();
120    let running = server.serve((stdin, stdout)).await?;
121    running.waiting().await?;
122    Ok(())
123}
124
125// ---------------------------------------------------------------------------
126// Tool argument schemas
127// ---------------------------------------------------------------------------
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct RememberArgs {
131    pub content: String,
132    #[serde(default)]
133    pub source_type: Option<String>,
134    #[serde(default)]
135    pub source_id: Option<String>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct RecallArgs {
140    pub query: String,
141    #[serde(default = "default_limit")]
142    pub limit: usize,
143}
144
145fn default_limit() -> usize {
146    5
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct ForgetArgs {
151    pub memory_id: String,
152    #[serde(default = "default_forget_reason")]
153    pub reason: String,
154}
155
156fn default_forget_reason() -> String {
157    "user-initiated via MCP".into()
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct InspectArgs {
162    pub memory_id: String,
163}
164
165// Path 1 derived-layer tools (v0.4.0+) — query the Steward's outputs.
166// `solo_query::derived` is the single source of truth; these handlers
167// just translate JSON args to function args and serialise the result
168// vec to JSON for the MCP wire.
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct ThemesArgs {
172    /// Optional time window in days; `None` = unfiltered, return up
173    /// to `limit` most-recent themes across all time. `Some(7)` =
174    /// "themes from the last week".
175    #[serde(default)]
176    pub window_days: Option<i64>,
177    #[serde(default = "default_limit")]
178    pub limit: usize,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct FactsAboutArgs {
183    /// Subject id to query — required (predicate-only scans
184    /// intentionally not supported).
185    pub subject: String,
186    #[serde(default)]
187    pub predicate: Option<String>,
188    #[serde(default)]
189    pub since_ms: Option<i64>,
190    #[serde(default)]
191    pub until_ms: Option<i64>,
192    #[serde(default = "default_limit")]
193    pub limit: usize,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct ContradictionsArgs {
198    #[serde(default = "default_limit")]
199    pub limit: usize,
200}
201
202/// Args for `memory_inspect_cluster` (v0.5.0 Priority 3). `cluster_id`
203/// is required; `full_content` is opt-in for the rare power-user case
204/// where 200-char-per-episode truncation is too aggressive.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct InspectClusterArgs {
207    pub cluster_id: String,
208    /// If `true`, episode `content` fields are returned verbatim. If
209    /// `false` or omitted (the default), each episode's content is
210    /// truncated to `solo_query::EPISODE_TRUNCATE_CHARS` chars with a
211    /// trailing `…`.
212    #[serde(default)]
213    pub full_content: bool,
214}
215
216// ---------------------------------------------------------------------------
217// ServerHandler implementation
218// ---------------------------------------------------------------------------
219
220impl ServerHandler for SoloMcpServer {
221    fn get_info(&self) -> ServerInfo {
222        ServerInfo {
223            protocol_version: ProtocolVersion::default(),
224            capabilities: ServerCapabilities {
225                tools: Some(ToolsCapability {
226                    list_changed: Some(false),
227                }),
228                ..Default::default()
229            },
230            server_info: Implementation {
231                name: "solo".into(),
232                version: env!("CARGO_PKG_VERSION").into(),
233            },
234            instructions: Some(
235                "Solo gives you persistent memory across conversations \
236                 with this user — what they've told you before, the \
237                 people and projects in their life, and where their \
238                 stated beliefs have shifted. Reach for these tools \
239                 whenever the user references something from earlier \
240                 (\"like I mentioned\", \"the project I'm working \
241                 on\", \"my friend Alex\") or asks a question that \
242                 hinges on personal context you don't have in the \
243                 current chat. \
244                 \n\nTools to write or look up specific moments: \
245                 memory_remember (save something worth keeping), \
246                 memory_recall (search past conversations by topic), \
247                 memory_inspect (show one saved item by id), \
248                 memory_forget (delete one saved item). \
249                 \n\nTools for the bigger picture (populated as the \
250                 user uses Solo over time): memory_themes (recent \
251                 topics they've been thinking about), \
252                 memory_facts_about (what you know about a person, \
253                 project, or place — \"what do you know about \
254                 Alex?\"), memory_contradictions (places where the \
255                 user has said two things that disagree — surface \
256                 these before answering), memory_inspect_cluster \
257                 (the raw conversations behind one summary)."
258                    .into(),
259            ),
260        }
261    }
262
263    async fn list_tools(
264        &self,
265        _request: PaginatedRequestParam,
266        _context: RequestContext<RoleServer>,
267    ) -> std::result::Result<ListToolsResult, McpError> {
268        Ok(ListToolsResult {
269            tools: build_tools(),
270            next_cursor: None,
271        })
272    }
273
274    async fn call_tool(
275        &self,
276        request: CallToolRequestParam,
277        _context: RequestContext<RoleServer>,
278    ) -> std::result::Result<CallToolResult, McpError> {
279        let CallToolRequestParam { name, arguments } = request;
280        let args_value = serde_json::Value::Object(arguments.unwrap_or_default());
281        self.dispatch_tool(&name, args_value).await
282    }
283}
284
285impl SoloMcpServer {
286    /// Direct tool-dispatch path used by both `call_tool` (the
287    /// ServerHandler trait method, behind the rmcp protocol layer) and
288    /// in-process tests that don't want to spin up a full transport pair.
289    /// Bypasses `RequestContext` (which requires a `Peer` not constructible
290    /// outside rmcp internals).
291    pub async fn dispatch_tool(
292        &self,
293        name: &str,
294        args_value: serde_json::Value,
295    ) -> std::result::Result<CallToolResult, McpError> {
296        match name {
297            "memory_remember" => {
298                let args: RememberArgs = parse_args(&args_value)?;
299                self.handle_remember(args).await
300            }
301            "memory_recall" => {
302                let args: RecallArgs = parse_args(&args_value)?;
303                self.handle_recall(args).await
304            }
305            "memory_forget" => {
306                let args: ForgetArgs = parse_args(&args_value)?;
307                self.handle_forget(args).await
308            }
309            "memory_inspect" => {
310                let args: InspectArgs = parse_args(&args_value)?;
311                self.handle_inspect(args).await
312            }
313            "memory_themes" => {
314                let args: ThemesArgs = parse_args(&args_value)?;
315                self.handle_themes(args).await
316            }
317            "memory_facts_about" => {
318                let args: FactsAboutArgs = parse_args(&args_value)?;
319                self.handle_facts_about(args).await
320            }
321            "memory_contradictions" => {
322                let args: ContradictionsArgs = parse_args(&args_value)?;
323                self.handle_contradictions(args).await
324            }
325            "memory_inspect_cluster" => {
326                let args: InspectClusterArgs = parse_args(&args_value)?;
327                self.handle_inspect_cluster(args).await
328            }
329            other => Err(McpError::invalid_params(
330                format!("unknown tool `{other}`"),
331                None,
332            )),
333        }
334    }
335
336    /// List the tools this server exposes. Mirrors `ServerHandler::list_tools`
337    /// without requiring a RequestContext.
338    pub fn dispatch_list_tools(&self) -> Vec<Tool> {
339        build_tools()
340    }
341}
342
343fn parse_args<T: serde::de::DeserializeOwned>(
344    v: &serde_json::Value,
345) -> std::result::Result<T, McpError> {
346    serde_json::from_value(v.clone()).map_err(|e| {
347        McpError::invalid_params(format!("invalid tool arguments: {e}"), None)
348    })
349}
350
351fn solo_to_mcp(e: solo_core::Error) -> McpError {
352    use solo_core::Error;
353    match e {
354        Error::NotFound(msg) => McpError::invalid_params(msg, None),
355        Error::InvalidInput(msg) => McpError::invalid_params(msg, None),
356        Error::Conflict(msg) => McpError::invalid_params(msg, None),
357        other => McpError::internal_error(other.to_string(), None),
358    }
359}
360
361// ---------------------------------------------------------------------------
362// Tool definitions (JSON Schema)
363// ---------------------------------------------------------------------------
364
365fn build_tools() -> Vec<Tool> {
366    vec![
367        Tool::new(
368            "memory_remember",
369            "Save something the user has told you — a fact, a \
370             preference, a name, a date, a context — so you can pick \
371             it up next conversation. Use whenever the user mentions \
372             something they'd reasonably expect you to recall later \
373             (\"I just started at Quotient\", \"my partner is Maya\"). \
374             Returns the saved item's id.",
375            json_schema_object(serde_json::json!({
376                "type": "object",
377                "properties": {
378                    "content": {
379                        "type": "string",
380                        "description": "The text to remember.",
381                    },
382                    "source_type": {
383                        "type": "string",
384                        "description": "Optional source-type tag (default: \"user_message\").",
385                    },
386                    "source_id": {
387                        "type": "string",
388                        "description": "Optional upstream id for traceability.",
389                    },
390                },
391                "required": ["content"],
392            })),
393        ),
394        Tool::new(
395            "memory_recall",
396            "Search past conversations with this user by topic or \
397             phrase. Returns up to `limit` of the closest matches, \
398             best match first. Use when the user references \
399             something they said before (\"that book I told you \
400             about\", \"the bug we were debugging last week\"). \
401             Skips items the user has deleted.",
402            json_schema_object(serde_json::json!({
403                "type": "object",
404                "properties": {
405                    "query": {
406                        "type": "string",
407                        "description": "The query text.",
408                    },
409                    "limit": {
410                        "type": "integer",
411                        "description": "Maximum results (default 5).",
412                        "minimum": 1,
413                        "maximum": 100,
414                    },
415                },
416                "required": ["query"],
417            })),
418        ),
419        Tool::new(
420            "memory_forget",
421            "Delete one saved item by id. Use when the user asks you \
422             to forget something specific (\"forget that I said \
423             X\"). The item stops appearing in future recalls. \
424             Reversible only via backups.",
425            json_schema_object(serde_json::json!({
426                "type": "object",
427                "properties": {
428                    "memory_id": {
429                        "type": "string",
430                        "description": "MemoryId to forget (UUID v7).",
431                    },
432                    "reason": {
433                        "type": "string",
434                        "description": "Optional free-form reason (logged, not yet persisted).",
435                    },
436                },
437                "required": ["memory_id"],
438            })),
439        ),
440        Tool::new(
441            "memory_inspect",
442            "Show the full record for one saved item — when it was \
443             saved, where it came from, and the full text. Use after \
444             memory_recall when you want the complete content of a \
445             specific hit (recall results may be truncated).",
446            json_schema_object(serde_json::json!({
447                "type": "object",
448                "properties": {
449                    "memory_id": {
450                        "type": "string",
451                        "description": "MemoryId to inspect (UUID v7).",
452                    },
453                },
454                "required": ["memory_id"],
455            })),
456        ),
457        // Path 1 derived-layer tools (v0.4.0+) — query the Steward's
458        // outputs. These four are populated by `solo consolidate` and
459        // were previously unreadable except via direct SQL.
460        Tool::new(
461            "memory_themes",
462            "Recent topics the user has been thinking about. Use to \
463             orient yourself at the start of a conversation, or when \
464             the user asks \"what have I been up to\" / \"what was I \
465             working on last week\". Pass `window_days` to scope \
466             (e.g. 7 for last week); omit for all-time.",
467            json_schema_object(serde_json::json!({
468                "type": "object",
469                "properties": {
470                    "window_days": {
471                        "type": "integer",
472                        "description": "Optional time window in days. Omit for unfiltered.",
473                        "minimum": 1,
474                    },
475                    "limit": {
476                        "type": "integer",
477                        "description": "Maximum results (default 5).",
478                        "minimum": 1,
479                        "maximum": 100,
480                    },
481                },
482            })),
483        ),
484        Tool::new(
485            "memory_facts_about",
486            "Look up what you remember about a person, project, or \
487             topic — names, dates, preferences, relationships. Use \
488             when the user asks \"what do you know about Alex?\", \
489             \"when did I start at Quotient?\", \"who is Maya?\", or \
490             whenever you need grounded facts about someone or \
491             something before answering. Subject is required (the \
492             person/place/thing you're asking about); narrow further \
493             with `predicate` (\"works_at\", \"lives_in\") or a date \
494             range. (Backed by subject-predicate-object triples \
495             distilled from past conversations.)",
496            json_schema_object(serde_json::json!({
497                "type": "object",
498                "properties": {
499                    "subject": {
500                        "type": "string",
501                        "description": "Subject id to query (e.g. 'Sam').",
502                    },
503                    "predicate": {
504                        "type": "string",
505                        "description": "Optional predicate filter (e.g. 'works_at').",
506                    },
507                    "since_ms": {
508                        "type": "integer",
509                        "description": "Optional valid_from_ms lower bound (epoch ms).",
510                    },
511                    "until_ms": {
512                        "type": "integer",
513                        "description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through.",
514                    },
515                    "limit": {
516                        "type": "integer",
517                        "description": "Maximum results (default 5).",
518                        "minimum": 1,
519                        "maximum": 100,
520                    },
521                },
522                "required": ["subject"],
523            })),
524        ),
525        Tool::new(
526            "memory_contradictions",
527            "Find places where the user's stated beliefs or facts \
528             disagree across conversations — flag disagreements \
529             before answering. Use whenever you're about to rely on \
530             a remembered fact that could have changed (jobs, \
531             relationships, preferences, opinions); a disagreement \
532             here means the user has told you both X and not-X over \
533             time and you should ask which is current instead of \
534             guessing. Each result shows both conflicting statements \
535             with the topic.",
536            json_schema_object(serde_json::json!({
537                "type": "object",
538                "properties": {
539                    "limit": {
540                        "type": "integer",
541                        "description": "Maximum results (default 5).",
542                        "minimum": 1,
543                        "maximum": 100,
544                    },
545                },
546            })),
547        ),
548        Tool::new(
549            "memory_inspect_cluster",
550            "Show the raw conversations behind one summary. Returns \
551             the one-line topic (the LLM-generated summary) and the \
552             source conversations the topic was built from. Use \
553             after memory_themes when the user asks \"show me the \
554             raw context behind this\" or \"why does Solo think \
555             that about cluster Y\". Source items are truncated to \
556             200 chars unless `full_content` is set.",
557            json_schema_object(serde_json::json!({
558                "type": "object",
559                "properties": {
560                    "cluster_id": {
561                        "type": "string",
562                        "description": "Cluster id to inspect (from memory_themes hits).",
563                    },
564                    "full_content": {
565                        "type": "boolean",
566                        "description": "If true, episode content is returned verbatim. Default false (truncate to 200 chars + ellipsis).",
567                    },
568                },
569                "required": ["cluster_id"],
570            })),
571        ),
572    ]
573}
574
575fn json_schema_object(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
576    match value {
577        serde_json::Value::Object(map) => map,
578        _ => panic!("json_schema_object: input must be an object"),
579    }
580}
581
582/// Names of every tool this server exposes, in registration order.
583///
584/// Exposed for cross-crate consumers (notably `solo doctor
585/// --check-mcp-compat`) that want the name list without paying the
586/// cost of building full `rmcp::Tool` records (which allocate JSON
587/// schemas). The registration order matches `build_tools()` so any
588/// drift between the two would be caught by the cross-provider regex
589/// test which iterates `build_tools()`.
590pub fn tool_names() -> Vec<&'static str> {
591    vec![
592        "memory_remember",
593        "memory_recall",
594        "memory_forget",
595        "memory_inspect",
596        "memory_themes",
597        "memory_facts_about",
598        "memory_contradictions",
599        "memory_inspect_cluster",
600    ]
601}
602
603// ---------------------------------------------------------------------------
604// Tool handlers
605// ---------------------------------------------------------------------------
606
607impl SoloMcpServer {
608    async fn handle_remember(
609        &self,
610        args: RememberArgs,
611    ) -> std::result::Result<CallToolResult, McpError> {
612        let content = args.content.trim_end().to_string();
613        if content.is_empty() {
614            return Err(McpError::invalid_params(
615                "memory_remember: content must not be empty".to_string(),
616                None,
617            ));
618        }
619        let embedding: solo_core::Embedding = self
620            .inner
621            .embedder
622            .embed(&content)
623            .await
624            .map_err(solo_to_mcp)?;
625        let episode = Episode {
626            memory_id: MemoryId::new(),
627            ts_ms: chrono::Utc::now().timestamp_millis(),
628            source_type: args.source_type.unwrap_or_else(|| "user_message".into()),
629            source_id: args.source_id,
630            content,
631            encoding_context: EncodingContext::default(),
632            provenance: None,
633            confidence: Confidence::new(0.9).unwrap(),
634            strength: 0.5,
635            salience: 0.5,
636            tier: Tier::Hot,
637        };
638        let mid = self
639            .inner
640            .write
641            .remember(episode, embedding)
642            .await
643            .map_err(solo_to_mcp)?;
644        Ok(CallToolResult::success(vec![Content::text(format!(
645            "remembered {mid}"
646        ))]))
647    }
648
649    async fn handle_recall(
650        &self,
651        args: RecallArgs,
652    ) -> std::result::Result<CallToolResult, McpError> {
653        // Pipeline lives in solo-query; the transport just formats the
654        // result. solo_query::run_recall validates empty queries
655        // (returns InvalidInput → invalid_params via solo_to_mcp).
656        let result = solo_query::run_recall(
657            &self.inner.embedder,
658            &self.inner.hnsw,
659            &self.inner.pool,
660            &args.query,
661            args.limit,
662        )
663        .await
664        .map_err(solo_to_mcp)?;
665
666        if result.hits.is_empty() {
667            return Ok(CallToolResult::success(vec![Content::text(format!(
668                "no matches (index has {} vectors)",
669                result.index_len
670            ))]));
671        }
672        let body = serde_json::to_string_pretty(&result.hits).unwrap_or_else(|_| String::new());
673        Ok(CallToolResult::success(vec![Content::text(body)]))
674    }
675
676    async fn handle_forget(
677        &self,
678        args: ForgetArgs,
679    ) -> std::result::Result<CallToolResult, McpError> {
680        let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
681            McpError::invalid_params(format!("invalid memory_id: {e}"), None)
682        })?;
683        self.inner
684            .write
685            .forget(mid, args.reason)
686            .await
687            .map_err(solo_to_mcp)?;
688        Ok(CallToolResult::success(vec![Content::text(format!(
689            "forgotten {mid}"
690        ))]))
691    }
692
693    async fn handle_inspect(
694        &self,
695        args: InspectArgs,
696    ) -> std::result::Result<CallToolResult, McpError> {
697        let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
698            McpError::invalid_params(format!("invalid memory_id: {e}"), None)
699        })?;
700        // Pipeline lives in solo-query::inspect; transports just format.
701        let row = solo_query::inspect_one(&self.inner.pool, mid)
702            .await
703            .map_err(solo_to_mcp)?;
704        let body = serde_json::to_string_pretty(&row).unwrap_or_else(|_| String::new());
705        Ok(CallToolResult::success(vec![Content::text(body)]))
706    }
707
708    // Path 1 derived-layer handlers (v0.4.0+). Each one delegates to a
709    // single solo-query::derived pipeline and serialises the result Vec
710    // to pretty JSON for the MCP wire. Empty result → JSON empty array
711    // `[]` (not a special-case "no matches" string) so MCP clients can
712    // parse uniformly.
713
714    async fn handle_themes(
715        &self,
716        args: ThemesArgs,
717    ) -> std::result::Result<CallToolResult, McpError> {
718        let hits = solo_query::themes(
719            &self.inner.pool,
720            args.window_days,
721            args.limit,
722        )
723        .await
724        .map_err(solo_to_mcp)?;
725        let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
726        Ok(CallToolResult::success(vec![Content::text(body)]))
727    }
728
729    async fn handle_facts_about(
730        &self,
731        args: FactsAboutArgs,
732    ) -> std::result::Result<CallToolResult, McpError> {
733        if args.subject.trim().is_empty() {
734            return Err(McpError::invalid_params(
735                "memory_facts_about: subject must not be empty".to_string(),
736                None,
737            ));
738        }
739        let hits = solo_query::facts_about(
740            &self.inner.pool,
741            &args.subject,
742            &self.inner.user_aliases,
743            args.predicate.as_deref(),
744            args.since_ms,
745            args.until_ms,
746            args.limit,
747        )
748        .await
749        .map_err(solo_to_mcp)?;
750        let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
751        Ok(CallToolResult::success(vec![Content::text(body)]))
752    }
753
754    async fn handle_contradictions(
755        &self,
756        args: ContradictionsArgs,
757    ) -> std::result::Result<CallToolResult, McpError> {
758        let hits = solo_query::contradictions(&self.inner.pool, args.limit)
759            .await
760            .map_err(solo_to_mcp)?;
761        let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
762        Ok(CallToolResult::success(vec![Content::text(body)]))
763    }
764
765    async fn handle_inspect_cluster(
766        &self,
767        args: InspectClusterArgs,
768    ) -> std::result::Result<CallToolResult, McpError> {
769        if args.cluster_id.trim().is_empty() {
770            return Err(McpError::invalid_params(
771                "memory_inspect_cluster: cluster_id must not be empty".to_string(),
772                None,
773            ));
774        }
775        // `solo_to_mcp` maps `Error::NotFound` → `invalid_params` for
776        // MCP (the protocol does not have a separate "not found" error
777        // shape; clients see the message verbatim, which includes the
778        // cluster_id).
779        let record = solo_query::inspect_cluster(
780            &self.inner.pool,
781            &args.cluster_id,
782            args.full_content,
783        )
784        .await
785        .map_err(solo_to_mcp)?;
786        let body = serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
787        Ok(CallToolResult::success(vec![Content::text(body)]))
788    }
789}
790
791#[cfg(test)]
792mod dispatch_tests {
793    //! In-process integration tests for the MCP tool surface. We invoke
794    //! `SoloMcpServer::dispatch_tool` directly (bypasses the rmcp
795    //! protocol framing + `RequestContext`, which requires a `Peer`
796    //! that's not constructible outside rmcp internals). The server is
797    //! constructed against a real WriterActor + ReaderPool +
798    //! StubEmbedder + StubVectorIndex from `solo_storage::test_support`.
799    //!
800    //! Tests live inline in this module rather than `tests/` because an
801    //! external integration-test exe in `target/debug/deps/mcp_dispatch-*`
802    //! tripped Windows UAC ERROR_ELEVATION_REQUIRED on the dev machine.
803    //! The lib test binary doesn't have that issue.
804    use super::*;
805    use serde_json::json;
806    use solo_core::VectorIndex;
807    use solo_storage::test_support::StubVectorIndex;
808    use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
809    use std::sync::Arc as StdArc;
810
811    struct Harness {
812        server: SoloMcpServer,
813        _tmp: tempfile::TempDir,
814        write_handle_extra: Option<solo_storage::WriteHandle>,
815        join: Option<std::thread::JoinHandle<()>>,
816    }
817
818    impl Harness {
819        fn new(runtime: &tokio::runtime::Runtime) -> Self {
820            let tmp = tempfile::TempDir::new().unwrap();
821            let dim = 16usize;
822            let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
823            let embedder: StdArc<dyn solo_core::Embedder> = StdArc::new(StubEmbedder::new("stub", "v1", dim));
824
825            let conn = solo_storage::test_support::open_test_db_at(&tmp.path().join("test.db"));
826            let WriterSpawn { handle, join } = WriterActor::spawn(conn, hnsw.clone());
827
828            // ReaderPool's deadpool::Pool needs a live tokio runtime for
829            // both build + drop; build inside block_on.
830            let path = tmp.path().join("test.db");
831            let pool: ReaderPool =
832                runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
833
834            let server = SoloMcpServer::new(handle.clone(), pool, embedder, hnsw);
835            Harness {
836                server,
837                _tmp: tmp,
838                write_handle_extra: Some(handle),
839                join: Some(join),
840            }
841        }
842
843        fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
844            // The whole shutdown runs inside block_on so deadpool-sqlite's
845            // drop (which schedules cleanup on the active runtime) sees a
846            // live reactor. Without this, dropping the SoloMcpServer
847            // (which holds the ReaderPool through its Arc<Inner>) panics
848            // with "no reactor running".
849            let join = self.join.take();
850            let extra = self.write_handle_extra.take();
851            runtime.block_on(async move {
852                drop(extra);
853                drop(self.server);
854                drop(self._tmp);
855                if let Some(join) = join {
856                    let (tx, rx) = std::sync::mpsc::channel();
857                    std::thread::spawn(move || {
858                        let _ = tx.send(join.join());
859                    });
860                    tokio::task::spawn_blocking(move || {
861                        rx.recv_timeout(std::time::Duration::from_secs(5))
862                    })
863                    .await
864                    .expect("blocking task")
865                    .expect("writer thread did not exit within 5s")
866                    .expect("writer thread panicked");
867                }
868            });
869        }
870    }
871
872    fn rt() -> tokio::runtime::Runtime {
873        tokio::runtime::Builder::new_multi_thread()
874            .worker_threads(2)
875            .enable_all()
876            .build()
877            .unwrap()
878    }
879
880    /// Pull the first Content::text body out of a CallToolResult. Use
881    /// serde_json roundtrip as a robust extractor — `Content`'s public
882    /// API doesn't directly expose the inner text without going through
883    /// pattern-matching on RawContent.
884    fn first_text(r: &rmcp::model::CallToolResult) -> String {
885        let first = r.content.first().expect("at least one content item");
886        let v = serde_json::to_value(first).expect("content serialises");
887        v.get("text")
888            .and_then(|t| t.as_str())
889            .map(|s| s.to_string())
890            .unwrap_or_else(|| format!("{v}"))
891    }
892
893    #[test]
894    fn tools_list_returns_eight_canonical_tools() {
895        let runtime = rt();
896        let h = Harness::new(&runtime);
897        let tools = h.server.dispatch_list_tools();
898        let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
899        assert_eq!(
900            names,
901            vec![
902                "memory_remember",
903                "memory_recall",
904                "memory_forget",
905                "memory_inspect",
906                // Derived-layer tools added in v0.4.0:
907                "memory_themes",
908                "memory_facts_about",
909                "memory_contradictions",
910                // Added in v0.5.0 (Priority 3):
911                "memory_inspect_cluster",
912            ]
913        );
914        for t in &tools {
915            assert!(!t.description.is_empty(), "{} description empty", t.name);
916            let _schema = t.schema_as_json_value();
917            // `required` is intentionally absent on memory_themes +
918            // memory_contradictions (all args optional with defaults).
919            // memory_facts_about does have required = ["subject"].
920            // We don't assert per-tool 'required' shape here; the
921            // schema's `properties` field is the more important
922            // signal and is always present.
923        }
924        h.shutdown(&runtime);
925    }
926
927    #[test]
928    fn themes_returns_json_array_on_empty_db() {
929        let runtime = rt();
930        let h = Harness::new(&runtime);
931        runtime.block_on(async {
932            let r = h
933                .server
934                .dispatch_tool("memory_themes", json!({}))
935                .await
936                .expect("themes succeeds");
937            let text = first_text(&r);
938            // Empty derived layer → empty array JSON. Parses cleanly.
939            let v: serde_json::Value =
940                serde_json::from_str(&text).expect("parses as json");
941            assert!(v.is_array(), "expected array, got: {text}");
942            assert_eq!(v.as_array().unwrap().len(), 0);
943        });
944        h.shutdown(&runtime);
945    }
946
947    #[test]
948    fn themes_passes_through_window_and_limit_args() {
949        let runtime = rt();
950        let h = Harness::new(&runtime);
951        runtime.block_on(async {
952            // Should not crash with optional + integer args present.
953            let r = h
954                .server
955                .dispatch_tool(
956                    "memory_themes",
957                    json!({ "window_days": 7, "limit": 20 }),
958                )
959                .await
960                .expect("themes with args succeeds");
961            let text = first_text(&r);
962            let v: serde_json::Value =
963                serde_json::from_str(&text).expect("parses as json");
964            assert!(v.is_array());
965        });
966        h.shutdown(&runtime);
967    }
968
969    #[test]
970    fn facts_about_rejects_empty_subject() {
971        let runtime = rt();
972        let h = Harness::new(&runtime);
973        runtime.block_on(async {
974            let err = h
975                .server
976                .dispatch_tool(
977                    "memory_facts_about",
978                    json!({ "subject": "   " }),
979                )
980                .await
981                .expect_err("empty subject must error");
982            // McpError doesn't expose a clean kind/message accessor; just
983            // verify the error fires (validation path reached).
984            let s = format!("{err:?}");
985            assert!(
986                s.to_lowercase().contains("subject")
987                    || s.to_lowercase().contains("invalid"),
988                "got: {s}"
989            );
990        });
991        h.shutdown(&runtime);
992    }
993
994    #[test]
995    fn facts_about_returns_array_for_unknown_subject() {
996        let runtime = rt();
997        let h = Harness::new(&runtime);
998        runtime.block_on(async {
999            let r = h
1000                .server
1001                .dispatch_tool(
1002                    "memory_facts_about",
1003                    json!({ "subject": "NobodyKnowsThisSubject" }),
1004                )
1005                .await
1006                .expect("facts_about with unknown subject succeeds");
1007            let text = first_text(&r);
1008            let v: serde_json::Value =
1009                serde_json::from_str(&text).expect("parses as json");
1010            assert_eq!(v.as_array().unwrap().len(), 0);
1011        });
1012        h.shutdown(&runtime);
1013    }
1014
1015    #[test]
1016    fn contradictions_returns_json_array_on_empty_db() {
1017        let runtime = rt();
1018        let h = Harness::new(&runtime);
1019        runtime.block_on(async {
1020            let r = h
1021                .server
1022                .dispatch_tool("memory_contradictions", json!({}))
1023                .await
1024                .expect("contradictions succeeds");
1025            let text = first_text(&r);
1026            let v: serde_json::Value =
1027                serde_json::from_str(&text).expect("parses as json");
1028            assert!(v.is_array());
1029            assert_eq!(v.as_array().unwrap().len(), 0);
1030        });
1031        h.shutdown(&runtime);
1032    }
1033
1034    #[test]
1035    fn remember_then_recall_round_trip() {
1036        let runtime = rt();
1037        let h = Harness::new(&runtime);
1038        // Use &h.server directly (no clone) so the only outstanding
1039        // reference at shutdown time is the harness's own. The clone
1040        // path triggered a 5-second writer-thread timeout because the
1041        // local clone held an Arc<Inner> with its own WriteHandle past
1042        // h.shutdown().
1043        runtime.block_on(async {
1044            let r = h
1045                .server
1046                .dispatch_tool("memory_remember", json!({ "content": "the cat sat on the mat" }))
1047                .await
1048                .expect("remember succeeds");
1049            let text = first_text(&r);
1050            assert!(text.starts_with("remembered "), "got: {text}");
1051
1052            let r = h
1053                .server
1054                .dispatch_tool(
1055                    "memory_recall",
1056                    json!({ "query": "the cat sat on the mat", "limit": 5 }),
1057                )
1058                .await
1059                .expect("recall succeeds");
1060            let text = first_text(&r);
1061            assert!(text.contains("the cat sat on the mat"), "got: {text}");
1062        });
1063        h.shutdown(&runtime);
1064    }
1065
1066    #[test]
1067    fn forget_excludes_row_from_subsequent_recall() {
1068        let runtime = rt();
1069        let h = Harness::new(&runtime);
1070
1071        runtime.block_on(async {
1072            let r = h
1073                .server
1074                .dispatch_tool("memory_remember", json!({ "content": "to be forgotten" }))
1075                .await
1076                .unwrap();
1077            let text = first_text(&r);
1078            let mid = text.strip_prefix("remembered ").unwrap().to_string();
1079
1080            h.server
1081                .dispatch_tool(
1082                    "memory_forget",
1083                    json!({ "memory_id": mid, "reason": "test" }),
1084                )
1085                .await
1086                .expect("forget succeeds");
1087
1088            let r = h
1089                .server
1090                .dispatch_tool(
1091                    "memory_recall",
1092                    json!({ "query": "to be forgotten", "limit": 5 }),
1093                )
1094                .await
1095                .unwrap();
1096            let text = first_text(&r);
1097            assert!(
1098                !text.contains(r#""content": "to be forgotten""#),
1099                "forgotten row should be excluded; got: {text}"
1100            );
1101        });
1102        h.shutdown(&runtime);
1103    }
1104
1105    #[test]
1106    fn empty_remember_returns_invalid_params() {
1107        let runtime = rt();
1108        let h = Harness::new(&runtime);
1109        runtime.block_on(async {
1110            let err = h
1111                .server
1112                .dispatch_tool("memory_remember", json!({ "content": "" }))
1113                .await
1114                .unwrap_err();
1115            assert!(format!("{err:?}").contains("must not be empty"));
1116        });
1117        h.shutdown(&runtime);
1118    }
1119
1120    #[test]
1121    fn empty_recall_query_returns_invalid_params() {
1122        let runtime = rt();
1123        let h = Harness::new(&runtime);
1124        runtime.block_on(async {
1125            let err = h
1126                .server
1127                .dispatch_tool("memory_recall", json!({ "query": "   " }))
1128                .await
1129                .unwrap_err();
1130            assert!(format!("{err:?}").contains("must not be empty"));
1131        });
1132        h.shutdown(&runtime);
1133    }
1134
1135    #[test]
1136    fn inspect_with_invalid_id_returns_invalid_params() {
1137        let runtime = rt();
1138        let h = Harness::new(&runtime);
1139        runtime.block_on(async {
1140            let err = h
1141                .server
1142                .dispatch_tool("memory_inspect", json!({ "memory_id": "not-a-uuid" }))
1143                .await
1144                .unwrap_err();
1145            assert!(format!("{err:?}").contains("invalid memory_id"));
1146        });
1147        h.shutdown(&runtime);
1148    }
1149
1150    #[test]
1151    fn forget_unknown_id_returns_invalid_params() {
1152        let runtime = rt();
1153        let h = Harness::new(&runtime);
1154        runtime.block_on(async {
1155            // Valid UUID format but not in episodes — handle_forget
1156            // surfaces NotFound, mapped to invalid_params per
1157            // solo_to_mcp.
1158            let err = h
1159                .server
1160                .dispatch_tool(
1161                    "memory_forget",
1162                    json!({ "memory_id": "00000000-0000-7000-8000-000000000000" }),
1163                )
1164                .await
1165                .unwrap_err();
1166            assert!(format!("{err:?}").contains("not found"));
1167        });
1168        h.shutdown(&runtime);
1169    }
1170
1171    #[test]
1172    fn unknown_tool_name_returns_invalid_params() {
1173        let runtime = rt();
1174        let h = Harness::new(&runtime);
1175        runtime.block_on(async {
1176            let err = h
1177                .server
1178                .dispatch_tool("memory.summon", json!({}))
1179                .await
1180                .unwrap_err();
1181            assert!(format!("{err:?}").contains("unknown tool"));
1182        });
1183        h.shutdown(&runtime);
1184    }
1185
1186    /// Regression guard for v0.4.1's MCP tool name fix, generalised
1187    /// in v0.5.0 Priority 4 to cover **all three** major LLM
1188    /// providers, not just Anthropic.
1189    ///
1190    /// Each provider enforces its own tool-name regex on the
1191    /// function-calling wire. A tool name has to satisfy ALL of them
1192    /// to be portable across clients:
1193    ///
1194    ///   - **Anthropic**: `^[a-zA-Z0-9_-]{1,64}$` (what shipped in
1195    ///     v0.4.1; failing this rejects the entire toolset on Claude
1196    ///     Desktop / Cursor / Claude Code with
1197    ///     `FrontendRemoteMcpToolDefinition.name: String should
1198    ///     match pattern ...`).
1199    ///   - **OpenAI** function-calling: `^[a-zA-Z_][a-zA-Z0-9_-]*$`
1200    ///     with length ≤ 64 (must start with letter or underscore).
1201    ///   - **Gemini** function-calling: documented as a-z, A-Z, 0-9,
1202    ///     underscores and dashes; some sources also allow dots. We
1203    ///     use the conservative intersection — must start with
1204    ///     letter or underscore, alphanumeric + underscore only (no
1205    ///     hyphen, no dot), length ≤ 63. This is the strictest of
1206    ///     the three patterns, so any tool that passes it also
1207    ///     passes the other two. Sources differ on whether Gemini
1208    ///     accepts dots or hyphens; the strictest reading guards us
1209    ///     against the future where one provider tightens the regex
1210    ///     (which is the failure mode v0.4.1 hit on Anthropic). See
1211    ///     <https://github.com/google-gemini/deprecated-generative-ai-python/blob/main/docs/api/google/generativeai/protos/FunctionDeclaration.md>
1212    ///     and <https://ai.google.dev/gemini-api/docs/function-calling>.
1213    ///
1214    /// Lesson banked v0.3 #8: rmcp framing tests pass dot-named
1215    /// tools fine because rmcp's own client-side validation is
1216    /// permissive. Only the downstream provider API enforces the
1217    /// regex. This test gates the names at `cargo test` time so any
1218    /// future tool-name change has to pass all three provider
1219    /// regexes before reaching real clients.
1220    #[test]
1221    fn tool_names_match_cross_provider_regex() {
1222        /// Anthropic API name regex: `^[a-zA-Z0-9_-]{1,64}$`.
1223        fn passes_anthropic(name: &str) -> bool {
1224            let len = name.len();
1225            if !(1..=64).contains(&len) {
1226                return false;
1227            }
1228            name.chars()
1229                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1230        }
1231
1232        /// OpenAI function-calling name regex:
1233        /// `^[a-zA-Z_][a-zA-Z0-9_-]*$`, length ≤ 64.
1234        fn passes_openai(name: &str) -> bool {
1235            let len = name.len();
1236            if !(1..=64).contains(&len) {
1237                return false;
1238            }
1239            let mut chars = name.chars();
1240            let first = match chars.next() {
1241                Some(c) => c,
1242                None => return false,
1243            };
1244            if !(first.is_ascii_alphabetic() || first == '_') {
1245                return false;
1246            }
1247            chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1248        }
1249
1250        /// Gemini function-calling name regex (conservative
1251        /// reading): `^[a-zA-Z_][a-zA-Z0-9_]*$`, length ≤ 63. No
1252        /// hyphen, no dot — strictest of the three so any name that
1253        /// passes this passes the other two.
1254        fn passes_gemini(name: &str) -> bool {
1255            let len = name.len();
1256            if !(1..=63).contains(&len) {
1257                return false;
1258            }
1259            let mut chars = name.chars();
1260            let first = match chars.next() {
1261                Some(c) => c,
1262                None => return false,
1263            };
1264            if !(first.is_ascii_alphabetic() || first == '_') {
1265                return false;
1266            }
1267            chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1268        }
1269
1270        let tools = build_tools();
1271        assert_eq!(
1272            tools.len(),
1273            8,
1274            "expected 8 tools in v0.5.0 (7 v0.4.x + memory_inspect_cluster)"
1275        );
1276        // Sanity-check that tool_names() agrees with build_tools().
1277        let tool_name_strings: Vec<String> =
1278            tools.iter().map(|t| t.name.to_string()).collect();
1279        let public_names: Vec<String> =
1280            super::tool_names().iter().map(|s| s.to_string()).collect();
1281        assert_eq!(
1282            tool_name_strings, public_names,
1283            "tool_names() drifted from build_tools() — keep them in sync"
1284        );
1285
1286        for t in tools {
1287            assert!(
1288                passes_anthropic(&t.name),
1289                "tool name {:?} fails Anthropic regex \
1290                 ^[a-zA-Z0-9_-]{{1,64}}$ — see v0.3 lesson #8",
1291                t.name
1292            );
1293            assert!(
1294                passes_openai(&t.name),
1295                "tool name {:?} fails OpenAI function-calling regex \
1296                 ^[a-zA-Z_][a-zA-Z0-9_-]*$ (len ≤ 64)",
1297                t.name
1298            );
1299            assert!(
1300                passes_gemini(&t.name),
1301                "tool name {:?} fails Gemini function-calling regex \
1302                 ^[a-zA-Z_][a-zA-Z0-9_]*$ (len ≤ 63, strict)",
1303                t.name
1304            );
1305        }
1306    }
1307
1308    /// Regression guard for the v0.5.0 Priority 4 jargon pass.
1309    ///
1310    /// Tool descriptions and `get_info().instructions` are the first
1311    /// (and often only) thing a calling LLM reads when its
1312    /// tool-search mechanism decides whether Solo's tools are
1313    /// relevant. Earlier descriptions leaned on Solo-internal
1314    /// vocabulary (`SPO`, `Steward`, `LEFT JOIN`, `candidate pair`,
1315    /// `tagged_with`) which doesn't pattern-match natural-language
1316    /// agent queries like "what do you know about Alex?" — that's
1317    /// the load-bearing v0.5.0 finding from the 2026-05-14
1318    /// thesis-test in Claude Desktop.
1319    ///
1320    /// This test pins the de-jargoning by forbidding the old
1321    /// vocabulary from appearing in any user-facing text. Future
1322    /// contributors who reach for jargon trip the test and have to
1323    /// pick plain-English phrasing instead.
1324    #[test]
1325    fn tool_descriptions_avoid_internal_jargon() {
1326        // Case-insensitive substring match. Drawn from the
1327        // pre-Priority-4 descriptions; expand only if a new term
1328        // creeps in.
1329        const FORBIDDEN: &[&str] = &[
1330            "SPO",
1331            "Steward",
1332            "Steward-flagged",
1333            "LEFT JOIN",
1334            "candidate pair",
1335            "candidate_pair",
1336            "tagged_with",
1337        ];
1338
1339        fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
1340            haystack.to_lowercase().contains(&needle.to_lowercase())
1341        }
1342
1343        // 1. Each tool description.
1344        for t in build_tools() {
1345            for term in FORBIDDEN {
1346                assert!(
1347                    !contains_case_insensitive(&t.description, term),
1348                    "tool {:?} description contains forbidden jargon \
1349                     {:?} — rewrite in plain English (see v0.5.0 \
1350                     Priority 4)",
1351                    t.name,
1352                    term,
1353                );
1354            }
1355        }
1356
1357        // 2. The server-level instructions (what tool-search sees
1358        // first).
1359        let server_info = harness_server_info();
1360        let instructions = server_info
1361            .instructions
1362            .as_deref()
1363            .expect("get_info() must set instructions");
1364        for term in FORBIDDEN {
1365            assert!(
1366                !contains_case_insensitive(instructions, term),
1367                "get_info().instructions contains forbidden jargon \
1368                 {:?} — rewrite in plain English",
1369                term,
1370            );
1371        }
1372    }
1373
1374    /// Build a `ServerInfo` for the jargon test without spinning up
1375    /// the full harness (which needs tokio + tempdir). The
1376    /// `ServerHandler::get_info()` method doesn't take `&self` state
1377    /// in any meaningful way for our impl — it returns a static
1378    /// `ServerInfo` literal — so we construct a minimal-input server
1379    /// just to call it.
1380    fn harness_server_info() -> rmcp::model::ServerInfo {
1381        let runtime = rt();
1382        let h = Harness::new(&runtime);
1383        let info = ServerHandler::get_info(&h.server);
1384        h.shutdown(&runtime);
1385        info
1386    }
1387
1388    // ---- memory_inspect_cluster (v0.5.0 Priority 3) ----
1389
1390    #[test]
1391    fn inspect_cluster_unknown_id_returns_invalid_params() {
1392        // NotFound from solo_query::inspect_cluster is mapped through
1393        // `solo_to_mcp` to `invalid_params` (MCP has no separate
1394        // not-found error shape). Error message should name the id.
1395        let runtime = rt();
1396        let h = Harness::new(&runtime);
1397        runtime.block_on(async {
1398            let err = h
1399                .server
1400                .dispatch_tool(
1401                    "memory_inspect_cluster",
1402                    json!({ "cluster_id": "no-such-cluster" }),
1403                )
1404                .await
1405                .expect_err("unknown cluster must error");
1406            let s = format!("{err:?}");
1407            assert!(
1408                s.contains("no-such-cluster") || s.to_lowercase().contains("not found"),
1409                "expected error to mention the missing cluster id; got: {s}"
1410            );
1411        });
1412        h.shutdown(&runtime);
1413    }
1414
1415    #[test]
1416    fn inspect_cluster_rejects_empty_id() {
1417        let runtime = rt();
1418        let h = Harness::new(&runtime);
1419        runtime.block_on(async {
1420            let err = h
1421                .server
1422                .dispatch_tool(
1423                    "memory_inspect_cluster",
1424                    json!({ "cluster_id": "   " }),
1425                )
1426                .await
1427                .expect_err("blank cluster_id must error");
1428            let s = format!("{err:?}");
1429            assert!(
1430                s.to_lowercase().contains("cluster_id")
1431                    || s.to_lowercase().contains("must not be empty"),
1432                "got: {s}"
1433            );
1434        });
1435        h.shutdown(&runtime);
1436    }
1437}
1438
1439// fetch_recall_rows + RecallHit + RecallRow used to live here. Recall
1440// pipeline moved to solo_query::recall in commit (consolidate-recall);
1441// transports just call solo_query::run_recall and format the result.