1use std::sync::Arc;
56
57use rmcp::handler::server::ServerHandler;
58use rmcp::model::{
59 CallToolRequestParam, CallToolResult, Content, Implementation, ListToolsResult,
60 PaginatedRequestParam, ProtocolVersion, ServerCapabilities, ServerInfo, Tool,
61 ToolsCapability,
62};
63use rmcp::service::{RequestContext, RoleServer};
64use rmcp::{Error as McpError, ServiceExt};
65use serde::{Deserialize, Serialize};
66use solo_core::{
67 Confidence, DocumentId, Embedder, EncodingContext, Episode, MemoryId, Tier,
68 VectorIndex,
69};
70use solo_storage::{ReaderPool, WriteHandle};
71use std::str::FromStr;
72
73#[derive(Clone)]
75pub struct SoloMcpServer {
76 inner: Arc<Inner>,
77}
78
79struct Inner {
80 write: WriteHandle,
81 pool: ReaderPool,
82 embedder: Arc<dyn Embedder>,
83 hnsw: Arc<dyn VectorIndex + Send + Sync>,
84 user_aliases: Vec<String>,
90}
91
92impl SoloMcpServer {
93 pub fn new(
97 write: WriteHandle,
98 pool: ReaderPool,
99 embedder: Arc<dyn Embedder>,
100 hnsw: Arc<dyn VectorIndex + Send + Sync>,
101 ) -> Self {
102 Self::new_with_identity(write, pool, embedder, hnsw, Vec::new())
103 }
104
105 pub fn new_with_identity(
109 write: WriteHandle,
110 pool: ReaderPool,
111 embedder: Arc<dyn Embedder>,
112 hnsw: Arc<dyn VectorIndex + Send + Sync>,
113 user_aliases: Vec<String>,
114 ) -> Self {
115 Self {
116 inner: Arc::new(Inner {
117 write,
118 pool,
119 embedder,
120 hnsw,
121 user_aliases,
122 }),
123 }
124 }
125}
126
127pub async fn serve_stdio(server: SoloMcpServer) -> anyhow::Result<()> {
130 use rmcp::transport::io::stdio;
131 let (stdin, stdout) = stdio();
132 let running = server.serve((stdin, stdout)).await?;
133 running.waiting().await?;
134 Ok(())
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct RememberArgs {
143 pub content: String,
144 #[serde(default)]
145 pub source_type: Option<String>,
146 #[serde(default)]
147 pub source_id: Option<String>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct RecallArgs {
152 pub query: String,
153 #[serde(default = "default_limit")]
154 pub limit: usize,
155}
156
157fn default_limit() -> usize {
158 5
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct ForgetArgs {
163 pub memory_id: String,
164 #[serde(default = "default_forget_reason")]
165 pub reason: String,
166}
167
168fn default_forget_reason() -> String {
169 "user-initiated via MCP".into()
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct InspectArgs {
174 pub memory_id: String,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct ThemesArgs {
184 #[serde(default)]
188 pub window_days: Option<i64>,
189 #[serde(default = "default_limit")]
190 pub limit: usize,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct FactsAboutArgs {
195 pub subject: String,
198 #[serde(default)]
199 pub predicate: Option<String>,
200 #[serde(default)]
201 pub since_ms: Option<i64>,
202 #[serde(default)]
203 pub until_ms: Option<i64>,
204 #[serde(default)]
209 pub include_as_object: bool,
210 #[serde(default = "default_limit")]
211 pub limit: usize,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct ContradictionsArgs {
216 #[serde(default = "default_limit")]
217 pub limit: usize,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct InspectClusterArgs {
225 pub cluster_id: String,
226 #[serde(default)]
231 pub full_content: bool,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct IngestDocumentArgs {
239 pub path: String,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct SearchDocsArgs {
248 pub query: String,
249 #[serde(default = "default_search_docs_limit")]
250 pub limit: usize,
251}
252
253fn default_search_docs_limit() -> usize {
254 5
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct InspectDocumentArgs {
259 pub doc_id: String,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct ListDocumentsArgs {
264 #[serde(default = "default_list_documents_limit")]
265 pub limit: usize,
266 #[serde(default)]
267 pub offset: usize,
268 #[serde(default)]
272 pub include_forgotten: bool,
273}
274
275fn default_list_documents_limit() -> usize {
276 20
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct ForgetDocumentArgs {
281 pub doc_id: String,
282}
283
284impl ServerHandler for SoloMcpServer {
289 fn get_info(&self) -> ServerInfo {
290 ServerInfo {
291 protocol_version: ProtocolVersion::default(),
292 capabilities: ServerCapabilities {
293 tools: Some(ToolsCapability {
294 list_changed: Some(false),
295 }),
296 ..Default::default()
297 },
298 server_info: Implementation {
299 name: "solo".into(),
300 version: env!("CARGO_PKG_VERSION").into(),
301 },
302 instructions: Some(
303 "Solo gives you persistent memory across conversations \
304 with this user — what they've told you before, the \
305 people and projects in their life, and where their \
306 stated beliefs have shifted, plus a library of \
307 documents the user has ingested (notes, runbooks, \
308 PDFs). Reach for these tools whenever the user \
309 references something from earlier (\"like I \
310 mentioned\", \"the project I'm working on\", \"my \
311 friend Alex\", \"the notes I uploaded last week\") \
312 or asks a question that hinges on personal context \
313 or document content you don't have in the current \
314 chat. \
315 \n\nTools to write or look up specific moments: \
316 memory_remember (save something worth keeping), \
317 memory_recall (search past conversations by topic), \
318 memory_inspect (show one saved item by id), \
319 memory_forget (delete one saved item). \
320 \n\nTools for the bigger picture (populated as the \
321 user uses Solo over time): memory_themes (recent \
322 topics they've been thinking about), \
323 memory_facts_about (what you know about a person, \
324 project, or place — \"what do you know about \
325 Alex?\"), memory_contradictions (places where the \
326 user has said two things that disagree — surface \
327 these before answering), memory_inspect_cluster \
328 (the raw conversations behind one summary). \
329 \n\nTools for the user's documents: \
330 memory_ingest_document (read a file from disk and \
331 add it to Solo's library), memory_search_docs \
332 (search across ingested documents by topic — use \
333 when the user asks about something they wrote down \
334 or saved as a file), memory_inspect_document (show \
335 one document's metadata plus a preview of its \
336 chunks), memory_list_documents (browse documents \
337 by recency), memory_forget_document (drop a \
338 document from the library)."
339 .into(),
340 ),
341 }
342 }
343
344 async fn list_tools(
345 &self,
346 _request: PaginatedRequestParam,
347 _context: RequestContext<RoleServer>,
348 ) -> std::result::Result<ListToolsResult, McpError> {
349 Ok(ListToolsResult {
350 tools: build_tools(),
351 next_cursor: None,
352 })
353 }
354
355 async fn call_tool(
356 &self,
357 request: CallToolRequestParam,
358 _context: RequestContext<RoleServer>,
359 ) -> std::result::Result<CallToolResult, McpError> {
360 let CallToolRequestParam { name, arguments } = request;
361 let args_value = serde_json::Value::Object(arguments.unwrap_or_default());
362 self.dispatch_tool(&name, args_value).await
363 }
364}
365
366impl SoloMcpServer {
367 pub async fn dispatch_tool(
373 &self,
374 name: &str,
375 args_value: serde_json::Value,
376 ) -> std::result::Result<CallToolResult, McpError> {
377 match name {
378 "memory_remember" => {
379 let args: RememberArgs = parse_args(&args_value)?;
380 self.handle_remember(args).await
381 }
382 "memory_recall" => {
383 let args: RecallArgs = parse_args(&args_value)?;
384 self.handle_recall(args).await
385 }
386 "memory_forget" => {
387 let args: ForgetArgs = parse_args(&args_value)?;
388 self.handle_forget(args).await
389 }
390 "memory_inspect" => {
391 let args: InspectArgs = parse_args(&args_value)?;
392 self.handle_inspect(args).await
393 }
394 "memory_themes" => {
395 let args: ThemesArgs = parse_args(&args_value)?;
396 self.handle_themes(args).await
397 }
398 "memory_facts_about" => {
399 let args: FactsAboutArgs = parse_args(&args_value)?;
400 self.handle_facts_about(args).await
401 }
402 "memory_contradictions" => {
403 let args: ContradictionsArgs = parse_args(&args_value)?;
404 self.handle_contradictions(args).await
405 }
406 "memory_inspect_cluster" => {
407 let args: InspectClusterArgs = parse_args(&args_value)?;
408 self.handle_inspect_cluster(args).await
409 }
410 "memory_ingest_document" => {
411 let args: IngestDocumentArgs = parse_args(&args_value)?;
412 self.handle_ingest_document(args).await
413 }
414 "memory_search_docs" => {
415 let args: SearchDocsArgs = parse_args(&args_value)?;
416 self.handle_search_docs(args).await
417 }
418 "memory_inspect_document" => {
419 let args: InspectDocumentArgs = parse_args(&args_value)?;
420 self.handle_inspect_document(args).await
421 }
422 "memory_list_documents" => {
423 let args: ListDocumentsArgs = parse_args(&args_value)?;
424 self.handle_list_documents(args).await
425 }
426 "memory_forget_document" => {
427 let args: ForgetDocumentArgs = parse_args(&args_value)?;
428 self.handle_forget_document(args).await
429 }
430 other => Err(McpError::invalid_params(
431 format!("unknown tool `{other}`"),
432 None,
433 )),
434 }
435 }
436
437 pub fn dispatch_list_tools(&self) -> Vec<Tool> {
440 build_tools()
441 }
442}
443
444fn parse_args<T: serde::de::DeserializeOwned>(
445 v: &serde_json::Value,
446) -> std::result::Result<T, McpError> {
447 serde_json::from_value(v.clone()).map_err(|e| {
448 McpError::invalid_params(format!("invalid tool arguments: {e}"), None)
449 })
450}
451
452fn solo_to_mcp(e: solo_core::Error) -> McpError {
453 use solo_core::Error;
454 match e {
455 Error::NotFound(msg) => McpError::invalid_params(msg, None),
456 Error::InvalidInput(msg) => McpError::invalid_params(msg, None),
457 Error::Conflict(msg) => McpError::invalid_params(msg, None),
458 other => McpError::internal_error(other.to_string(), None),
459 }
460}
461
462fn build_tools() -> Vec<Tool> {
467 vec![
468 Tool::new(
469 "memory_remember",
470 "Save something the user has told you — a fact, a \
471 preference, a name, a date, a context — so you can pick \
472 it up next conversation. Use whenever the user mentions \
473 something they'd reasonably expect you to recall later \
474 (\"I just started at Quotient\", \"my partner is Maya\"). \
475 Returns the saved item's id.",
476 json_schema_object(serde_json::json!({
477 "type": "object",
478 "properties": {
479 "content": {
480 "type": "string",
481 "description": "The text to remember.",
482 },
483 "source_type": {
484 "type": "string",
485 "description": "Optional source-type tag (default: \"user_message\").",
486 },
487 "source_id": {
488 "type": "string",
489 "description": "Optional upstream id for traceability.",
490 },
491 },
492 "required": ["content"],
493 })),
494 ),
495 Tool::new(
496 "memory_recall",
497 "Search past conversations with this user by topic or \
498 phrase. Returns up to `limit` of the closest matches, \
499 best match first. Use when the user references \
500 something they said before (\"that book I told you \
501 about\", \"the bug we were debugging last week\"). \
502 Skips items the user has deleted.",
503 json_schema_object(serde_json::json!({
504 "type": "object",
505 "properties": {
506 "query": {
507 "type": "string",
508 "description": "The query text.",
509 },
510 "limit": {
511 "type": "integer",
512 "description": "Maximum results (default 5).",
513 "minimum": 1,
514 "maximum": 100,
515 },
516 },
517 "required": ["query"],
518 })),
519 ),
520 Tool::new(
521 "memory_forget",
522 "Delete one saved item by id. Use when the user asks you \
523 to forget something specific (\"forget that I said \
524 X\"). The item stops appearing in future recalls. \
525 Reversible only via backups.",
526 json_schema_object(serde_json::json!({
527 "type": "object",
528 "properties": {
529 "memory_id": {
530 "type": "string",
531 "description": "MemoryId to forget (UUID v7).",
532 },
533 "reason": {
534 "type": "string",
535 "description": "Optional free-form reason (logged, not yet persisted).",
536 },
537 },
538 "required": ["memory_id"],
539 })),
540 ),
541 Tool::new(
542 "memory_inspect",
543 "Show the full record for one saved item — when it was \
544 saved, where it came from, and the full text. Use after \
545 memory_recall when you want the complete content of a \
546 specific hit (recall results may be truncated).",
547 json_schema_object(serde_json::json!({
548 "type": "object",
549 "properties": {
550 "memory_id": {
551 "type": "string",
552 "description": "MemoryId to inspect (UUID v7).",
553 },
554 },
555 "required": ["memory_id"],
556 })),
557 ),
558 Tool::new(
562 "memory_themes",
563 "Recent topics the user has been thinking about. Use to \
564 orient yourself at the start of a conversation, or when \
565 the user asks \"what have I been up to\" / \"what was I \
566 working on last week\". Pass `window_days` to scope \
567 (e.g. 7 for last week); omit for all-time.",
568 json_schema_object(serde_json::json!({
569 "type": "object",
570 "properties": {
571 "window_days": {
572 "type": "integer",
573 "description": "Optional time window in days. Omit for unfiltered.",
574 "minimum": 1,
575 },
576 "limit": {
577 "type": "integer",
578 "description": "Maximum results (default 5).",
579 "minimum": 1,
580 "maximum": 100,
581 },
582 },
583 })),
584 ),
585 Tool::new(
586 "memory_facts_about",
587 "Look up what you remember about a person, project, or \
588 topic — names, dates, preferences, relationships. Use \
589 when the user asks \"what do you know about Alex?\", \
590 \"when did I start at Quotient?\", \"who is Maya?\", or \
591 whenever you need grounded facts about someone or \
592 something before answering. Subject is required (the \
593 person/place/thing you're asking about); narrow further \
594 with `predicate` (\"works_at\", \"lives_in\") or a date \
595 range. Set `include_as_object=true` to also surface \
596 facts where the subject appears on the receiving side of \
597 a relationship (e.g. \"Sam pushes back on PRs about \
598 Maya\" surfaces under facts_about(subject=\"Maya\", \
599 include_as_object=true)). (Backed by \
600 subject-predicate-object triples distilled from past \
601 conversations.) Clients should set a 30s timeout on this \
602 call; if exceeded, retry once or fall back to \
603 `memory_recall`.",
604 json_schema_object(serde_json::json!({
605 "type": "object",
606 "properties": {
607 "subject": {
608 "type": "string",
609 "description": "Subject id to query (e.g. 'Sam').",
610 },
611 "predicate": {
612 "type": "string",
613 "description": "Optional predicate filter (e.g. 'works_at').",
614 },
615 "since_ms": {
616 "type": "integer",
617 "description": "Optional valid_from_ms lower bound (epoch ms).",
618 },
619 "until_ms": {
620 "type": "integer",
621 "description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through.",
622 },
623 "include_as_object": {
624 "type": "boolean",
625 "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.",
626 "default": false,
627 },
628 "limit": {
629 "type": "integer",
630 "description": "Maximum results (default 5).",
631 "minimum": 1,
632 "maximum": 100,
633 },
634 },
635 "required": ["subject"],
636 })),
637 ),
638 Tool::new(
639 "memory_contradictions",
640 "Find places where the user's stated beliefs or facts \
641 disagree across conversations — flag disagreements \
642 before answering. Use whenever you're about to rely on \
643 a remembered fact that could have changed (jobs, \
644 relationships, preferences, opinions); a disagreement \
645 here means the user has told you both X and not-X over \
646 time and you should ask which is current instead of \
647 guessing. Each result shows both conflicting statements \
648 with the topic.",
649 json_schema_object(serde_json::json!({
650 "type": "object",
651 "properties": {
652 "limit": {
653 "type": "integer",
654 "description": "Maximum results (default 5).",
655 "minimum": 1,
656 "maximum": 100,
657 },
658 },
659 })),
660 ),
661 Tool::new(
662 "memory_inspect_cluster",
663 "Show the raw conversations behind one summary. Returns \
664 the one-line topic (the LLM-generated summary) and the \
665 source conversations the topic was built from. Use \
666 after memory_themes when the user asks \"show me the \
667 raw context behind this\" or \"why does Solo think \
668 that about cluster Y\". Source items are truncated to \
669 200 chars unless `full_content` is set.",
670 json_schema_object(serde_json::json!({
671 "type": "object",
672 "properties": {
673 "cluster_id": {
674 "type": "string",
675 "description": "Cluster id to inspect (from memory_themes hits).",
676 },
677 "full_content": {
678 "type": "boolean",
679 "description": "If true, episode content is returned verbatim. Default false (truncate to 200 chars + ellipsis).",
680 },
681 },
682 "required": ["cluster_id"],
683 })),
684 ),
685 Tool::new(
689 "memory_ingest_document",
690 "Read a file from disk and add it to the user's document \
691 library so it becomes searchable alongside past \
692 conversations. Use when the user asks you to remember a \
693 whole file (\"add my notes/runbook.md\", \"ingest this \
694 PDF\"). The file is split into ~500-token chunks and \
695 each chunk is embedded; chunks then surface through \
696 memory_search_docs. Returns the new document id, chunk \
697 count, and a `deduped` flag (true if the same content \
698 was already ingested under another id).",
699 json_schema_object(serde_json::json!({
700 "type": "object",
701 "properties": {
702 "path": {
703 "type": "string",
704 "description": "Server-side absolute path to the file to ingest. The file must be readable by the Solo process.",
705 },
706 },
707 "required": ["path"],
708 })),
709 ),
710 Tool::new(
711 "memory_search_docs",
712 "Search across the user's ingested documents by topic or \
713 phrase. Returns up to `limit` matching chunks, best \
714 match first, each with the parent document's title + \
715 source path so you can cite where the answer came from. \
716 Use when the user asks a question that hinges on \
717 material they've added as a file (\"what does my \
718 runbook say about backups?\", \"find the section in the \
719 notes about the new policy\"). Forgotten documents are \
720 skipped.",
721 json_schema_object(serde_json::json!({
722 "type": "object",
723 "properties": {
724 "query": {
725 "type": "string",
726 "description": "The query text.",
727 },
728 "limit": {
729 "type": "integer",
730 "description": "Maximum results (default 5).",
731 "minimum": 1,
732 "maximum": 100,
733 },
734 },
735 "required": ["query"],
736 })),
737 ),
738 Tool::new(
739 "memory_inspect_document",
740 "Show one document's metadata plus a preview of every \
741 chunk it was split into. Use after memory_search_docs \
742 when the user wants the bigger picture for one hit \
743 (\"show me the whole document this came from\"), or \
744 after memory_list_documents to drill into one entry. \
745 Each chunk preview is truncated to 200 chars.",
746 json_schema_object(serde_json::json!({
747 "type": "object",
748 "properties": {
749 "doc_id": {
750 "type": "string",
751 "description": "Document id to inspect (UUID v7).",
752 },
753 },
754 "required": ["doc_id"],
755 })),
756 ),
757 Tool::new(
758 "memory_list_documents",
759 "List the user's ingested documents, newest first. Use \
760 when the user asks \"what documents have I added?\" or \
761 \"show me my files\". Returns a paginated index — pass \
762 `offset` to page further back. Forgotten documents are \
763 hidden by default; set `include_forgotten=true` to see \
764 them too.",
765 json_schema_object(serde_json::json!({
766 "type": "object",
767 "properties": {
768 "limit": {
769 "type": "integer",
770 "description": "Maximum results per page (default 20).",
771 "minimum": 1,
772 "maximum": 100,
773 },
774 "offset": {
775 "type": "integer",
776 "description": "Number of rows to skip (for paging). Default 0.",
777 "minimum": 0,
778 },
779 "include_forgotten": {
780 "type": "boolean",
781 "description": "If true, also include documents the user has forgotten. Default false.",
782 },
783 },
784 })),
785 ),
786 Tool::new(
787 "memory_forget_document",
788 "Drop one document from the user's library by id. Use \
789 when the user asks you to forget a specific file \
790 (\"forget my old runbook\"). The document's chunks stop \
791 appearing in memory_search_docs and the vectors are \
792 tombstoned in the index. The chunk rows themselves are \
793 kept for forensic value (a future restore command can \
794 undo this).",
795 json_schema_object(serde_json::json!({
796 "type": "object",
797 "properties": {
798 "doc_id": {
799 "type": "string",
800 "description": "Document id to forget (UUID v7).",
801 },
802 },
803 "required": ["doc_id"],
804 })),
805 ),
806 ]
807}
808
809fn json_schema_object(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
810 match value {
811 serde_json::Value::Object(map) => map,
812 _ => panic!("json_schema_object: input must be an object"),
813 }
814}
815
816pub fn tool_names() -> Vec<&'static str> {
825 vec![
826 "memory_remember",
827 "memory_recall",
828 "memory_forget",
829 "memory_inspect",
830 "memory_themes",
831 "memory_facts_about",
832 "memory_contradictions",
833 "memory_inspect_cluster",
834 "memory_ingest_document",
836 "memory_search_docs",
837 "memory_inspect_document",
838 "memory_list_documents",
839 "memory_forget_document",
840 ]
841}
842
843impl SoloMcpServer {
848 async fn handle_remember(
849 &self,
850 args: RememberArgs,
851 ) -> std::result::Result<CallToolResult, McpError> {
852 let content = args.content.trim_end().to_string();
853 if content.is_empty() {
854 return Err(McpError::invalid_params(
855 "memory_remember: content must not be empty".to_string(),
856 None,
857 ));
858 }
859 let embedding: solo_core::Embedding = self
860 .inner
861 .embedder
862 .embed(&content)
863 .await
864 .map_err(solo_to_mcp)?;
865 let episode = Episode {
866 memory_id: MemoryId::new(),
867 ts_ms: chrono::Utc::now().timestamp_millis(),
868 source_type: args.source_type.unwrap_or_else(|| "user_message".into()),
869 source_id: args.source_id,
870 content,
871 encoding_context: EncodingContext::default(),
872 provenance: None,
873 confidence: Confidence::new(0.9).unwrap(),
874 strength: 0.5,
875 salience: 0.5,
876 tier: Tier::Hot,
877 };
878 let mid = self
879 .inner
880 .write
881 .remember(episode, embedding)
882 .await
883 .map_err(solo_to_mcp)?;
884 Ok(CallToolResult::success(vec![Content::text(format!(
885 "remembered {mid}"
886 ))]))
887 }
888
889 async fn handle_recall(
890 &self,
891 args: RecallArgs,
892 ) -> std::result::Result<CallToolResult, McpError> {
893 let result = solo_query::run_recall(
897 &self.inner.embedder,
898 &self.inner.hnsw,
899 &self.inner.pool,
900 &args.query,
901 args.limit,
902 )
903 .await
904 .map_err(solo_to_mcp)?;
905
906 if result.hits.is_empty() {
907 return Ok(CallToolResult::success(vec![Content::text(format!(
908 "no matches (index has {} vectors)",
909 result.index_len
910 ))]));
911 }
912 let body = serde_json::to_string_pretty(&result.hits).unwrap_or_else(|_| String::new());
913 Ok(CallToolResult::success(vec![Content::text(body)]))
914 }
915
916 async fn handle_forget(
917 &self,
918 args: ForgetArgs,
919 ) -> std::result::Result<CallToolResult, McpError> {
920 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
921 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
922 })?;
923 self.inner
924 .write
925 .forget(mid, args.reason)
926 .await
927 .map_err(solo_to_mcp)?;
928 Ok(CallToolResult::success(vec![Content::text(format!(
929 "forgotten {mid}"
930 ))]))
931 }
932
933 async fn handle_inspect(
934 &self,
935 args: InspectArgs,
936 ) -> std::result::Result<CallToolResult, McpError> {
937 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
938 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
939 })?;
940 let row = solo_query::inspect_one(&self.inner.pool, mid)
942 .await
943 .map_err(solo_to_mcp)?;
944 let body = serde_json::to_string_pretty(&row).unwrap_or_else(|_| String::new());
945 Ok(CallToolResult::success(vec![Content::text(body)]))
946 }
947
948 async fn handle_themes(
955 &self,
956 args: ThemesArgs,
957 ) -> std::result::Result<CallToolResult, McpError> {
958 let hits = solo_query::themes(
959 &self.inner.pool,
960 args.window_days,
961 args.limit,
962 )
963 .await
964 .map_err(solo_to_mcp)?;
965 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
966 Ok(CallToolResult::success(vec![Content::text(body)]))
967 }
968
969 async fn handle_facts_about(
970 &self,
971 args: FactsAboutArgs,
972 ) -> std::result::Result<CallToolResult, McpError> {
973 if args.subject.trim().is_empty() {
974 return Err(McpError::invalid_params(
975 "memory_facts_about: subject must not be empty".to_string(),
976 None,
977 ));
978 }
979 let hits = solo_query::facts_about(
980 &self.inner.pool,
981 &args.subject,
982 &self.inner.user_aliases,
983 args.include_as_object,
984 args.predicate.as_deref(),
985 args.since_ms,
986 args.until_ms,
987 args.limit,
988 )
989 .await
990 .map_err(solo_to_mcp)?;
991 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
992 Ok(CallToolResult::success(vec![Content::text(body)]))
993 }
994
995 async fn handle_contradictions(
996 &self,
997 args: ContradictionsArgs,
998 ) -> std::result::Result<CallToolResult, McpError> {
999 let hits = solo_query::contradictions(&self.inner.pool, args.limit)
1000 .await
1001 .map_err(solo_to_mcp)?;
1002 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1003 Ok(CallToolResult::success(vec![Content::text(body)]))
1004 }
1005
1006 async fn handle_inspect_cluster(
1007 &self,
1008 args: InspectClusterArgs,
1009 ) -> std::result::Result<CallToolResult, McpError> {
1010 if args.cluster_id.trim().is_empty() {
1011 return Err(McpError::invalid_params(
1012 "memory_inspect_cluster: cluster_id must not be empty".to_string(),
1013 None,
1014 ));
1015 }
1016 let record = solo_query::inspect_cluster(
1021 &self.inner.pool,
1022 &args.cluster_id,
1023 args.full_content,
1024 )
1025 .await
1026 .map_err(solo_to_mcp)?;
1027 let body = serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
1028 Ok(CallToolResult::success(vec![Content::text(body)]))
1029 }
1030
1031 async fn handle_ingest_document(
1036 &self,
1037 args: IngestDocumentArgs,
1038 ) -> std::result::Result<CallToolResult, McpError> {
1039 if args.path.trim().is_empty() {
1040 return Err(McpError::invalid_params(
1041 "memory_ingest_document: path must not be empty".to_string(),
1042 None,
1043 ));
1044 }
1045 let path = std::path::PathBuf::from(args.path);
1046 let chunk_config = solo_storage::document::ChunkConfig::default();
1050 let report = self
1051 .inner
1052 .write
1053 .ingest_document(path, chunk_config)
1054 .await
1055 .map_err(solo_to_mcp)?;
1056 let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| String::new());
1057 Ok(CallToolResult::success(vec![Content::text(body)]))
1058 }
1059
1060 async fn handle_search_docs(
1061 &self,
1062 args: SearchDocsArgs,
1063 ) -> std::result::Result<CallToolResult, McpError> {
1064 let hits = solo_query::run_doc_search(
1068 &self.inner.embedder,
1069 &self.inner.hnsw,
1070 &self.inner.pool,
1071 &args.query,
1072 args.limit,
1073 )
1074 .await
1075 .map_err(solo_to_mcp)?;
1076 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1077 Ok(CallToolResult::success(vec![Content::text(body)]))
1078 }
1079
1080 async fn handle_inspect_document(
1081 &self,
1082 args: InspectDocumentArgs,
1083 ) -> std::result::Result<CallToolResult, McpError> {
1084 let doc_id = DocumentId::from_str(&args.doc_id).map_err(|e| {
1085 McpError::invalid_params(format!("invalid doc_id: {e}"), None)
1086 })?;
1087 let result_opt = solo_query::inspect_document(&self.inner.pool, &doc_id)
1088 .await
1089 .map_err(solo_to_mcp)?;
1090 match result_opt {
1091 Some(record) => {
1092 let body =
1093 serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
1094 Ok(CallToolResult::success(vec![Content::text(body)]))
1095 }
1096 None => Err(McpError::invalid_params(
1097 format!("document {doc_id} not found"),
1098 None,
1099 )),
1100 }
1101 }
1102
1103 async fn handle_list_documents(
1104 &self,
1105 args: ListDocumentsArgs,
1106 ) -> std::result::Result<CallToolResult, McpError> {
1107 let rows = solo_query::list_documents(
1108 &self.inner.pool,
1109 args.limit,
1110 args.offset,
1111 args.include_forgotten,
1112 )
1113 .await
1114 .map_err(solo_to_mcp)?;
1115 let body = serde_json::to_string_pretty(&rows).unwrap_or_else(|_| String::new());
1116 Ok(CallToolResult::success(vec![Content::text(body)]))
1117 }
1118
1119 async fn handle_forget_document(
1120 &self,
1121 args: ForgetDocumentArgs,
1122 ) -> std::result::Result<CallToolResult, McpError> {
1123 let doc_id = DocumentId::from_str(&args.doc_id).map_err(|e| {
1124 McpError::invalid_params(format!("invalid doc_id: {e}"), None)
1125 })?;
1126 let report = self
1127 .inner
1128 .write
1129 .forget_document(doc_id)
1130 .await
1131 .map_err(solo_to_mcp)?;
1132 let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| String::new());
1133 Ok(CallToolResult::success(vec![Content::text(body)]))
1134 }
1135}
1136
1137#[cfg(test)]
1138mod dispatch_tests {
1139 use super::*;
1151 use serde_json::json;
1152 use solo_core::VectorIndex;
1153 use solo_storage::test_support::StubVectorIndex;
1154 use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
1155 use std::sync::Arc as StdArc;
1156
1157 struct Harness {
1158 server: SoloMcpServer,
1159 _tmp: tempfile::TempDir,
1160 write_handle_extra: Option<solo_storage::WriteHandle>,
1161 join: Option<std::thread::JoinHandle<()>>,
1162 }
1163
1164 impl Harness {
1165 fn new(runtime: &tokio::runtime::Runtime) -> Self {
1166 let tmp = tempfile::TempDir::new().unwrap();
1167 let dim = 16usize;
1168 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1169 let embedder: StdArc<dyn solo_core::Embedder> = StdArc::new(StubEmbedder::new("stub", "v1", dim));
1170
1171 let conn = solo_storage::test_support::open_test_db_at(&tmp.path().join("test.db"));
1172 let WriterSpawn { handle, join } = WriterActor::spawn(conn, hnsw.clone());
1173
1174 let path = tmp.path().join("test.db");
1177 let pool: ReaderPool =
1178 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1179
1180 let server = SoloMcpServer::new(handle.clone(), pool, embedder, hnsw);
1181 Harness {
1182 server,
1183 _tmp: tmp,
1184 write_handle_extra: Some(handle),
1185 join: Some(join),
1186 }
1187 }
1188
1189 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1190 let join = self.join.take();
1196 let extra = self.write_handle_extra.take();
1197 runtime.block_on(async move {
1198 drop(extra);
1199 drop(self.server);
1200 drop(self._tmp);
1201 if let Some(join) = join {
1202 let (tx, rx) = std::sync::mpsc::channel();
1203 std::thread::spawn(move || {
1204 let _ = tx.send(join.join());
1205 });
1206 tokio::task::spawn_blocking(move || {
1207 rx.recv_timeout(std::time::Duration::from_secs(5))
1208 })
1209 .await
1210 .expect("blocking task")
1211 .expect("writer thread did not exit within 5s")
1212 .expect("writer thread panicked");
1213 }
1214 });
1215 }
1216 }
1217
1218 fn rt() -> tokio::runtime::Runtime {
1219 tokio::runtime::Builder::new_multi_thread()
1220 .worker_threads(2)
1221 .enable_all()
1222 .build()
1223 .unwrap()
1224 }
1225
1226 fn first_text(r: &rmcp::model::CallToolResult) -> String {
1231 let first = r.content.first().expect("at least one content item");
1232 let v = serde_json::to_value(first).expect("content serialises");
1233 v.get("text")
1234 .and_then(|t| t.as_str())
1235 .map(|s| s.to_string())
1236 .unwrap_or_else(|| format!("{v}"))
1237 }
1238
1239 #[test]
1240 fn tools_list_returns_thirteen_canonical_tools() {
1241 let runtime = rt();
1242 let h = Harness::new(&runtime);
1243 let tools = h.server.dispatch_list_tools();
1244 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1245 assert_eq!(
1246 names,
1247 vec![
1248 "memory_remember",
1249 "memory_recall",
1250 "memory_forget",
1251 "memory_inspect",
1252 "memory_themes",
1254 "memory_facts_about",
1255 "memory_contradictions",
1256 "memory_inspect_cluster",
1258 "memory_ingest_document",
1260 "memory_search_docs",
1261 "memory_inspect_document",
1262 "memory_list_documents",
1263 "memory_forget_document",
1264 ]
1265 );
1266 for t in &tools {
1267 assert!(!t.description.is_empty(), "{} description empty", t.name);
1268 let _schema = t.schema_as_json_value();
1269 }
1276 h.shutdown(&runtime);
1277 }
1278
1279 #[test]
1280 fn themes_returns_json_array_on_empty_db() {
1281 let runtime = rt();
1282 let h = Harness::new(&runtime);
1283 runtime.block_on(async {
1284 let r = h
1285 .server
1286 .dispatch_tool("memory_themes", json!({}))
1287 .await
1288 .expect("themes succeeds");
1289 let text = first_text(&r);
1290 let v: serde_json::Value =
1292 serde_json::from_str(&text).expect("parses as json");
1293 assert!(v.is_array(), "expected array, got: {text}");
1294 assert_eq!(v.as_array().unwrap().len(), 0);
1295 });
1296 h.shutdown(&runtime);
1297 }
1298
1299 #[test]
1300 fn themes_passes_through_window_and_limit_args() {
1301 let runtime = rt();
1302 let h = Harness::new(&runtime);
1303 runtime.block_on(async {
1304 let r = h
1306 .server
1307 .dispatch_tool(
1308 "memory_themes",
1309 json!({ "window_days": 7, "limit": 20 }),
1310 )
1311 .await
1312 .expect("themes with args succeeds");
1313 let text = first_text(&r);
1314 let v: serde_json::Value =
1315 serde_json::from_str(&text).expect("parses as json");
1316 assert!(v.is_array());
1317 });
1318 h.shutdown(&runtime);
1319 }
1320
1321 #[test]
1322 fn facts_about_rejects_empty_subject() {
1323 let runtime = rt();
1324 let h = Harness::new(&runtime);
1325 runtime.block_on(async {
1326 let err = h
1327 .server
1328 .dispatch_tool(
1329 "memory_facts_about",
1330 json!({ "subject": " " }),
1331 )
1332 .await
1333 .expect_err("empty subject must error");
1334 let s = format!("{err:?}");
1337 assert!(
1338 s.to_lowercase().contains("subject")
1339 || s.to_lowercase().contains("invalid"),
1340 "got: {s}"
1341 );
1342 });
1343 h.shutdown(&runtime);
1344 }
1345
1346 #[test]
1347 fn facts_about_returns_array_for_unknown_subject() {
1348 let runtime = rt();
1349 let h = Harness::new(&runtime);
1350 runtime.block_on(async {
1351 let r = h
1352 .server
1353 .dispatch_tool(
1354 "memory_facts_about",
1355 json!({ "subject": "NobodyKnowsThisSubject" }),
1356 )
1357 .await
1358 .expect("facts_about with unknown subject succeeds");
1359 let text = first_text(&r);
1360 let v: serde_json::Value =
1361 serde_json::from_str(&text).expect("parses as json");
1362 assert_eq!(v.as_array().unwrap().len(), 0);
1363 });
1364 h.shutdown(&runtime);
1365 }
1366
1367 #[test]
1368 fn facts_about_accepts_include_as_object_arg() {
1369 let runtime = rt();
1377 let h = Harness::new(&runtime);
1378 runtime.block_on(async {
1379 let r = h
1381 .server
1382 .dispatch_tool(
1383 "memory_facts_about",
1384 json!({ "subject": "Maya", "include_as_object": true }),
1385 )
1386 .await
1387 .expect("dispatch with include_as_object=true succeeds");
1388 let v: serde_json::Value = serde_json::from_str(&first_text(&r))
1389 .expect("parses as json");
1390 assert_eq!(v.as_array().unwrap().len(), 0);
1391
1392 let r = h
1394 .server
1395 .dispatch_tool(
1396 "memory_facts_about",
1397 json!({ "subject": "Maya" }),
1398 )
1399 .await
1400 .expect("dispatch without include_as_object succeeds (default false)");
1401 let v: serde_json::Value = serde_json::from_str(&first_text(&r))
1402 .expect("parses as json");
1403 assert_eq!(v.as_array().unwrap().len(), 0);
1404 });
1405 h.shutdown(&runtime);
1406 }
1407
1408 #[test]
1409 fn contradictions_returns_json_array_on_empty_db() {
1410 let runtime = rt();
1411 let h = Harness::new(&runtime);
1412 runtime.block_on(async {
1413 let r = h
1414 .server
1415 .dispatch_tool("memory_contradictions", json!({}))
1416 .await
1417 .expect("contradictions succeeds");
1418 let text = first_text(&r);
1419 let v: serde_json::Value =
1420 serde_json::from_str(&text).expect("parses as json");
1421 assert!(v.is_array());
1422 assert_eq!(v.as_array().unwrap().len(), 0);
1423 });
1424 h.shutdown(&runtime);
1425 }
1426
1427 #[test]
1428 fn remember_then_recall_round_trip() {
1429 let runtime = rt();
1430 let h = Harness::new(&runtime);
1431 runtime.block_on(async {
1437 let r = h
1438 .server
1439 .dispatch_tool("memory_remember", json!({ "content": "the cat sat on the mat" }))
1440 .await
1441 .expect("remember succeeds");
1442 let text = first_text(&r);
1443 assert!(text.starts_with("remembered "), "got: {text}");
1444
1445 let r = h
1446 .server
1447 .dispatch_tool(
1448 "memory_recall",
1449 json!({ "query": "the cat sat on the mat", "limit": 5 }),
1450 )
1451 .await
1452 .expect("recall succeeds");
1453 let text = first_text(&r);
1454 assert!(text.contains("the cat sat on the mat"), "got: {text}");
1455 });
1456 h.shutdown(&runtime);
1457 }
1458
1459 #[test]
1460 fn forget_excludes_row_from_subsequent_recall() {
1461 let runtime = rt();
1462 let h = Harness::new(&runtime);
1463
1464 runtime.block_on(async {
1465 let r = h
1466 .server
1467 .dispatch_tool("memory_remember", json!({ "content": "to be forgotten" }))
1468 .await
1469 .unwrap();
1470 let text = first_text(&r);
1471 let mid = text.strip_prefix("remembered ").unwrap().to_string();
1472
1473 h.server
1474 .dispatch_tool(
1475 "memory_forget",
1476 json!({ "memory_id": mid, "reason": "test" }),
1477 )
1478 .await
1479 .expect("forget succeeds");
1480
1481 let r = h
1482 .server
1483 .dispatch_tool(
1484 "memory_recall",
1485 json!({ "query": "to be forgotten", "limit": 5 }),
1486 )
1487 .await
1488 .unwrap();
1489 let text = first_text(&r);
1490 assert!(
1491 !text.contains(r#""content": "to be forgotten""#),
1492 "forgotten row should be excluded; got: {text}"
1493 );
1494 });
1495 h.shutdown(&runtime);
1496 }
1497
1498 #[test]
1499 fn empty_remember_returns_invalid_params() {
1500 let runtime = rt();
1501 let h = Harness::new(&runtime);
1502 runtime.block_on(async {
1503 let err = h
1504 .server
1505 .dispatch_tool("memory_remember", json!({ "content": "" }))
1506 .await
1507 .unwrap_err();
1508 assert!(format!("{err:?}").contains("must not be empty"));
1509 });
1510 h.shutdown(&runtime);
1511 }
1512
1513 #[test]
1514 fn empty_recall_query_returns_invalid_params() {
1515 let runtime = rt();
1516 let h = Harness::new(&runtime);
1517 runtime.block_on(async {
1518 let err = h
1519 .server
1520 .dispatch_tool("memory_recall", json!({ "query": " " }))
1521 .await
1522 .unwrap_err();
1523 assert!(format!("{err:?}").contains("must not be empty"));
1524 });
1525 h.shutdown(&runtime);
1526 }
1527
1528 #[test]
1529 fn inspect_with_invalid_id_returns_invalid_params() {
1530 let runtime = rt();
1531 let h = Harness::new(&runtime);
1532 runtime.block_on(async {
1533 let err = h
1534 .server
1535 .dispatch_tool("memory_inspect", json!({ "memory_id": "not-a-uuid" }))
1536 .await
1537 .unwrap_err();
1538 assert!(format!("{err:?}").contains("invalid memory_id"));
1539 });
1540 h.shutdown(&runtime);
1541 }
1542
1543 #[test]
1544 fn forget_unknown_id_returns_invalid_params() {
1545 let runtime = rt();
1546 let h = Harness::new(&runtime);
1547 runtime.block_on(async {
1548 let err = h
1552 .server
1553 .dispatch_tool(
1554 "memory_forget",
1555 json!({ "memory_id": "00000000-0000-7000-8000-000000000000" }),
1556 )
1557 .await
1558 .unwrap_err();
1559 assert!(format!("{err:?}").contains("not found"));
1560 });
1561 h.shutdown(&runtime);
1562 }
1563
1564 #[test]
1565 fn unknown_tool_name_returns_invalid_params() {
1566 let runtime = rt();
1567 let h = Harness::new(&runtime);
1568 runtime.block_on(async {
1569 let err = h
1570 .server
1571 .dispatch_tool("memory.summon", json!({}))
1572 .await
1573 .unwrap_err();
1574 assert!(format!("{err:?}").contains("unknown tool"));
1575 });
1576 h.shutdown(&runtime);
1577 }
1578
1579 #[test]
1614 fn tool_names_match_cross_provider_regex() {
1615 fn passes_anthropic(name: &str) -> bool {
1617 let len = name.len();
1618 if !(1..=64).contains(&len) {
1619 return false;
1620 }
1621 name.chars()
1622 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1623 }
1624
1625 fn passes_openai(name: &str) -> bool {
1628 let len = name.len();
1629 if !(1..=64).contains(&len) {
1630 return false;
1631 }
1632 let mut chars = name.chars();
1633 let first = match chars.next() {
1634 Some(c) => c,
1635 None => return false,
1636 };
1637 if !(first.is_ascii_alphabetic() || first == '_') {
1638 return false;
1639 }
1640 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1641 }
1642
1643 fn passes_gemini(name: &str) -> bool {
1648 let len = name.len();
1649 if !(1..=63).contains(&len) {
1650 return false;
1651 }
1652 let mut chars = name.chars();
1653 let first = match chars.next() {
1654 Some(c) => c,
1655 None => return false,
1656 };
1657 if !(first.is_ascii_alphabetic() || first == '_') {
1658 return false;
1659 }
1660 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1661 }
1662
1663 let tools = build_tools();
1664 assert_eq!(
1665 tools.len(),
1666 13,
1667 "expected 13 tools in v0.7.0 (8 v0.5.x + 5 document tools)"
1668 );
1669 let tool_name_strings: Vec<String> =
1671 tools.iter().map(|t| t.name.to_string()).collect();
1672 let public_names: Vec<String> =
1673 super::tool_names().iter().map(|s| s.to_string()).collect();
1674 assert_eq!(
1675 tool_name_strings, public_names,
1676 "tool_names() drifted from build_tools() — keep them in sync"
1677 );
1678
1679 for t in tools {
1680 assert!(
1681 passes_anthropic(&t.name),
1682 "tool name {:?} fails Anthropic regex \
1683 ^[a-zA-Z0-9_-]{{1,64}}$ — see v0.3 lesson #8",
1684 t.name
1685 );
1686 assert!(
1687 passes_openai(&t.name),
1688 "tool name {:?} fails OpenAI function-calling regex \
1689 ^[a-zA-Z_][a-zA-Z0-9_-]*$ (len ≤ 64)",
1690 t.name
1691 );
1692 assert!(
1693 passes_gemini(&t.name),
1694 "tool name {:?} fails Gemini function-calling regex \
1695 ^[a-zA-Z_][a-zA-Z0-9_]*$ (len ≤ 63, strict)",
1696 t.name
1697 );
1698 }
1699 }
1700
1701 #[test]
1718 fn tool_descriptions_avoid_internal_jargon() {
1719 const FORBIDDEN: &[&str] = &[
1723 "SPO",
1724 "Steward",
1725 "Steward-flagged",
1726 "LEFT JOIN",
1727 "candidate pair",
1728 "candidate_pair",
1729 "tagged_with",
1730 ];
1731
1732 fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
1733 haystack.to_lowercase().contains(&needle.to_lowercase())
1734 }
1735
1736 for t in build_tools() {
1738 for term in FORBIDDEN {
1739 assert!(
1740 !contains_case_insensitive(&t.description, term),
1741 "tool {:?} description contains forbidden jargon \
1742 {:?} — rewrite in plain English (see v0.5.0 \
1743 Priority 4)",
1744 t.name,
1745 term,
1746 );
1747 }
1748 }
1749
1750 let server_info = harness_server_info();
1753 let instructions = server_info
1754 .instructions
1755 .as_deref()
1756 .expect("get_info() must set instructions");
1757 for term in FORBIDDEN {
1758 assert!(
1759 !contains_case_insensitive(instructions, term),
1760 "get_info().instructions contains forbidden jargon \
1761 {:?} — rewrite in plain English",
1762 term,
1763 );
1764 }
1765 }
1766
1767 fn harness_server_info() -> rmcp::model::ServerInfo {
1774 let runtime = rt();
1775 let h = Harness::new(&runtime);
1776 let info = ServerHandler::get_info(&h.server);
1777 h.shutdown(&runtime);
1778 info
1779 }
1780
1781 #[test]
1784 fn inspect_cluster_unknown_id_returns_invalid_params() {
1785 let runtime = rt();
1789 let h = Harness::new(&runtime);
1790 runtime.block_on(async {
1791 let err = h
1792 .server
1793 .dispatch_tool(
1794 "memory_inspect_cluster",
1795 json!({ "cluster_id": "no-such-cluster" }),
1796 )
1797 .await
1798 .expect_err("unknown cluster must error");
1799 let s = format!("{err:?}");
1800 assert!(
1801 s.contains("no-such-cluster") || s.to_lowercase().contains("not found"),
1802 "expected error to mention the missing cluster id; got: {s}"
1803 );
1804 });
1805 h.shutdown(&runtime);
1806 }
1807
1808 #[test]
1809 fn inspect_cluster_rejects_empty_id() {
1810 let runtime = rt();
1811 let h = Harness::new(&runtime);
1812 runtime.block_on(async {
1813 let err = h
1814 .server
1815 .dispatch_tool(
1816 "memory_inspect_cluster",
1817 json!({ "cluster_id": " " }),
1818 )
1819 .await
1820 .expect_err("blank cluster_id must error");
1821 let s = format!("{err:?}");
1822 assert!(
1823 s.to_lowercase().contains("cluster_id")
1824 || s.to_lowercase().contains("must not be empty"),
1825 "got: {s}"
1826 );
1827 });
1828 h.shutdown(&runtime);
1829 }
1830
1831 #[test]
1847 fn ingest_document_args_parse_with_required_path() {
1848 let v: IngestDocumentArgs =
1849 serde_json::from_value(json!({ "path": "/tmp/notes.md" })).expect("parses");
1850 assert_eq!(v.path, "/tmp/notes.md");
1851 let err = serde_json::from_value::<IngestDocumentArgs>(json!({})).unwrap_err();
1853 assert!(format!("{err}").contains("path"));
1854 }
1855
1856 #[test]
1857 fn search_docs_args_parse_with_default_limit() {
1858 let v: SearchDocsArgs =
1859 serde_json::from_value(json!({ "query": "backups" })).expect("parses");
1860 assert_eq!(v.query, "backups");
1861 assert_eq!(v.limit, 5, "default limit must be 5");
1862 let v: SearchDocsArgs =
1863 serde_json::from_value(json!({ "query": "backups", "limit": 20 })).expect("parses");
1864 assert_eq!(v.limit, 20);
1865 }
1866
1867 #[test]
1868 fn inspect_document_args_parse_with_required_doc_id() {
1869 let v: InspectDocumentArgs =
1870 serde_json::from_value(json!({ "doc_id": "abc" })).expect("parses");
1871 assert_eq!(v.doc_id, "abc");
1872 let err = serde_json::from_value::<InspectDocumentArgs>(json!({})).unwrap_err();
1873 assert!(format!("{err}").contains("doc_id"));
1874 }
1875
1876 #[test]
1877 fn list_documents_args_parse_with_all_defaults() {
1878 let v: ListDocumentsArgs = serde_json::from_value(json!({})).expect("parses");
1879 assert_eq!(v.limit, 20, "default limit must be 20");
1880 assert_eq!(v.offset, 0, "default offset must be 0");
1881 assert!(!v.include_forgotten, "default include_forgotten must be false");
1882 let v: ListDocumentsArgs = serde_json::from_value(
1883 json!({ "limit": 5, "offset": 10, "include_forgotten": true }),
1884 )
1885 .expect("parses");
1886 assert_eq!(v.limit, 5);
1887 assert_eq!(v.offset, 10);
1888 assert!(v.include_forgotten);
1889 }
1890
1891 #[test]
1892 fn forget_document_args_parse_with_required_doc_id() {
1893 let v: ForgetDocumentArgs =
1894 serde_json::from_value(json!({ "doc_id": "abc" })).expect("parses");
1895 assert_eq!(v.doc_id, "abc");
1896 let err = serde_json::from_value::<ForgetDocumentArgs>(json!({})).unwrap_err();
1897 assert!(format!("{err}").contains("doc_id"));
1898 }
1899
1900 #[test]
1901 fn ingest_document_rejects_empty_path() {
1902 let runtime = rt();
1905 let h = Harness::new(&runtime);
1906 runtime.block_on(async {
1907 let err = h
1908 .server
1909 .dispatch_tool("memory_ingest_document", json!({ "path": "" }))
1910 .await
1911 .expect_err("empty path must error");
1912 let s = format!("{err:?}");
1913 assert!(
1914 s.to_lowercase().contains("path")
1915 || s.to_lowercase().contains("must not be empty"),
1916 "got: {s}"
1917 );
1918 });
1919 h.shutdown(&runtime);
1920 }
1921
1922 #[test]
1923 fn search_docs_rejects_empty_query() {
1924 let runtime = rt();
1927 let h = Harness::new(&runtime);
1928 runtime.block_on(async {
1929 let err = h
1930 .server
1931 .dispatch_tool("memory_search_docs", json!({ "query": " " }))
1932 .await
1933 .expect_err("empty query must error");
1934 let s = format!("{err:?}");
1935 assert!(
1936 s.to_lowercase().contains("must not be empty")
1937 || s.to_lowercase().contains("invalid"),
1938 "got: {s}"
1939 );
1940 });
1941 h.shutdown(&runtime);
1942 }
1943
1944 #[test]
1945 fn inspect_document_unknown_id_returns_invalid_params() {
1946 let runtime = rt();
1949 let h = Harness::new(&runtime);
1950 runtime.block_on(async {
1951 let err = h
1952 .server
1953 .dispatch_tool(
1954 "memory_inspect_document",
1955 json!({ "doc_id": "00000000-0000-7000-8000-000000000000" }),
1956 )
1957 .await
1958 .expect_err("unknown doc must error");
1959 let s = format!("{err:?}");
1960 assert!(
1961 s.to_lowercase().contains("not found"),
1962 "expected 'not found' message; got: {s}"
1963 );
1964 });
1965 h.shutdown(&runtime);
1966 }
1967
1968 #[test]
1969 fn inspect_document_rejects_malformed_id() {
1970 let runtime = rt();
1971 let h = Harness::new(&runtime);
1972 runtime.block_on(async {
1973 let err = h
1974 .server
1975 .dispatch_tool(
1976 "memory_inspect_document",
1977 json!({ "doc_id": "not-a-uuid" }),
1978 )
1979 .await
1980 .expect_err("malformed doc_id must error");
1981 let s = format!("{err:?}");
1982 assert!(s.contains("invalid doc_id"), "got: {s}");
1983 });
1984 h.shutdown(&runtime);
1985 }
1986
1987 #[test]
1988 fn list_documents_returns_empty_array_on_empty_db() {
1989 let runtime = rt();
1990 let h = Harness::new(&runtime);
1991 runtime.block_on(async {
1992 let r = h
1993 .server
1994 .dispatch_tool("memory_list_documents", json!({}))
1995 .await
1996 .expect("list succeeds");
1997 let text = first_text(&r);
1998 let v: serde_json::Value =
1999 serde_json::from_str(&text).expect("parses as json");
2000 assert!(v.is_array(), "expected array, got: {text}");
2001 assert_eq!(v.as_array().unwrap().len(), 0);
2002 });
2003 h.shutdown(&runtime);
2004 }
2005
2006 #[test]
2007 fn list_documents_passes_through_limit_offset_include_args() {
2008 let runtime = rt();
2009 let h = Harness::new(&runtime);
2010 runtime.block_on(async {
2011 let r = h
2012 .server
2013 .dispatch_tool(
2014 "memory_list_documents",
2015 json!({ "limit": 5, "offset": 10, "include_forgotten": true }),
2016 )
2017 .await
2018 .expect("list with args succeeds");
2019 let text = first_text(&r);
2020 let v: serde_json::Value =
2021 serde_json::from_str(&text).expect("parses as json");
2022 assert!(v.is_array());
2023 });
2024 h.shutdown(&runtime);
2025 }
2026
2027 #[test]
2028 fn forget_document_rejects_malformed_id() {
2029 let runtime = rt();
2030 let h = Harness::new(&runtime);
2031 runtime.block_on(async {
2032 let err = h
2033 .server
2034 .dispatch_tool(
2035 "memory_forget_document",
2036 json!({ "doc_id": "not-a-uuid" }),
2037 )
2038 .await
2039 .expect_err("malformed doc_id must error");
2040 let s = format!("{err:?}");
2041 assert!(s.contains("invalid doc_id"), "got: {s}");
2042 });
2043 h.shutdown(&runtime);
2044 }
2045}
2046
2047