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, EncodingContext, Episode, MemoryId, Tier,
68};
69use solo_storage::{TenantHandle, TenantRegistry};
70use std::str::FromStr;
71
72#[derive(Clone)]
82pub struct SoloMcpServer {
83 inner: Arc<Inner>,
84}
85
86struct Inner {
87 #[allow(dead_code)]
92 registry: Arc<TenantRegistry>,
93 tenant: Arc<TenantHandle>,
96 user_aliases: Vec<String>,
102 audit_principal: Option<String>,
109}
110
111impl SoloMcpServer {
112 pub fn new_for_tenant(
117 registry: Arc<TenantRegistry>,
118 tenant: Arc<TenantHandle>,
119 user_aliases: Vec<String>,
120 ) -> Self {
121 Self::new_for_tenant_with_principal(registry, tenant, user_aliases, None)
122 }
123
124 pub fn new_for_tenant_with_principal(
131 registry: Arc<TenantRegistry>,
132 tenant: Arc<TenantHandle>,
133 user_aliases: Vec<String>,
134 audit_principal: Option<String>,
135 ) -> Self {
136 Self {
137 inner: Arc::new(Inner {
138 registry,
139 tenant,
140 user_aliases,
141 audit_principal,
142 }),
143 }
144 }
145}
146
147pub async fn serve_stdio(server: SoloMcpServer) -> anyhow::Result<()> {
150 use rmcp::transport::io::stdio;
151 let (stdin, stdout) = stdio();
152 let running = server.serve((stdin, stdout)).await?;
153 running.waiting().await?;
154 Ok(())
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct RememberArgs {
163 pub content: String,
164 #[serde(default)]
165 pub source_type: Option<String>,
166 #[serde(default)]
167 pub source_id: Option<String>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct RecallArgs {
172 pub query: String,
173 #[serde(default = "default_limit")]
174 pub limit: usize,
175}
176
177fn default_limit() -> usize {
178 5
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct ForgetArgs {
183 pub memory_id: String,
184 #[serde(default = "default_forget_reason")]
185 pub reason: String,
186}
187
188fn default_forget_reason() -> String {
189 "user-initiated via MCP".into()
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct InspectArgs {
194 pub memory_id: String,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct ThemesArgs {
204 #[serde(default)]
208 pub window_days: Option<i64>,
209 #[serde(default = "default_limit")]
210 pub limit: usize,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct FactsAboutArgs {
215 pub subject: String,
218 #[serde(default)]
219 pub predicate: Option<String>,
220 #[serde(default)]
221 pub since_ms: Option<i64>,
222 #[serde(default)]
223 pub until_ms: Option<i64>,
224 #[serde(default)]
229 pub include_as_object: bool,
230 #[serde(default = "default_limit")]
231 pub limit: usize,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct ContradictionsArgs {
236 #[serde(default = "default_limit")]
237 pub limit: usize,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct InspectClusterArgs {
245 pub cluster_id: String,
246 #[serde(default)]
251 pub full_content: bool,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct IngestDocumentArgs {
259 pub path: String,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct SearchDocsArgs {
268 pub query: String,
269 #[serde(default = "default_search_docs_limit")]
270 pub limit: usize,
271}
272
273fn default_search_docs_limit() -> usize {
274 5
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct InspectDocumentArgs {
279 pub doc_id: String,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct ListDocumentsArgs {
284 #[serde(default = "default_list_documents_limit")]
285 pub limit: usize,
286 #[serde(default)]
287 pub offset: usize,
288 #[serde(default)]
292 pub include_forgotten: bool,
293}
294
295fn default_list_documents_limit() -> usize {
296 20
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ForgetDocumentArgs {
301 pub doc_id: String,
302}
303
304impl ServerHandler for SoloMcpServer {
309 fn get_info(&self) -> ServerInfo {
310 ServerInfo {
311 protocol_version: ProtocolVersion::default(),
312 capabilities: ServerCapabilities {
313 tools: Some(ToolsCapability {
314 list_changed: Some(false),
315 }),
316 ..Default::default()
317 },
318 server_info: Implementation {
319 name: "solo".into(),
320 version: env!("CARGO_PKG_VERSION").into(),
321 },
322 instructions: Some(
323 "Solo gives you persistent memory across conversations \
324 with this user — what they've told you before, the \
325 people and projects in their life, and where their \
326 stated beliefs have shifted, plus a library of \
327 documents the user has ingested (notes, runbooks, \
328 PDFs). Reach for these tools whenever the user \
329 references something from earlier (\"like I \
330 mentioned\", \"the project I'm working on\", \"my \
331 friend Alex\", \"the notes I uploaded last week\") \
332 or asks a question that hinges on personal context \
333 or document content you don't have in the current \
334 chat. \
335 \n\nTools to write or look up specific moments: \
336 memory_remember (save something worth keeping), \
337 memory_recall (search past conversations by topic), \
338 memory_inspect (show one saved item by id), \
339 memory_forget (delete one saved item). \
340 \n\nTools for the bigger picture (populated as the \
341 user uses Solo over time): memory_themes (recent \
342 topics they've been thinking about), \
343 memory_facts_about (what you know about a person, \
344 project, or place — \"what do you know about \
345 Alex?\"), memory_contradictions (places where the \
346 user has said two things that disagree — surface \
347 these before answering), memory_inspect_cluster \
348 (the raw conversations behind one summary). \
349 \n\nTools for the user's documents: \
350 memory_ingest_document (read a file from disk and \
351 add it to Solo's library), memory_search_docs \
352 (search across ingested documents by topic — use \
353 when the user asks about something they wrote down \
354 or saved as a file), memory_inspect_document (show \
355 one document's metadata plus a preview of its \
356 chunks), memory_list_documents (browse documents \
357 by recency), memory_forget_document (drop a \
358 document from the library)."
359 .into(),
360 ),
361 }
362 }
363
364 async fn list_tools(
365 &self,
366 _request: PaginatedRequestParam,
367 _context: RequestContext<RoleServer>,
368 ) -> std::result::Result<ListToolsResult, McpError> {
369 Ok(ListToolsResult {
370 tools: build_tools(),
371 next_cursor: None,
372 })
373 }
374
375 async fn call_tool(
376 &self,
377 request: CallToolRequestParam,
378 _context: RequestContext<RoleServer>,
379 ) -> std::result::Result<CallToolResult, McpError> {
380 let CallToolRequestParam { name, arguments } = request;
381 let args_value = serde_json::Value::Object(arguments.unwrap_or_default());
382 self.dispatch_tool(&name, args_value).await
383 }
384}
385
386impl SoloMcpServer {
387 pub async fn dispatch_tool(
393 &self,
394 name: &str,
395 args_value: serde_json::Value,
396 ) -> std::result::Result<CallToolResult, McpError> {
397 match name {
398 "memory_remember" => {
399 let args: RememberArgs = parse_args(&args_value)?;
400 self.handle_remember(args).await
401 }
402 "memory_recall" => {
403 let args: RecallArgs = parse_args(&args_value)?;
404 self.handle_recall(args).await
405 }
406 "memory_forget" => {
407 let args: ForgetArgs = parse_args(&args_value)?;
408 self.handle_forget(args).await
409 }
410 "memory_inspect" => {
411 let args: InspectArgs = parse_args(&args_value)?;
412 self.handle_inspect(args).await
413 }
414 "memory_themes" => {
415 let args: ThemesArgs = parse_args(&args_value)?;
416 self.handle_themes(args).await
417 }
418 "memory_facts_about" => {
419 let args: FactsAboutArgs = parse_args(&args_value)?;
420 self.handle_facts_about(args).await
421 }
422 "memory_contradictions" => {
423 let args: ContradictionsArgs = parse_args(&args_value)?;
424 self.handle_contradictions(args).await
425 }
426 "memory_inspect_cluster" => {
427 let args: InspectClusterArgs = parse_args(&args_value)?;
428 self.handle_inspect_cluster(args).await
429 }
430 "memory_ingest_document" => {
431 let args: IngestDocumentArgs = parse_args(&args_value)?;
432 self.handle_ingest_document(args).await
433 }
434 "memory_search_docs" => {
435 let args: SearchDocsArgs = parse_args(&args_value)?;
436 self.handle_search_docs(args).await
437 }
438 "memory_inspect_document" => {
439 let args: InspectDocumentArgs = parse_args(&args_value)?;
440 self.handle_inspect_document(args).await
441 }
442 "memory_list_documents" => {
443 let args: ListDocumentsArgs = parse_args(&args_value)?;
444 self.handle_list_documents(args).await
445 }
446 "memory_forget_document" => {
447 let args: ForgetDocumentArgs = parse_args(&args_value)?;
448 self.handle_forget_document(args).await
449 }
450 other => Err(McpError::invalid_params(
451 format!("unknown tool `{other}`"),
452 None,
453 )),
454 }
455 }
456
457 pub fn dispatch_list_tools(&self) -> Vec<Tool> {
460 build_tools()
461 }
462}
463
464fn parse_args<T: serde::de::DeserializeOwned>(
465 v: &serde_json::Value,
466) -> std::result::Result<T, McpError> {
467 serde_json::from_value(v.clone()).map_err(|e| {
468 McpError::invalid_params(format!("invalid tool arguments: {e}"), None)
469 })
470}
471
472fn solo_to_mcp(e: solo_core::Error) -> McpError {
473 use solo_core::Error;
474 match e {
475 Error::NotFound(msg) => McpError::invalid_params(msg, None),
476 Error::InvalidInput(msg) => McpError::invalid_params(msg, None),
477 Error::Conflict(msg) => McpError::invalid_params(msg, None),
478 other => McpError::internal_error(other.to_string(), None),
479 }
480}
481
482fn build_tools() -> Vec<Tool> {
487 vec![
488 Tool::new(
489 "memory_remember",
490 "Save something the user has told you — a fact, a \
491 preference, a name, a date, a context — so you can pick \
492 it up next conversation. Use whenever the user mentions \
493 something they'd reasonably expect you to recall later \
494 (\"I just started at Quotient\", \"my partner is Maya\"). \
495 Returns the saved item's id.",
496 json_schema_object(serde_json::json!({
497 "type": "object",
498 "properties": {
499 "content": {
500 "type": "string",
501 "description": "The text to remember.",
502 },
503 "source_type": {
504 "type": "string",
505 "description": "Optional source-type tag (default: \"user_message\").",
506 },
507 "source_id": {
508 "type": "string",
509 "description": "Optional upstream id for traceability.",
510 },
511 },
512 "required": ["content"],
513 })),
514 ),
515 Tool::new(
516 "memory_recall",
517 "Search past conversations with this user by topic or \
518 phrase. Returns up to `limit` of the closest matches, \
519 best match first. Use when the user references \
520 something they said before (\"that book I told you \
521 about\", \"the bug we were debugging last week\"). \
522 Skips items the user has deleted.",
523 json_schema_object(serde_json::json!({
524 "type": "object",
525 "properties": {
526 "query": {
527 "type": "string",
528 "description": "The query text.",
529 },
530 "limit": {
531 "type": "integer",
532 "description": "Maximum results (default 5).",
533 "minimum": 1,
534 "maximum": 100,
535 },
536 },
537 "required": ["query"],
538 })),
539 ),
540 Tool::new(
541 "memory_forget",
542 "Delete one saved item by id. Use when the user asks you \
543 to forget something specific (\"forget that I said \
544 X\"). The item stops appearing in future recalls. \
545 Reversible only via backups.",
546 json_schema_object(serde_json::json!({
547 "type": "object",
548 "properties": {
549 "memory_id": {
550 "type": "string",
551 "description": "MemoryId to forget (UUID v7).",
552 },
553 "reason": {
554 "type": "string",
555 "description": "Optional free-form reason (logged, not yet persisted).",
556 },
557 },
558 "required": ["memory_id"],
559 })),
560 ),
561 Tool::new(
562 "memory_inspect",
563 "Show the full record for one saved item — when it was \
564 saved, where it came from, and the full text. Use after \
565 memory_recall when you want the complete content of a \
566 specific hit (recall results may be truncated).",
567 json_schema_object(serde_json::json!({
568 "type": "object",
569 "properties": {
570 "memory_id": {
571 "type": "string",
572 "description": "MemoryId to inspect (UUID v7).",
573 },
574 },
575 "required": ["memory_id"],
576 })),
577 ),
578 Tool::new(
582 "memory_themes",
583 "Recent topics the user has been thinking about. Use to \
584 orient yourself at the start of a conversation, or when \
585 the user asks \"what have I been up to\" / \"what was I \
586 working on last week\". Pass `window_days` to scope \
587 (e.g. 7 for last week); omit for all-time.",
588 json_schema_object(serde_json::json!({
589 "type": "object",
590 "properties": {
591 "window_days": {
592 "type": "integer",
593 "description": "Optional time window in days. Omit for unfiltered.",
594 "minimum": 1,
595 },
596 "limit": {
597 "type": "integer",
598 "description": "Maximum results (default 5).",
599 "minimum": 1,
600 "maximum": 100,
601 },
602 },
603 })),
604 ),
605 Tool::new(
606 "memory_facts_about",
607 "Look up what you remember about a person, project, or \
608 topic — names, dates, preferences, relationships. Use \
609 when the user asks \"what do you know about Alex?\", \
610 \"when did I start at Quotient?\", \"who is Maya?\", or \
611 whenever you need grounded facts about someone or \
612 something before answering. Subject is required (the \
613 person/place/thing you're asking about); narrow further \
614 with `predicate` (\"works_at\", \"lives_in\") or a date \
615 range. Set `include_as_object=true` to also surface \
616 facts where the subject appears on the receiving side of \
617 a relationship (e.g. \"Sam pushes back on PRs about \
618 Maya\" surfaces under facts_about(subject=\"Maya\", \
619 include_as_object=true)). (Backed by \
620 subject-predicate-object triples distilled from past \
621 conversations.) Clients should set a 30s timeout on this \
622 call; if exceeded, retry once or fall back to \
623 `memory_recall`.",
624 json_schema_object(serde_json::json!({
625 "type": "object",
626 "properties": {
627 "subject": {
628 "type": "string",
629 "description": "Subject id to query (e.g. 'Sam').",
630 },
631 "predicate": {
632 "type": "string",
633 "description": "Optional predicate filter (e.g. 'works_at').",
634 },
635 "since_ms": {
636 "type": "integer",
637 "description": "Optional valid_from_ms lower bound (epoch ms).",
638 },
639 "until_ms": {
640 "type": "integer",
641 "description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through.",
642 },
643 "include_as_object": {
644 "type": "boolean",
645 "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.",
646 "default": false,
647 },
648 "limit": {
649 "type": "integer",
650 "description": "Maximum results (default 5).",
651 "minimum": 1,
652 "maximum": 100,
653 },
654 },
655 "required": ["subject"],
656 })),
657 ),
658 Tool::new(
659 "memory_contradictions",
660 "Find places where the user's stated beliefs or facts \
661 disagree across conversations — flag disagreements \
662 before answering. Use whenever you're about to rely on \
663 a remembered fact that could have changed (jobs, \
664 relationships, preferences, opinions); a disagreement \
665 here means the user has told you both X and not-X over \
666 time and you should ask which is current instead of \
667 guessing. Each result shows both conflicting statements \
668 with the topic.",
669 json_schema_object(serde_json::json!({
670 "type": "object",
671 "properties": {
672 "limit": {
673 "type": "integer",
674 "description": "Maximum results (default 5).",
675 "minimum": 1,
676 "maximum": 100,
677 },
678 },
679 })),
680 ),
681 Tool::new(
682 "memory_inspect_cluster",
683 "Show the raw conversations behind one summary. Returns \
684 the one-line topic (the LLM-generated summary) and the \
685 source conversations the topic was built from. Use \
686 after memory_themes when the user asks \"show me the \
687 raw context behind this\" or \"why does Solo think \
688 that about cluster Y\". Source items are truncated to \
689 200 chars unless `full_content` is set.",
690 json_schema_object(serde_json::json!({
691 "type": "object",
692 "properties": {
693 "cluster_id": {
694 "type": "string",
695 "description": "Cluster id to inspect (from memory_themes hits).",
696 },
697 "full_content": {
698 "type": "boolean",
699 "description": "If true, episode content is returned verbatim. Default false (truncate to 200 chars + ellipsis).",
700 },
701 },
702 "required": ["cluster_id"],
703 })),
704 ),
705 Tool::new(
709 "memory_ingest_document",
710 "Read a file from disk and add it to the user's document \
711 library so it becomes searchable alongside past \
712 conversations. Use when the user asks you to remember a \
713 whole file (\"add my notes/runbook.md\", \"ingest this \
714 PDF\"). The file is split into ~500-token chunks and \
715 each chunk is embedded; chunks then surface through \
716 memory_search_docs. Returns the new document id, chunk \
717 count, and a `deduped` flag (true if the same content \
718 was already ingested under another id).",
719 json_schema_object(serde_json::json!({
720 "type": "object",
721 "properties": {
722 "path": {
723 "type": "string",
724 "description": "Server-side absolute path to the file to ingest. The file must be readable by the Solo process.",
725 },
726 },
727 "required": ["path"],
728 })),
729 ),
730 Tool::new(
731 "memory_search_docs",
732 "Search across the user's ingested documents by topic or \
733 phrase. Returns up to `limit` matching chunks, best \
734 match first, each with the parent document's title + \
735 source path so you can cite where the answer came from. \
736 Use when the user asks a question that hinges on \
737 material they've added as a file (\"what does my \
738 runbook say about backups?\", \"find the section in the \
739 notes about the new policy\"). Forgotten documents are \
740 skipped.",
741 json_schema_object(serde_json::json!({
742 "type": "object",
743 "properties": {
744 "query": {
745 "type": "string",
746 "description": "The query text.",
747 },
748 "limit": {
749 "type": "integer",
750 "description": "Maximum results (default 5).",
751 "minimum": 1,
752 "maximum": 100,
753 },
754 },
755 "required": ["query"],
756 })),
757 ),
758 Tool::new(
759 "memory_inspect_document",
760 "Show one document's metadata plus a preview of every \
761 chunk it was split into. Use after memory_search_docs \
762 when the user wants the bigger picture for one hit \
763 (\"show me the whole document this came from\"), or \
764 after memory_list_documents to drill into one entry. \
765 Each chunk preview is truncated to 200 chars.",
766 json_schema_object(serde_json::json!({
767 "type": "object",
768 "properties": {
769 "doc_id": {
770 "type": "string",
771 "description": "Document id to inspect (UUID v7).",
772 },
773 },
774 "required": ["doc_id"],
775 })),
776 ),
777 Tool::new(
778 "memory_list_documents",
779 "List the user's ingested documents, newest first. Use \
780 when the user asks \"what documents have I added?\" or \
781 \"show me my files\". Returns a paginated index — pass \
782 `offset` to page further back. Forgotten documents are \
783 hidden by default; set `include_forgotten=true` to see \
784 them too.",
785 json_schema_object(serde_json::json!({
786 "type": "object",
787 "properties": {
788 "limit": {
789 "type": "integer",
790 "description": "Maximum results per page (default 20).",
791 "minimum": 1,
792 "maximum": 100,
793 },
794 "offset": {
795 "type": "integer",
796 "description": "Number of rows to skip (for paging). Default 0.",
797 "minimum": 0,
798 },
799 "include_forgotten": {
800 "type": "boolean",
801 "description": "If true, also include documents the user has forgotten. Default false.",
802 },
803 },
804 })),
805 ),
806 Tool::new(
807 "memory_forget_document",
808 "Drop one document from the user's library by id. Use \
809 when the user asks you to forget a specific file \
810 (\"forget my old runbook\"). The document's chunks stop \
811 appearing in memory_search_docs and the vectors are \
812 tombstoned in the index. The chunk rows themselves are \
813 kept for forensic value (a future restore command can \
814 undo this).",
815 json_schema_object(serde_json::json!({
816 "type": "object",
817 "properties": {
818 "doc_id": {
819 "type": "string",
820 "description": "Document id to forget (UUID v7).",
821 },
822 },
823 "required": ["doc_id"],
824 })),
825 ),
826 ]
827}
828
829fn json_schema_object(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
830 match value {
831 serde_json::Value::Object(map) => map,
832 _ => panic!("json_schema_object: input must be an object"),
833 }
834}
835
836pub fn tool_names() -> Vec<&'static str> {
845 vec![
846 "memory_remember",
847 "memory_recall",
848 "memory_forget",
849 "memory_inspect",
850 "memory_themes",
851 "memory_facts_about",
852 "memory_contradictions",
853 "memory_inspect_cluster",
854 "memory_ingest_document",
856 "memory_search_docs",
857 "memory_inspect_document",
858 "memory_list_documents",
859 "memory_forget_document",
860 ]
861}
862
863impl SoloMcpServer {
868 async fn handle_remember(
869 &self,
870 args: RememberArgs,
871 ) -> std::result::Result<CallToolResult, McpError> {
872 let content = args.content.trim_end().to_string();
873 if content.is_empty() {
874 return Err(McpError::invalid_params(
875 "memory_remember: content must not be empty".to_string(),
876 None,
877 ));
878 }
879 let embedding: solo_core::Embedding = self
880 .inner
881 .tenant
882 .embedder()
883 .embed(&content)
884 .await
885 .map_err(solo_to_mcp)?;
886 let episode = Episode {
887 memory_id: MemoryId::new(),
888 ts_ms: chrono::Utc::now().timestamp_millis(),
889 source_type: args.source_type.unwrap_or_else(|| "user_message".into()),
890 source_id: args.source_id,
891 content,
892 encoding_context: EncodingContext::default(),
893 provenance: None,
894 confidence: Confidence::new(0.9).unwrap(),
895 strength: 0.5,
896 salience: 0.5,
897 tier: Tier::Hot,
898 };
899 let mid = self
900 .inner
901 .tenant
902 .write()
903 .remember_as(self.inner.audit_principal.clone(), episode, embedding)
904 .await
905 .map_err(solo_to_mcp)?;
906 Ok(CallToolResult::success(vec![Content::text(format!(
907 "remembered {mid}"
908 ))]))
909 }
910
911 async fn handle_recall(
912 &self,
913 args: RecallArgs,
914 ) -> std::result::Result<CallToolResult, McpError> {
915 let result = solo_query::run_recall(
919 self.inner.tenant.as_ref(),
920 self.inner.audit_principal.clone(),
921 &args.query,
922 args.limit,
923 )
924 .await
925 .map_err(solo_to_mcp)?;
926
927 if result.hits.is_empty() {
928 return Ok(CallToolResult::success(vec![Content::text(format!(
929 "no matches (index has {} vectors)",
930 result.index_len
931 ))]));
932 }
933 let body = serde_json::to_string_pretty(&result.hits).unwrap_or_else(|_| String::new());
934 Ok(CallToolResult::success(vec![Content::text(body)]))
935 }
936
937 async fn handle_forget(
938 &self,
939 args: ForgetArgs,
940 ) -> std::result::Result<CallToolResult, McpError> {
941 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
942 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
943 })?;
944 self.inner
945 .tenant
946 .write()
947 .forget_as(self.inner.audit_principal.clone(), mid, args.reason)
948 .await
949 .map_err(solo_to_mcp)?;
950 Ok(CallToolResult::success(vec![Content::text(format!(
951 "forgotten {mid}"
952 ))]))
953 }
954
955 async fn handle_inspect(
956 &self,
957 args: InspectArgs,
958 ) -> std::result::Result<CallToolResult, McpError> {
959 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
960 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
961 })?;
962 let row = solo_query::inspect_one(
964 self.inner.tenant.read(),
965 self.inner.tenant.audit(),
966 self.inner.audit_principal.clone(),
967 mid,
968 )
969 .await
970 .map_err(solo_to_mcp)?;
971 let body = serde_json::to_string_pretty(&row).unwrap_or_else(|_| String::new());
972 Ok(CallToolResult::success(vec![Content::text(body)]))
973 }
974
975 async fn handle_themes(
982 &self,
983 args: ThemesArgs,
984 ) -> std::result::Result<CallToolResult, McpError> {
985 let hits = solo_query::themes(
986 self.inner.tenant.read(),
987 self.inner.tenant.audit(),
988 self.inner.audit_principal.clone(),
989 args.window_days,
990 args.limit,
991 )
992 .await
993 .map_err(solo_to_mcp)?;
994 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
995 Ok(CallToolResult::success(vec![Content::text(body)]))
996 }
997
998 async fn handle_facts_about(
999 &self,
1000 args: FactsAboutArgs,
1001 ) -> std::result::Result<CallToolResult, McpError> {
1002 if args.subject.trim().is_empty() {
1003 return Err(McpError::invalid_params(
1004 "memory_facts_about: subject must not be empty".to_string(),
1005 None,
1006 ));
1007 }
1008 let hits = solo_query::facts_about(
1009 self.inner.tenant.read(),
1010 self.inner.tenant.audit(),
1011 self.inner.audit_principal.clone(),
1012 &args.subject,
1013 &self.inner.user_aliases,
1014 args.include_as_object,
1015 args.predicate.as_deref(),
1016 args.since_ms,
1017 args.until_ms,
1018 args.limit,
1019 )
1020 .await
1021 .map_err(solo_to_mcp)?;
1022 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1023 Ok(CallToolResult::success(vec![Content::text(body)]))
1024 }
1025
1026 async fn handle_contradictions(
1027 &self,
1028 args: ContradictionsArgs,
1029 ) -> std::result::Result<CallToolResult, McpError> {
1030 let hits = solo_query::contradictions(
1031 self.inner.tenant.read(),
1032 self.inner.tenant.audit(),
1033 self.inner.audit_principal.clone(),
1034 args.limit,
1035 )
1036 .await
1037 .map_err(solo_to_mcp)?;
1038 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1039 Ok(CallToolResult::success(vec![Content::text(body)]))
1040 }
1041
1042 async fn handle_inspect_cluster(
1043 &self,
1044 args: InspectClusterArgs,
1045 ) -> std::result::Result<CallToolResult, McpError> {
1046 if args.cluster_id.trim().is_empty() {
1047 return Err(McpError::invalid_params(
1048 "memory_inspect_cluster: cluster_id must not be empty".to_string(),
1049 None,
1050 ));
1051 }
1052 let record = solo_query::inspect_cluster(
1057 self.inner.tenant.read(),
1058 self.inner.tenant.audit(),
1059 self.inner.audit_principal.clone(),
1060 &args.cluster_id,
1061 args.full_content,
1062 )
1063 .await
1064 .map_err(solo_to_mcp)?;
1065 let body = serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
1066 Ok(CallToolResult::success(vec![Content::text(body)]))
1067 }
1068
1069 async fn handle_ingest_document(
1074 &self,
1075 args: IngestDocumentArgs,
1076 ) -> std::result::Result<CallToolResult, McpError> {
1077 if args.path.trim().is_empty() {
1078 return Err(McpError::invalid_params(
1079 "memory_ingest_document: path must not be empty".to_string(),
1080 None,
1081 ));
1082 }
1083 let path = std::path::PathBuf::from(args.path);
1084 let chunk_config = solo_storage::document::ChunkConfig::default();
1088 let report = self
1089 .inner
1090 .tenant
1091 .write()
1092 .ingest_document_as(self.inner.audit_principal.clone(), path, chunk_config)
1093 .await
1094 .map_err(solo_to_mcp)?;
1095 let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| String::new());
1096 Ok(CallToolResult::success(vec![Content::text(body)]))
1097 }
1098
1099 async fn handle_search_docs(
1100 &self,
1101 args: SearchDocsArgs,
1102 ) -> std::result::Result<CallToolResult, McpError> {
1103 let hits = solo_query::run_doc_search(
1107 self.inner.tenant.as_ref(),
1108 self.inner.audit_principal.clone(),
1109 &args.query,
1110 args.limit,
1111 )
1112 .await
1113 .map_err(solo_to_mcp)?;
1114 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1115 Ok(CallToolResult::success(vec![Content::text(body)]))
1116 }
1117
1118 async fn handle_inspect_document(
1119 &self,
1120 args: InspectDocumentArgs,
1121 ) -> std::result::Result<CallToolResult, McpError> {
1122 let doc_id = DocumentId::from_str(&args.doc_id).map_err(|e| {
1123 McpError::invalid_params(format!("invalid doc_id: {e}"), None)
1124 })?;
1125 let result_opt = solo_query::inspect_document(
1126 self.inner.tenant.read(),
1127 self.inner.tenant.audit(),
1128 self.inner.audit_principal.clone(),
1129 &doc_id,
1130 )
1131 .await
1132 .map_err(solo_to_mcp)?;
1133 match result_opt {
1134 Some(record) => {
1135 let body =
1136 serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
1137 Ok(CallToolResult::success(vec![Content::text(body)]))
1138 }
1139 None => Err(McpError::invalid_params(
1140 format!("document {doc_id} not found"),
1141 None,
1142 )),
1143 }
1144 }
1145
1146 async fn handle_list_documents(
1147 &self,
1148 args: ListDocumentsArgs,
1149 ) -> std::result::Result<CallToolResult, McpError> {
1150 let rows = solo_query::list_documents(
1151 self.inner.tenant.read(),
1152 self.inner.tenant.audit(),
1153 self.inner.audit_principal.clone(),
1154 args.limit,
1155 args.offset,
1156 args.include_forgotten,
1157 )
1158 .await
1159 .map_err(solo_to_mcp)?;
1160 let body = serde_json::to_string_pretty(&rows).unwrap_or_else(|_| String::new());
1161 Ok(CallToolResult::success(vec![Content::text(body)]))
1162 }
1163
1164 async fn handle_forget_document(
1165 &self,
1166 args: ForgetDocumentArgs,
1167 ) -> std::result::Result<CallToolResult, McpError> {
1168 let doc_id = DocumentId::from_str(&args.doc_id).map_err(|e| {
1169 McpError::invalid_params(format!("invalid doc_id: {e}"), None)
1170 })?;
1171 let report = self
1172 .inner
1173 .tenant
1174 .write()
1175 .forget_document_as(self.inner.audit_principal.clone(), doc_id)
1176 .await
1177 .map_err(solo_to_mcp)?;
1178 let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| String::new());
1179 Ok(CallToolResult::success(vec![Content::text(body)]))
1180 }
1181}
1182
1183#[cfg(test)]
1184mod dispatch_tests {
1185 use super::*;
1197 use serde_json::json;
1198 use solo_core::VectorIndex;
1199 use solo_storage::test_support::StubVectorIndex;
1200 use solo_storage::{
1201 EmbedderConfig, IdentityConfig, KeyMaterial, ReaderPool, SoloConfig,
1202 StubEmbedder, TenantHandle, TenantRegistry, WriterActor, WriterSpawn,
1203 };
1204 use std::sync::Arc as StdArc;
1205
1206 fn fake_config(dim: u32) -> SoloConfig {
1207 SoloConfig {
1208 schema_version: 1,
1209 salt_hex: "00000000000000000000000000000000".to_string(),
1210 embedder: EmbedderConfig {
1211 name: "stub".to_string(),
1212 version: "v1".to_string(),
1213 dim,
1214 dtype: "f32".to_string(),
1215 },
1216 identity: IdentityConfig::default(),
1217 documents: solo_storage::DocumentConfig::default(),
1218 auth: None,
1219 audit: solo_storage::AuditSettings::default(),
1220 redaction: solo_storage::RedactionConfig::default(),
1221 }
1222 }
1223
1224 struct Harness {
1225 server: SoloMcpServer,
1226 _tmp: tempfile::TempDir,
1227 write_handle_extra: Option<solo_storage::WriteHandle>,
1228 join: Option<std::thread::JoinHandle<()>>,
1229 }
1230
1231 impl Harness {
1232 fn new(runtime: &tokio::runtime::Runtime) -> Self {
1233 let tmp = tempfile::TempDir::new().unwrap();
1234 let dim = 16usize;
1235 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1236 let embedder: StdArc<dyn solo_core::Embedder> = StdArc::new(StubEmbedder::new("stub", "v1", dim));
1237
1238 let conn = solo_storage::test_support::open_test_db_at(&tmp.path().join("test.db"));
1239 let WriterSpawn { handle, join } = WriterActor::spawn(conn, hnsw.clone());
1240
1241 let path = tmp.path().join("test.db");
1244 let pool: ReaderPool =
1245 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1246
1247 let tenant_id = solo_core::TenantId::default_tenant();
1248 let tenant_handle = StdArc::new(
1249 TenantHandle::from_parts_for_tests(
1250 tenant_id.clone(),
1251 fake_config(dim as u32),
1252 path.clone(),
1253 tmp.path().to_path_buf(),
1254 0, hnsw,
1256 embedder.clone(),
1257 handle.clone(),
1258 std::thread::spawn(|| {}),
1259 pool,
1260 ),
1261 );
1262 let key = KeyMaterial::from_bytes_for_tests([0u8; 32]);
1263 let registry = StdArc::new(TenantRegistry::for_tests_with_single_tenant(
1264 tmp.path().to_path_buf(),
1265 key,
1266 embedder,
1267 tenant_handle.clone(),
1268 ));
1269 let server = SoloMcpServer::new_for_tenant(registry, tenant_handle, Vec::new());
1270 Harness {
1271 server,
1272 _tmp: tmp,
1273 write_handle_extra: Some(handle),
1274 join: Some(join),
1275 }
1276 }
1277
1278 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1279 let join = self.join.take();
1285 let extra = self.write_handle_extra.take();
1286 runtime.block_on(async move {
1287 drop(extra);
1288 drop(self.server);
1289 drop(self._tmp);
1290 if let Some(join) = join {
1291 let (tx, rx) = std::sync::mpsc::channel();
1292 std::thread::spawn(move || {
1293 let _ = tx.send(join.join());
1294 });
1295 tokio::task::spawn_blocking(move || {
1296 rx.recv_timeout(std::time::Duration::from_secs(5))
1297 })
1298 .await
1299 .expect("blocking task")
1300 .expect("writer thread did not exit within 5s")
1301 .expect("writer thread panicked");
1302 }
1303 });
1304 }
1305 }
1306
1307 fn rt() -> tokio::runtime::Runtime {
1308 tokio::runtime::Builder::new_multi_thread()
1309 .worker_threads(2)
1310 .enable_all()
1311 .build()
1312 .unwrap()
1313 }
1314
1315 fn first_text(r: &rmcp::model::CallToolResult) -> String {
1320 let first = r.content.first().expect("at least one content item");
1321 let v = serde_json::to_value(first).expect("content serialises");
1322 v.get("text")
1323 .and_then(|t| t.as_str())
1324 .map(|s| s.to_string())
1325 .unwrap_or_else(|| format!("{v}"))
1326 }
1327
1328 #[test]
1329 fn tools_list_returns_thirteen_canonical_tools() {
1330 let runtime = rt();
1331 let h = Harness::new(&runtime);
1332 let tools = h.server.dispatch_list_tools();
1333 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1334 assert_eq!(
1335 names,
1336 vec![
1337 "memory_remember",
1338 "memory_recall",
1339 "memory_forget",
1340 "memory_inspect",
1341 "memory_themes",
1343 "memory_facts_about",
1344 "memory_contradictions",
1345 "memory_inspect_cluster",
1347 "memory_ingest_document",
1349 "memory_search_docs",
1350 "memory_inspect_document",
1351 "memory_list_documents",
1352 "memory_forget_document",
1353 ]
1354 );
1355 for t in &tools {
1356 assert!(!t.description.is_empty(), "{} description empty", t.name);
1357 let _schema = t.schema_as_json_value();
1358 }
1365 h.shutdown(&runtime);
1366 }
1367
1368 #[test]
1369 fn themes_returns_json_array_on_empty_db() {
1370 let runtime = rt();
1371 let h = Harness::new(&runtime);
1372 runtime.block_on(async {
1373 let r = h
1374 .server
1375 .dispatch_tool("memory_themes", json!({}))
1376 .await
1377 .expect("themes succeeds");
1378 let text = first_text(&r);
1379 let v: serde_json::Value =
1381 serde_json::from_str(&text).expect("parses as json");
1382 assert!(v.is_array(), "expected array, got: {text}");
1383 assert_eq!(v.as_array().unwrap().len(), 0);
1384 });
1385 h.shutdown(&runtime);
1386 }
1387
1388 #[test]
1389 fn themes_passes_through_window_and_limit_args() {
1390 let runtime = rt();
1391 let h = Harness::new(&runtime);
1392 runtime.block_on(async {
1393 let r = h
1395 .server
1396 .dispatch_tool(
1397 "memory_themes",
1398 json!({ "window_days": 7, "limit": 20 }),
1399 )
1400 .await
1401 .expect("themes with args succeeds");
1402 let text = first_text(&r);
1403 let v: serde_json::Value =
1404 serde_json::from_str(&text).expect("parses as json");
1405 assert!(v.is_array());
1406 });
1407 h.shutdown(&runtime);
1408 }
1409
1410 #[test]
1411 fn facts_about_rejects_empty_subject() {
1412 let runtime = rt();
1413 let h = Harness::new(&runtime);
1414 runtime.block_on(async {
1415 let err = h
1416 .server
1417 .dispatch_tool(
1418 "memory_facts_about",
1419 json!({ "subject": " " }),
1420 )
1421 .await
1422 .expect_err("empty subject must error");
1423 let s = format!("{err:?}");
1426 assert!(
1427 s.to_lowercase().contains("subject")
1428 || s.to_lowercase().contains("invalid"),
1429 "got: {s}"
1430 );
1431 });
1432 h.shutdown(&runtime);
1433 }
1434
1435 #[test]
1436 fn facts_about_returns_array_for_unknown_subject() {
1437 let runtime = rt();
1438 let h = Harness::new(&runtime);
1439 runtime.block_on(async {
1440 let r = h
1441 .server
1442 .dispatch_tool(
1443 "memory_facts_about",
1444 json!({ "subject": "NobodyKnowsThisSubject" }),
1445 )
1446 .await
1447 .expect("facts_about with unknown subject succeeds");
1448 let text = first_text(&r);
1449 let v: serde_json::Value =
1450 serde_json::from_str(&text).expect("parses as json");
1451 assert_eq!(v.as_array().unwrap().len(), 0);
1452 });
1453 h.shutdown(&runtime);
1454 }
1455
1456 #[test]
1457 fn facts_about_accepts_include_as_object_arg() {
1458 let runtime = rt();
1466 let h = Harness::new(&runtime);
1467 runtime.block_on(async {
1468 let r = h
1470 .server
1471 .dispatch_tool(
1472 "memory_facts_about",
1473 json!({ "subject": "Maya", "include_as_object": true }),
1474 )
1475 .await
1476 .expect("dispatch with include_as_object=true succeeds");
1477 let v: serde_json::Value = serde_json::from_str(&first_text(&r))
1478 .expect("parses as json");
1479 assert_eq!(v.as_array().unwrap().len(), 0);
1480
1481 let r = h
1483 .server
1484 .dispatch_tool(
1485 "memory_facts_about",
1486 json!({ "subject": "Maya" }),
1487 )
1488 .await
1489 .expect("dispatch without include_as_object succeeds (default false)");
1490 let v: serde_json::Value = serde_json::from_str(&first_text(&r))
1491 .expect("parses as json");
1492 assert_eq!(v.as_array().unwrap().len(), 0);
1493 });
1494 h.shutdown(&runtime);
1495 }
1496
1497 #[test]
1498 fn contradictions_returns_json_array_on_empty_db() {
1499 let runtime = rt();
1500 let h = Harness::new(&runtime);
1501 runtime.block_on(async {
1502 let r = h
1503 .server
1504 .dispatch_tool("memory_contradictions", json!({}))
1505 .await
1506 .expect("contradictions succeeds");
1507 let text = first_text(&r);
1508 let v: serde_json::Value =
1509 serde_json::from_str(&text).expect("parses as json");
1510 assert!(v.is_array());
1511 assert_eq!(v.as_array().unwrap().len(), 0);
1512 });
1513 h.shutdown(&runtime);
1514 }
1515
1516 #[test]
1517 fn remember_then_recall_round_trip() {
1518 let runtime = rt();
1519 let h = Harness::new(&runtime);
1520 runtime.block_on(async {
1526 let r = h
1527 .server
1528 .dispatch_tool("memory_remember", json!({ "content": "the cat sat on the mat" }))
1529 .await
1530 .expect("remember succeeds");
1531 let text = first_text(&r);
1532 assert!(text.starts_with("remembered "), "got: {text}");
1533
1534 let r = h
1535 .server
1536 .dispatch_tool(
1537 "memory_recall",
1538 json!({ "query": "the cat sat on the mat", "limit": 5 }),
1539 )
1540 .await
1541 .expect("recall succeeds");
1542 let text = first_text(&r);
1543 assert!(text.contains("the cat sat on the mat"), "got: {text}");
1544 });
1545 h.shutdown(&runtime);
1546 }
1547
1548 #[test]
1549 fn forget_excludes_row_from_subsequent_recall() {
1550 let runtime = rt();
1551 let h = Harness::new(&runtime);
1552
1553 runtime.block_on(async {
1554 let r = h
1555 .server
1556 .dispatch_tool("memory_remember", json!({ "content": "to be forgotten" }))
1557 .await
1558 .unwrap();
1559 let text = first_text(&r);
1560 let mid = text.strip_prefix("remembered ").unwrap().to_string();
1561
1562 h.server
1563 .dispatch_tool(
1564 "memory_forget",
1565 json!({ "memory_id": mid, "reason": "test" }),
1566 )
1567 .await
1568 .expect("forget succeeds");
1569
1570 let r = h
1571 .server
1572 .dispatch_tool(
1573 "memory_recall",
1574 json!({ "query": "to be forgotten", "limit": 5 }),
1575 )
1576 .await
1577 .unwrap();
1578 let text = first_text(&r);
1579 assert!(
1580 !text.contains(r#""content": "to be forgotten""#),
1581 "forgotten row should be excluded; got: {text}"
1582 );
1583 });
1584 h.shutdown(&runtime);
1585 }
1586
1587 #[test]
1588 fn empty_remember_returns_invalid_params() {
1589 let runtime = rt();
1590 let h = Harness::new(&runtime);
1591 runtime.block_on(async {
1592 let err = h
1593 .server
1594 .dispatch_tool("memory_remember", json!({ "content": "" }))
1595 .await
1596 .unwrap_err();
1597 assert!(format!("{err:?}").contains("must not be empty"));
1598 });
1599 h.shutdown(&runtime);
1600 }
1601
1602 #[test]
1603 fn empty_recall_query_returns_invalid_params() {
1604 let runtime = rt();
1605 let h = Harness::new(&runtime);
1606 runtime.block_on(async {
1607 let err = h
1608 .server
1609 .dispatch_tool("memory_recall", json!({ "query": " " }))
1610 .await
1611 .unwrap_err();
1612 assert!(format!("{err:?}").contains("must not be empty"));
1613 });
1614 h.shutdown(&runtime);
1615 }
1616
1617 #[test]
1618 fn inspect_with_invalid_id_returns_invalid_params() {
1619 let runtime = rt();
1620 let h = Harness::new(&runtime);
1621 runtime.block_on(async {
1622 let err = h
1623 .server
1624 .dispatch_tool("memory_inspect", json!({ "memory_id": "not-a-uuid" }))
1625 .await
1626 .unwrap_err();
1627 assert!(format!("{err:?}").contains("invalid memory_id"));
1628 });
1629 h.shutdown(&runtime);
1630 }
1631
1632 #[test]
1633 fn forget_unknown_id_returns_invalid_params() {
1634 let runtime = rt();
1635 let h = Harness::new(&runtime);
1636 runtime.block_on(async {
1637 let err = h
1641 .server
1642 .dispatch_tool(
1643 "memory_forget",
1644 json!({ "memory_id": "00000000-0000-7000-8000-000000000000" }),
1645 )
1646 .await
1647 .unwrap_err();
1648 assert!(format!("{err:?}").contains("not found"));
1649 });
1650 h.shutdown(&runtime);
1651 }
1652
1653 #[test]
1654 fn unknown_tool_name_returns_invalid_params() {
1655 let runtime = rt();
1656 let h = Harness::new(&runtime);
1657 runtime.block_on(async {
1658 let err = h
1659 .server
1660 .dispatch_tool("memory.summon", json!({}))
1661 .await
1662 .unwrap_err();
1663 assert!(format!("{err:?}").contains("unknown tool"));
1664 });
1665 h.shutdown(&runtime);
1666 }
1667
1668 #[test]
1703 fn tool_names_match_cross_provider_regex() {
1704 fn passes_anthropic(name: &str) -> bool {
1706 let len = name.len();
1707 if !(1..=64).contains(&len) {
1708 return false;
1709 }
1710 name.chars()
1711 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1712 }
1713
1714 fn passes_openai(name: &str) -> bool {
1717 let len = name.len();
1718 if !(1..=64).contains(&len) {
1719 return false;
1720 }
1721 let mut chars = name.chars();
1722 let first = match chars.next() {
1723 Some(c) => c,
1724 None => return false,
1725 };
1726 if !(first.is_ascii_alphabetic() || first == '_') {
1727 return false;
1728 }
1729 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1730 }
1731
1732 fn passes_gemini(name: &str) -> bool {
1737 let len = name.len();
1738 if !(1..=63).contains(&len) {
1739 return false;
1740 }
1741 let mut chars = name.chars();
1742 let first = match chars.next() {
1743 Some(c) => c,
1744 None => return false,
1745 };
1746 if !(first.is_ascii_alphabetic() || first == '_') {
1747 return false;
1748 }
1749 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1750 }
1751
1752 let tools = build_tools();
1753 assert_eq!(
1754 tools.len(),
1755 13,
1756 "expected 13 tools in v0.7.0 (8 v0.5.x + 5 document tools)"
1757 );
1758 let tool_name_strings: Vec<String> =
1760 tools.iter().map(|t| t.name.to_string()).collect();
1761 let public_names: Vec<String> =
1762 super::tool_names().iter().map(|s| s.to_string()).collect();
1763 assert_eq!(
1764 tool_name_strings, public_names,
1765 "tool_names() drifted from build_tools() — keep them in sync"
1766 );
1767
1768 for t in tools {
1769 assert!(
1770 passes_anthropic(&t.name),
1771 "tool name {:?} fails Anthropic regex \
1772 ^[a-zA-Z0-9_-]{{1,64}}$ — see v0.3 lesson #8",
1773 t.name
1774 );
1775 assert!(
1776 passes_openai(&t.name),
1777 "tool name {:?} fails OpenAI function-calling regex \
1778 ^[a-zA-Z_][a-zA-Z0-9_-]*$ (len ≤ 64)",
1779 t.name
1780 );
1781 assert!(
1782 passes_gemini(&t.name),
1783 "tool name {:?} fails Gemini function-calling regex \
1784 ^[a-zA-Z_][a-zA-Z0-9_]*$ (len ≤ 63, strict)",
1785 t.name
1786 );
1787 }
1788 }
1789
1790 #[test]
1807 fn tool_descriptions_avoid_internal_jargon() {
1808 const FORBIDDEN: &[&str] = &[
1812 "SPO",
1813 "Steward",
1814 "Steward-flagged",
1815 "LEFT JOIN",
1816 "candidate pair",
1817 "candidate_pair",
1818 "tagged_with",
1819 ];
1820
1821 fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
1822 haystack.to_lowercase().contains(&needle.to_lowercase())
1823 }
1824
1825 for t in build_tools() {
1827 for term in FORBIDDEN {
1828 assert!(
1829 !contains_case_insensitive(&t.description, term),
1830 "tool {:?} description contains forbidden jargon \
1831 {:?} — rewrite in plain English (see v0.5.0 \
1832 Priority 4)",
1833 t.name,
1834 term,
1835 );
1836 }
1837 }
1838
1839 let server_info = harness_server_info();
1842 let instructions = server_info
1843 .instructions
1844 .as_deref()
1845 .expect("get_info() must set instructions");
1846 for term in FORBIDDEN {
1847 assert!(
1848 !contains_case_insensitive(instructions, term),
1849 "get_info().instructions contains forbidden jargon \
1850 {:?} — rewrite in plain English",
1851 term,
1852 );
1853 }
1854 }
1855
1856 fn harness_server_info() -> rmcp::model::ServerInfo {
1863 let runtime = rt();
1864 let h = Harness::new(&runtime);
1865 let info = ServerHandler::get_info(&h.server);
1866 h.shutdown(&runtime);
1867 info
1868 }
1869
1870 #[test]
1873 fn inspect_cluster_unknown_id_returns_invalid_params() {
1874 let runtime = rt();
1878 let h = Harness::new(&runtime);
1879 runtime.block_on(async {
1880 let err = h
1881 .server
1882 .dispatch_tool(
1883 "memory_inspect_cluster",
1884 json!({ "cluster_id": "no-such-cluster" }),
1885 )
1886 .await
1887 .expect_err("unknown cluster must error");
1888 let s = format!("{err:?}");
1889 assert!(
1890 s.contains("no-such-cluster") || s.to_lowercase().contains("not found"),
1891 "expected error to mention the missing cluster id; got: {s}"
1892 );
1893 });
1894 h.shutdown(&runtime);
1895 }
1896
1897 #[test]
1898 fn inspect_cluster_rejects_empty_id() {
1899 let runtime = rt();
1900 let h = Harness::new(&runtime);
1901 runtime.block_on(async {
1902 let err = h
1903 .server
1904 .dispatch_tool(
1905 "memory_inspect_cluster",
1906 json!({ "cluster_id": " " }),
1907 )
1908 .await
1909 .expect_err("blank cluster_id must error");
1910 let s = format!("{err:?}");
1911 assert!(
1912 s.to_lowercase().contains("cluster_id")
1913 || s.to_lowercase().contains("must not be empty"),
1914 "got: {s}"
1915 );
1916 });
1917 h.shutdown(&runtime);
1918 }
1919
1920 #[test]
1936 fn ingest_document_args_parse_with_required_path() {
1937 let v: IngestDocumentArgs =
1938 serde_json::from_value(json!({ "path": "/tmp/notes.md" })).expect("parses");
1939 assert_eq!(v.path, "/tmp/notes.md");
1940 let err = serde_json::from_value::<IngestDocumentArgs>(json!({})).unwrap_err();
1942 assert!(format!("{err}").contains("path"));
1943 }
1944
1945 #[test]
1946 fn search_docs_args_parse_with_default_limit() {
1947 let v: SearchDocsArgs =
1948 serde_json::from_value(json!({ "query": "backups" })).expect("parses");
1949 assert_eq!(v.query, "backups");
1950 assert_eq!(v.limit, 5, "default limit must be 5");
1951 let v: SearchDocsArgs =
1952 serde_json::from_value(json!({ "query": "backups", "limit": 20 })).expect("parses");
1953 assert_eq!(v.limit, 20);
1954 }
1955
1956 #[test]
1957 fn inspect_document_args_parse_with_required_doc_id() {
1958 let v: InspectDocumentArgs =
1959 serde_json::from_value(json!({ "doc_id": "abc" })).expect("parses");
1960 assert_eq!(v.doc_id, "abc");
1961 let err = serde_json::from_value::<InspectDocumentArgs>(json!({})).unwrap_err();
1962 assert!(format!("{err}").contains("doc_id"));
1963 }
1964
1965 #[test]
1966 fn list_documents_args_parse_with_all_defaults() {
1967 let v: ListDocumentsArgs = serde_json::from_value(json!({})).expect("parses");
1968 assert_eq!(v.limit, 20, "default limit must be 20");
1969 assert_eq!(v.offset, 0, "default offset must be 0");
1970 assert!(!v.include_forgotten, "default include_forgotten must be false");
1971 let v: ListDocumentsArgs = serde_json::from_value(
1972 json!({ "limit": 5, "offset": 10, "include_forgotten": true }),
1973 )
1974 .expect("parses");
1975 assert_eq!(v.limit, 5);
1976 assert_eq!(v.offset, 10);
1977 assert!(v.include_forgotten);
1978 }
1979
1980 #[test]
1981 fn forget_document_args_parse_with_required_doc_id() {
1982 let v: ForgetDocumentArgs =
1983 serde_json::from_value(json!({ "doc_id": "abc" })).expect("parses");
1984 assert_eq!(v.doc_id, "abc");
1985 let err = serde_json::from_value::<ForgetDocumentArgs>(json!({})).unwrap_err();
1986 assert!(format!("{err}").contains("doc_id"));
1987 }
1988
1989 #[test]
1990 fn ingest_document_rejects_empty_path() {
1991 let runtime = rt();
1994 let h = Harness::new(&runtime);
1995 runtime.block_on(async {
1996 let err = h
1997 .server
1998 .dispatch_tool("memory_ingest_document", json!({ "path": "" }))
1999 .await
2000 .expect_err("empty path must error");
2001 let s = format!("{err:?}");
2002 assert!(
2003 s.to_lowercase().contains("path")
2004 || s.to_lowercase().contains("must not be empty"),
2005 "got: {s}"
2006 );
2007 });
2008 h.shutdown(&runtime);
2009 }
2010
2011 #[test]
2012 fn search_docs_rejects_empty_query() {
2013 let runtime = rt();
2016 let h = Harness::new(&runtime);
2017 runtime.block_on(async {
2018 let err = h
2019 .server
2020 .dispatch_tool("memory_search_docs", json!({ "query": " " }))
2021 .await
2022 .expect_err("empty query must error");
2023 let s = format!("{err:?}");
2024 assert!(
2025 s.to_lowercase().contains("must not be empty")
2026 || s.to_lowercase().contains("invalid"),
2027 "got: {s}"
2028 );
2029 });
2030 h.shutdown(&runtime);
2031 }
2032
2033 #[test]
2034 fn inspect_document_unknown_id_returns_invalid_params() {
2035 let runtime = rt();
2038 let h = Harness::new(&runtime);
2039 runtime.block_on(async {
2040 let err = h
2041 .server
2042 .dispatch_tool(
2043 "memory_inspect_document",
2044 json!({ "doc_id": "00000000-0000-7000-8000-000000000000" }),
2045 )
2046 .await
2047 .expect_err("unknown doc must error");
2048 let s = format!("{err:?}");
2049 assert!(
2050 s.to_lowercase().contains("not found"),
2051 "expected 'not found' message; got: {s}"
2052 );
2053 });
2054 h.shutdown(&runtime);
2055 }
2056
2057 #[test]
2058 fn inspect_document_rejects_malformed_id() {
2059 let runtime = rt();
2060 let h = Harness::new(&runtime);
2061 runtime.block_on(async {
2062 let err = h
2063 .server
2064 .dispatch_tool(
2065 "memory_inspect_document",
2066 json!({ "doc_id": "not-a-uuid" }),
2067 )
2068 .await
2069 .expect_err("malformed doc_id must error");
2070 let s = format!("{err:?}");
2071 assert!(s.contains("invalid doc_id"), "got: {s}");
2072 });
2073 h.shutdown(&runtime);
2074 }
2075
2076 #[test]
2077 fn list_documents_returns_empty_array_on_empty_db() {
2078 let runtime = rt();
2079 let h = Harness::new(&runtime);
2080 runtime.block_on(async {
2081 let r = h
2082 .server
2083 .dispatch_tool("memory_list_documents", json!({}))
2084 .await
2085 .expect("list succeeds");
2086 let text = first_text(&r);
2087 let v: serde_json::Value =
2088 serde_json::from_str(&text).expect("parses as json");
2089 assert!(v.is_array(), "expected array, got: {text}");
2090 assert_eq!(v.as_array().unwrap().len(), 0);
2091 });
2092 h.shutdown(&runtime);
2093 }
2094
2095 #[test]
2096 fn list_documents_passes_through_limit_offset_include_args() {
2097 let runtime = rt();
2098 let h = Harness::new(&runtime);
2099 runtime.block_on(async {
2100 let r = h
2101 .server
2102 .dispatch_tool(
2103 "memory_list_documents",
2104 json!({ "limit": 5, "offset": 10, "include_forgotten": true }),
2105 )
2106 .await
2107 .expect("list with args succeeds");
2108 let text = first_text(&r);
2109 let v: serde_json::Value =
2110 serde_json::from_str(&text).expect("parses as json");
2111 assert!(v.is_array());
2112 });
2113 h.shutdown(&runtime);
2114 }
2115
2116 #[test]
2117 fn forget_document_rejects_malformed_id() {
2118 let runtime = rt();
2119 let h = Harness::new(&runtime);
2120 runtime.block_on(async {
2121 let err = h
2122 .server
2123 .dispatch_tool(
2124 "memory_forget_document",
2125 json!({ "doc_id": "not-a-uuid" }),
2126 )
2127 .await
2128 .expect_err("malformed doc_id must error");
2129 let s = format!("{err:?}");
2130 assert!(s.contains("invalid doc_id"), "got: {s}");
2131 });
2132 h.shutdown(&runtime);
2133 }
2134}
2135
2136