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