1use std::sync::Arc;
56
57use rmcp::handler::server::ServerHandler;
58use rmcp::model::{
59 CallToolRequestParams as CallToolRequestParam, CallToolResult, Content, Implementation,
60 InitializeRequestParams, InitializeResult, ListToolsResult,
61 PaginatedRequestParams as PaginatedRequestParam, ProtocolVersion,
62 ServerCapabilities, ServerInfo, Tool,
63};
64use rmcp::service::{RequestContext, RoleServer};
65use rmcp::{ErrorData as McpError, ServiceExt};
66use serde::{Deserialize, Serialize};
67use solo_core::{
68 Confidence, DocumentId, EncodingContext, Episode, MemoryId, Tier,
69};
70use solo_storage::{TenantHandle, TenantRegistry};
71use std::str::FromStr;
72
73#[derive(Clone)]
83pub struct SoloMcpServer {
84 inner: Arc<Inner>,
85}
86
87struct Inner {
88 #[allow(dead_code)]
93 registry: Arc<TenantRegistry>,
94 tenant: Arc<TenantHandle>,
97 user_aliases: Vec<String>,
103 audit_principal: Option<String>,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum InitializeDecision {
122 Allow,
127 PopulateSamplingSteward,
131 RejectMissingSamplingCapability,
135}
136
137pub fn initialize_decision(
144 llm_settings: &Option<solo_storage::LlmSettings>,
145 peer_sampling_supported: bool,
146) -> InitializeDecision {
147 match llm_settings {
148 Some(settings) if settings.requires_mcp_peer() => {
149 if peer_sampling_supported {
150 InitializeDecision::PopulateSamplingSteward
151 } else {
152 InitializeDecision::RejectMissingSamplingCapability
153 }
154 }
155 _ => InitializeDecision::Allow,
156 }
157}
158
159pub fn sampling_capability_missing_error_message() -> String {
169 [
170 "LLM backend `mcp_sampling` requires a connected MCP client that",
171 "advertises the `sampling` capability at initialize. Either the",
172 "current MCP client does not support sampling, or this Solo",
173 "process is running in daemon-only mode (no peer to call back).",
174 "",
175 "Pick one of:",
176 "",
177 " # Anthropic (hosted):",
178 " [llm]",
179 " mode = \"anthropic\"",
180 " api_key_env = \"ANTHROPIC_API_KEY\"",
181 " model = \"claude-sonnet-4-6\"",
182 "",
183 " # OpenAI (hosted):",
184 " [llm]",
185 " mode = \"openai\"",
186 " api_key_env = \"OPENAI_API_KEY\"",
187 " model = \"gpt-5o\"",
188 "",
189 " # Ollama (local daemon):",
190 " [llm]",
191 " mode = \"ollama\"",
192 " base_url = \"http://localhost:11434\"",
193 " model = \"qwen3-coder:30b\"",
194 "",
195 " # None (cluster-only; abstractions skipped):",
196 " [llm]",
197 " mode = \"none\"",
198 "",
199 "See docs/releases/v0.9.0.md \u{00a7}LLM-backend selection for details.",
200 ]
201 .join("\n")
202}
203
204pub const ENV_MCP_PRINCIPAL_TOKEN: &str = "SOLO_MCP_PRINCIPAL_TOKEN";
219
220pub fn resolve_mcp_principal(header_value: Option<&str>) -> Option<String> {
237 if let Some(h) = header_value {
239 if let Some(token) = h.strip_prefix("Bearer ") {
240 let trimmed = token.trim();
241 if !trimmed.is_empty() {
242 return Some(trimmed.to_string());
248 }
249 }
250 }
251 match std::env::var(ENV_MCP_PRINCIPAL_TOKEN) {
253 Ok(v) => {
254 let trimmed = v.trim();
255 if trimmed.is_empty() {
256 None
257 } else {
258 Some(trimmed.to_string())
259 }
260 }
261 Err(_) => None,
262 }
263}
264
265impl SoloMcpServer {
266 pub fn new_for_tenant(
276 registry: Arc<TenantRegistry>,
277 tenant: Arc<TenantHandle>,
278 user_aliases: Vec<String>,
279 ) -> Self {
280 let principal = resolve_mcp_principal(None);
281 Self::new_for_tenant_with_principal(registry, tenant, user_aliases, principal)
282 }
283
284 pub fn new_for_tenant_with_principal(
297 registry: Arc<TenantRegistry>,
298 tenant: Arc<TenantHandle>,
299 user_aliases: Vec<String>,
300 audit_principal: Option<String>,
301 ) -> Self {
302 Self {
303 inner: Arc::new(Inner {
304 registry,
305 tenant,
306 user_aliases,
307 audit_principal,
308 }),
309 }
310 }
311}
312
313pub async fn serve_stdio(server: SoloMcpServer) -> anyhow::Result<()> {
316 use rmcp::transport::io::stdio;
317 let (stdin, stdout) = stdio();
318 let running = server.serve((stdin, stdout)).await?;
319 running.waiting().await?;
320 Ok(())
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct RememberArgs {
329 pub content: String,
330 #[serde(default)]
331 pub source_type: Option<String>,
332 #[serde(default)]
333 pub source_id: Option<String>,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct RecallArgs {
338 pub query: String,
339 #[serde(default = "default_limit")]
340 pub limit: usize,
341}
342
343fn default_limit() -> usize {
344 5
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct ForgetArgs {
349 pub memory_id: String,
350 #[serde(default = "default_forget_reason")]
351 pub reason: String,
352}
353
354fn default_forget_reason() -> String {
355 "user-initiated via MCP".into()
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct InspectArgs {
360 pub memory_id: String,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct ThemesArgs {
370 #[serde(default)]
374 pub window_days: Option<i64>,
375 #[serde(default = "default_limit")]
376 pub limit: usize,
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct FactsAboutArgs {
381 pub subject: String,
384 #[serde(default)]
385 pub predicate: Option<String>,
386 #[serde(default)]
387 pub since_ms: Option<i64>,
388 #[serde(default)]
389 pub until_ms: Option<i64>,
390 #[serde(default)]
395 pub include_as_object: bool,
396 #[serde(default = "default_limit")]
397 pub limit: usize,
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct ContradictionsArgs {
402 #[serde(default = "default_limit")]
403 pub limit: usize,
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct InspectClusterArgs {
411 pub cluster_id: String,
412 #[serde(default)]
417 pub full_content: bool,
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct IngestDocumentArgs {
425 pub path: String,
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct SearchDocsArgs {
434 pub query: String,
435 #[serde(default = "default_search_docs_limit")]
436 pub limit: usize,
437}
438
439fn default_search_docs_limit() -> usize {
440 5
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct InspectDocumentArgs {
445 pub doc_id: String,
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize)]
449pub struct ListDocumentsArgs {
450 #[serde(default = "default_list_documents_limit")]
451 pub limit: usize,
452 #[serde(default)]
453 pub offset: usize,
454 #[serde(default)]
458 pub include_forgotten: bool,
459}
460
461fn default_list_documents_limit() -> usize {
462 20
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct ForgetDocumentArgs {
467 pub doc_id: String,
468}
469
470impl ServerHandler for SoloMcpServer {
475 fn get_info(&self) -> ServerInfo {
476 let capabilities = ServerCapabilities::builder()
480 .enable_tools()
481 .build();
482 let mut info = ServerInfo::default();
483 info.protocol_version = ProtocolVersion::default();
484 info.capabilities = capabilities;
485 info.server_info = Implementation::from_build_env();
486 info.instructions = Some(
487 "Solo gives you persistent memory across conversations \
488 with this user — what they've told you before, the \
489 people and projects in their life, and where their \
490 stated beliefs have shifted, plus a library of \
491 documents the user has ingested (notes, runbooks, \
492 PDFs). Reach for these tools whenever the user \
493 references something from earlier (\"like I \
494 mentioned\", \"the project I'm working on\", \"my \
495 friend Alex\", \"the notes I uploaded last week\") \
496 or asks a question that hinges on personal context \
497 or document content you don't have in the current \
498 chat. \
499 \n\nTools to write or look up specific moments: \
500 memory_remember (save something worth keeping), \
501 memory_recall (search past conversations by topic), \
502 memory_inspect (show one saved item by id), \
503 memory_forget (delete one saved item). \
504 \n\nTools for the bigger picture (populated as the \
505 user uses Solo over time): memory_themes (recent \
506 topics they've been thinking about), \
507 memory_facts_about (what you know about a person, \
508 project, or place — \"what do you know about \
509 Alex?\"), memory_contradictions (places where the \
510 user has said two things that disagree — surface \
511 these before answering), memory_inspect_cluster \
512 (the raw conversations behind one summary). \
513 \n\nTools for the user's documents: \
514 memory_ingest_document (read a file from disk and \
515 add it to Solo's library), memory_search_docs \
516 (search across ingested documents by topic — use \
517 when the user asks about something they wrote down \
518 or saved as a file), memory_inspect_document (show \
519 one document's metadata plus a preview of its \
520 chunks), memory_list_documents (browse documents \
521 by recency), memory_forget_document (drop a \
522 document from the library)."
523 .into(),
524 );
525 info
526 }
527
528 async fn initialize(
544 &self,
545 request: InitializeRequestParams,
546 context: RequestContext<RoleServer>,
547 ) -> std::result::Result<InitializeResult, McpError> {
548 if context.peer.peer_info().is_none() {
551 context.peer.set_peer_info(request.clone());
552 }
553
554 let llm_settings =
555 self.inner.tenant.config().llm.as_ref().cloned();
556 let peer_sampling_supported =
557 request.capabilities.sampling.is_some();
558 match initialize_decision(&llm_settings, peer_sampling_supported) {
559 InitializeDecision::Allow => {}
560 InitializeDecision::PopulateSamplingSteward => {
561 self.populate_sampling_steward(&context).await;
565 }
566 InitializeDecision::RejectMissingSamplingCapability => {
567 return Err(McpError::invalid_request(
568 sampling_capability_missing_error_message(),
569 None,
570 ));
571 }
572 }
573
574 Ok(self.get_info())
575 }
576
577 async fn list_tools(
578 &self,
579 _request: Option<PaginatedRequestParam>,
580 _context: RequestContext<RoleServer>,
581 ) -> std::result::Result<ListToolsResult, McpError> {
582 Ok(ListToolsResult {
583 tools: build_tools(),
584 next_cursor: None,
585 ..Default::default()
586 })
587 }
588
589 async fn call_tool(
590 &self,
591 request: CallToolRequestParam,
592 _context: RequestContext<RoleServer>,
593 ) -> std::result::Result<CallToolResult, McpError> {
594 let CallToolRequestParam { name, arguments, .. } = request;
595 let args_value = serde_json::Value::Object(arguments.unwrap_or_default());
596 self.dispatch_tool(&name, args_value).await
597 }
598}
599
600impl SoloMcpServer {
601 async fn populate_sampling_steward(
626 &self,
627 context: &RequestContext<RoleServer>,
628 ) {
629 let steward_config = solo_steward::StewardConfig::from_env()
630 .unwrap_or_else(|e| {
631 tracing::warn!(
632 error = %e,
633 "v0.9.0 P2: StewardConfig::from_env failed at MCP \
634 initialize; falling back to defaults"
635 );
636 solo_steward::StewardConfig::default()
637 });
638 let sampling_config = self.inner.tenant.config().sampling.clone();
644 let peer = context.peer.clone();
645 let write_handle = self.inner.tenant.write().clone();
646 let steward = crate::llm::build_sampling_steward(
647 peer,
648 write_handle,
649 self.inner.audit_principal.clone(),
650 steward_config,
651 sampling_config.clone(),
652 );
653 let slot = self.inner.tenant.steward_slot();
654 let mut guard = slot.write().await;
655 *guard = Some(steward);
656 tracing::info!(
657 tenant = %self.inner.tenant.tenant_id(),
658 coalesce_window_ms = sampling_config.coalesce_window_ms,
659 coalesce_max_requests = sampling_config.coalesce_max_requests,
660 "v0.9.0 P5: MCP-sampling Steward attached to tenant.steward_slot \
661 (PeerSamplingClient → SamplingCoordinator → SamplingLlmClient)"
662 );
663 }
664
665 pub async fn dispatch_tool(
671 &self,
672 name: &str,
673 args_value: serde_json::Value,
674 ) -> std::result::Result<CallToolResult, McpError> {
675 match name {
676 "memory_remember" => {
677 let args: RememberArgs = parse_args(&args_value)?;
678 self.handle_remember(args).await
679 }
680 "memory_recall" => {
681 let args: RecallArgs = parse_args(&args_value)?;
682 self.handle_recall(args).await
683 }
684 "memory_forget" => {
685 let args: ForgetArgs = parse_args(&args_value)?;
686 self.handle_forget(args).await
687 }
688 "memory_inspect" => {
689 let args: InspectArgs = parse_args(&args_value)?;
690 self.handle_inspect(args).await
691 }
692 "memory_themes" => {
693 let args: ThemesArgs = parse_args(&args_value)?;
694 self.handle_themes(args).await
695 }
696 "memory_facts_about" => {
697 let args: FactsAboutArgs = parse_args(&args_value)?;
698 self.handle_facts_about(args).await
699 }
700 "memory_contradictions" => {
701 let args: ContradictionsArgs = parse_args(&args_value)?;
702 self.handle_contradictions(args).await
703 }
704 "memory_inspect_cluster" => {
705 let args: InspectClusterArgs = parse_args(&args_value)?;
706 self.handle_inspect_cluster(args).await
707 }
708 "memory_ingest_document" => {
709 let args: IngestDocumentArgs = parse_args(&args_value)?;
710 self.handle_ingest_document(args).await
711 }
712 "memory_search_docs" => {
713 let args: SearchDocsArgs = parse_args(&args_value)?;
714 self.handle_search_docs(args).await
715 }
716 "memory_inspect_document" => {
717 let args: InspectDocumentArgs = parse_args(&args_value)?;
718 self.handle_inspect_document(args).await
719 }
720 "memory_list_documents" => {
721 let args: ListDocumentsArgs = parse_args(&args_value)?;
722 self.handle_list_documents(args).await
723 }
724 "memory_forget_document" => {
725 let args: ForgetDocumentArgs = parse_args(&args_value)?;
726 self.handle_forget_document(args).await
727 }
728 other => Err(McpError::invalid_params(
729 format!("unknown tool `{other}`"),
730 None,
731 )),
732 }
733 }
734
735 pub fn dispatch_list_tools(&self) -> Vec<Tool> {
738 build_tools()
739 }
740}
741
742fn parse_args<T: serde::de::DeserializeOwned>(
743 v: &serde_json::Value,
744) -> std::result::Result<T, McpError> {
745 serde_json::from_value(v.clone()).map_err(|e| {
746 McpError::invalid_params(format!("invalid tool arguments: {e}"), None)
747 })
748}
749
750fn solo_to_mcp(e: solo_core::Error) -> McpError {
751 use solo_core::Error;
752 match e {
753 Error::NotFound(msg) => McpError::invalid_params(msg, None),
754 Error::InvalidInput(msg) => McpError::invalid_params(msg, None),
755 Error::Conflict(msg) => McpError::invalid_params(msg, None),
756 other => McpError::internal_error(other.to_string(), None),
757 }
758}
759
760fn build_tools() -> Vec<Tool> {
765 vec![
766 Tool::new(
767 "memory_remember",
768 "Save something the user has told you — a fact, a \
769 preference, a name, a date, a context — so you can pick \
770 it up next conversation. Use whenever the user mentions \
771 something they'd reasonably expect you to recall later \
772 (\"I just started at Quotient\", \"my partner is Maya\"). \
773 Returns the saved item's id.",
774 json_schema_object(serde_json::json!({
775 "type": "object",
776 "properties": {
777 "content": {
778 "type": "string",
779 "description": "The text to remember.",
780 },
781 "source_type": {
782 "type": "string",
783 "description": "Optional source-type tag (default: \"user_message\").",
784 },
785 "source_id": {
786 "type": "string",
787 "description": "Optional upstream id for traceability.",
788 },
789 },
790 "required": ["content"],
791 })),
792 ),
793 Tool::new(
794 "memory_recall",
795 "Search past conversations with this user by topic or \
796 phrase. Returns up to `limit` of the closest matches, \
797 best match first. Use when the user references \
798 something they said before (\"that book I told you \
799 about\", \"the bug we were debugging last week\"). \
800 Skips items the user has deleted.",
801 json_schema_object(serde_json::json!({
802 "type": "object",
803 "properties": {
804 "query": {
805 "type": "string",
806 "description": "The query text.",
807 },
808 "limit": {
809 "type": "integer",
810 "description": "Maximum results (default 5).",
811 "minimum": 1,
812 "maximum": 100,
813 },
814 },
815 "required": ["query"],
816 })),
817 ),
818 Tool::new(
819 "memory_forget",
820 "Delete one saved item by id. Use when the user asks you \
821 to forget something specific (\"forget that I said \
822 X\"). The item stops appearing in future recalls. \
823 Reversible only via backups.",
824 json_schema_object(serde_json::json!({
825 "type": "object",
826 "properties": {
827 "memory_id": {
828 "type": "string",
829 "description": "MemoryId to forget (UUID v7).",
830 },
831 "reason": {
832 "type": "string",
833 "description": "Optional free-form reason (logged, not yet persisted).",
834 },
835 },
836 "required": ["memory_id"],
837 })),
838 ),
839 Tool::new(
840 "memory_inspect",
841 "Show the full record for one saved item — when it was \
842 saved, where it came from, and the full text. Use after \
843 memory_recall when you want the complete content of a \
844 specific hit (recall results may be truncated).",
845 json_schema_object(serde_json::json!({
846 "type": "object",
847 "properties": {
848 "memory_id": {
849 "type": "string",
850 "description": "MemoryId to inspect (UUID v7).",
851 },
852 },
853 "required": ["memory_id"],
854 })),
855 ),
856 Tool::new(
860 "memory_themes",
861 "Recent topics the user has been thinking about. Use to \
862 orient yourself at the start of a conversation, or when \
863 the user asks \"what have I been up to\" / \"what was I \
864 working on last week\". Pass `window_days` to scope \
865 (e.g. 7 for last week); omit for all-time.",
866 json_schema_object(serde_json::json!({
867 "type": "object",
868 "properties": {
869 "window_days": {
870 "type": "integer",
871 "description": "Optional time window in days. Omit for unfiltered.",
872 "minimum": 1,
873 },
874 "limit": {
875 "type": "integer",
876 "description": "Maximum results (default 5).",
877 "minimum": 1,
878 "maximum": 100,
879 },
880 },
881 })),
882 ),
883 Tool::new(
884 "memory_facts_about",
885 "Look up what you remember about a person, project, or \
886 topic — names, dates, preferences, relationships. Use \
887 when the user asks \"what do you know about Alex?\", \
888 \"when did I start at Quotient?\", \"who is Maya?\", or \
889 whenever you need grounded facts about someone or \
890 something before answering. Subject is required (the \
891 person/place/thing you're asking about); narrow further \
892 with `predicate` (\"works_at\", \"lives_in\") or a date \
893 range. Set `include_as_object=true` to also surface \
894 facts where the subject appears on the receiving side of \
895 a relationship (e.g. \"Sam pushes back on PRs about \
896 Maya\" surfaces under facts_about(subject=\"Maya\", \
897 include_as_object=true)). (Backed by \
898 subject-predicate-object triples distilled from past \
899 conversations.) Clients should set a 30s timeout on this \
900 call; if exceeded, retry once or fall back to \
901 `memory_recall`.",
902 json_schema_object(serde_json::json!({
903 "type": "object",
904 "properties": {
905 "subject": {
906 "type": "string",
907 "description": "Subject id to query (e.g. 'Sam').",
908 },
909 "predicate": {
910 "type": "string",
911 "description": "Optional predicate filter (e.g. 'works_at').",
912 },
913 "since_ms": {
914 "type": "integer",
915 "description": "Optional valid_from_ms lower bound (epoch ms).",
916 },
917 "until_ms": {
918 "type": "integer",
919 "description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through.",
920 },
921 "include_as_object": {
922 "type": "boolean",
923 "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.",
924 "default": false,
925 },
926 "limit": {
927 "type": "integer",
928 "description": "Maximum results (default 5).",
929 "minimum": 1,
930 "maximum": 100,
931 },
932 },
933 "required": ["subject"],
934 })),
935 ),
936 Tool::new(
937 "memory_contradictions",
938 "Find places where the user's stated beliefs or facts \
939 disagree across conversations — flag disagreements \
940 before answering. Use whenever you're about to rely on \
941 a remembered fact that could have changed (jobs, \
942 relationships, preferences, opinions); a disagreement \
943 here means the user has told you both X and not-X over \
944 time and you should ask which is current instead of \
945 guessing. Each result shows both conflicting statements \
946 with the topic.",
947 json_schema_object(serde_json::json!({
948 "type": "object",
949 "properties": {
950 "limit": {
951 "type": "integer",
952 "description": "Maximum results (default 5).",
953 "minimum": 1,
954 "maximum": 100,
955 },
956 },
957 })),
958 ),
959 Tool::new(
960 "memory_inspect_cluster",
961 "Show the raw conversations behind one summary. Returns \
962 the one-line topic (the LLM-generated summary) and the \
963 source conversations the topic was built from. Use \
964 after memory_themes when the user asks \"show me the \
965 raw context behind this\" or \"why does Solo think \
966 that about cluster Y\". Source items are truncated to \
967 200 chars unless `full_content` is set.",
968 json_schema_object(serde_json::json!({
969 "type": "object",
970 "properties": {
971 "cluster_id": {
972 "type": "string",
973 "description": "Cluster id to inspect (from memory_themes hits).",
974 },
975 "full_content": {
976 "type": "boolean",
977 "description": "If true, episode content is returned verbatim. Default false (truncate to 200 chars + ellipsis).",
978 },
979 },
980 "required": ["cluster_id"],
981 })),
982 ),
983 Tool::new(
987 "memory_ingest_document",
988 "Read a file from disk and add it to the user's document \
989 library so it becomes searchable alongside past \
990 conversations. Use when the user asks you to remember a \
991 whole file (\"add my notes/runbook.md\", \"ingest this \
992 PDF\"). The file is split into ~500-token chunks and \
993 each chunk is embedded; chunks then surface through \
994 memory_search_docs. Returns the new document id, chunk \
995 count, and a `deduped` flag (true if the same content \
996 was already ingested under another id).",
997 json_schema_object(serde_json::json!({
998 "type": "object",
999 "properties": {
1000 "path": {
1001 "type": "string",
1002 "description": "Server-side absolute path to the file to ingest. The file must be readable by the Solo process.",
1003 },
1004 },
1005 "required": ["path"],
1006 })),
1007 ),
1008 Tool::new(
1009 "memory_search_docs",
1010 "Search across the user's ingested documents by topic or \
1011 phrase. Returns up to `limit` matching chunks, best \
1012 match first, each with the parent document's title + \
1013 source path so you can cite where the answer came from. \
1014 Use when the user asks a question that hinges on \
1015 material they've added as a file (\"what does my \
1016 runbook say about backups?\", \"find the section in the \
1017 notes about the new policy\"). Forgotten documents are \
1018 skipped.",
1019 json_schema_object(serde_json::json!({
1020 "type": "object",
1021 "properties": {
1022 "query": {
1023 "type": "string",
1024 "description": "The query text.",
1025 },
1026 "limit": {
1027 "type": "integer",
1028 "description": "Maximum results (default 5).",
1029 "minimum": 1,
1030 "maximum": 100,
1031 },
1032 },
1033 "required": ["query"],
1034 })),
1035 ),
1036 Tool::new(
1037 "memory_inspect_document",
1038 "Show one document's metadata plus a preview of every \
1039 chunk it was split into. Use after memory_search_docs \
1040 when the user wants the bigger picture for one hit \
1041 (\"show me the whole document this came from\"), or \
1042 after memory_list_documents to drill into one entry. \
1043 Each chunk preview is truncated to 200 chars.",
1044 json_schema_object(serde_json::json!({
1045 "type": "object",
1046 "properties": {
1047 "doc_id": {
1048 "type": "string",
1049 "description": "Document id to inspect (UUID v7).",
1050 },
1051 },
1052 "required": ["doc_id"],
1053 })),
1054 ),
1055 Tool::new(
1056 "memory_list_documents",
1057 "List the user's ingested documents, newest first. Use \
1058 when the user asks \"what documents have I added?\" or \
1059 \"show me my files\". Returns a paginated index — pass \
1060 `offset` to page further back. Forgotten documents are \
1061 hidden by default; set `include_forgotten=true` to see \
1062 them too.",
1063 json_schema_object(serde_json::json!({
1064 "type": "object",
1065 "properties": {
1066 "limit": {
1067 "type": "integer",
1068 "description": "Maximum results per page (default 20).",
1069 "minimum": 1,
1070 "maximum": 100,
1071 },
1072 "offset": {
1073 "type": "integer",
1074 "description": "Number of rows to skip (for paging). Default 0.",
1075 "minimum": 0,
1076 },
1077 "include_forgotten": {
1078 "type": "boolean",
1079 "description": "If true, also include documents the user has forgotten. Default false.",
1080 },
1081 },
1082 })),
1083 ),
1084 Tool::new(
1085 "memory_forget_document",
1086 "Drop one document from the user's library by id. Use \
1087 when the user asks you to forget a specific file \
1088 (\"forget my old runbook\"). The document's chunks stop \
1089 appearing in memory_search_docs and the vectors are \
1090 tombstoned in the index. The chunk rows themselves are \
1091 kept for forensic value (a future restore command can \
1092 undo this).",
1093 json_schema_object(serde_json::json!({
1094 "type": "object",
1095 "properties": {
1096 "doc_id": {
1097 "type": "string",
1098 "description": "Document id to forget (UUID v7).",
1099 },
1100 },
1101 "required": ["doc_id"],
1102 })),
1103 ),
1104 ]
1105}
1106
1107fn json_schema_object(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
1108 match value {
1109 serde_json::Value::Object(map) => map,
1110 _ => panic!("json_schema_object: input must be an object"),
1111 }
1112}
1113
1114pub fn tool_names() -> Vec<&'static str> {
1123 vec![
1124 "memory_remember",
1125 "memory_recall",
1126 "memory_forget",
1127 "memory_inspect",
1128 "memory_themes",
1129 "memory_facts_about",
1130 "memory_contradictions",
1131 "memory_inspect_cluster",
1132 "memory_ingest_document",
1134 "memory_search_docs",
1135 "memory_inspect_document",
1136 "memory_list_documents",
1137 "memory_forget_document",
1138 ]
1139}
1140
1141impl SoloMcpServer {
1146 async fn handle_remember(
1147 &self,
1148 args: RememberArgs,
1149 ) -> std::result::Result<CallToolResult, McpError> {
1150 let content = args.content.trim_end().to_string();
1151 if content.is_empty() {
1152 return Err(McpError::invalid_params(
1153 "memory_remember: content must not be empty".to_string(),
1154 None,
1155 ));
1156 }
1157 let embedding: solo_core::Embedding = self
1158 .inner
1159 .tenant
1160 .embedder()
1161 .embed(&content)
1162 .await
1163 .map_err(solo_to_mcp)?;
1164 let episode = Episode {
1165 memory_id: MemoryId::new(),
1166 ts_ms: chrono::Utc::now().timestamp_millis(),
1167 source_type: args.source_type.unwrap_or_else(|| "user_message".into()),
1168 source_id: args.source_id,
1169 content,
1170 encoding_context: EncodingContext::default(),
1171 provenance: None,
1172 confidence: Confidence::new(0.9).unwrap(),
1173 strength: 0.5,
1174 salience: 0.5,
1175 tier: Tier::Hot,
1176 };
1177 let mid = self
1178 .inner
1179 .tenant
1180 .write()
1181 .remember_as(self.inner.audit_principal.clone(), episode, embedding)
1182 .await
1183 .map_err(solo_to_mcp)?;
1184 Ok(CallToolResult::success(vec![Content::text(format!(
1185 "remembered {mid}"
1186 ))]))
1187 }
1188
1189 async fn handle_recall(
1190 &self,
1191 args: RecallArgs,
1192 ) -> std::result::Result<CallToolResult, McpError> {
1193 let result = solo_query::run_recall(
1197 self.inner.tenant.as_ref(),
1198 self.inner.audit_principal.clone(),
1199 &args.query,
1200 args.limit,
1201 )
1202 .await
1203 .map_err(solo_to_mcp)?;
1204
1205 if result.hits.is_empty() {
1206 return Ok(CallToolResult::success(vec![Content::text(format!(
1207 "no matches (index has {} vectors)",
1208 result.index_len
1209 ))]));
1210 }
1211 let body = serde_json::to_string_pretty(&result.hits).unwrap_or_else(|_| String::new());
1212 Ok(CallToolResult::success(vec![Content::text(body)]))
1213 }
1214
1215 async fn handle_forget(
1216 &self,
1217 args: ForgetArgs,
1218 ) -> std::result::Result<CallToolResult, McpError> {
1219 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
1220 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
1221 })?;
1222 self.inner
1223 .tenant
1224 .write()
1225 .forget_as(self.inner.audit_principal.clone(), mid, args.reason)
1226 .await
1227 .map_err(solo_to_mcp)?;
1228 Ok(CallToolResult::success(vec![Content::text(format!(
1229 "forgotten {mid}"
1230 ))]))
1231 }
1232
1233 async fn handle_inspect(
1234 &self,
1235 args: InspectArgs,
1236 ) -> std::result::Result<CallToolResult, McpError> {
1237 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
1238 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
1239 })?;
1240 let row = solo_query::inspect_one(
1242 self.inner.tenant.read(),
1243 self.inner.tenant.audit(),
1244 self.inner.audit_principal.clone(),
1245 mid,
1246 )
1247 .await
1248 .map_err(solo_to_mcp)?;
1249 let body = serde_json::to_string_pretty(&row).unwrap_or_else(|_| String::new());
1250 Ok(CallToolResult::success(vec![Content::text(body)]))
1251 }
1252
1253 async fn handle_themes(
1260 &self,
1261 args: ThemesArgs,
1262 ) -> std::result::Result<CallToolResult, McpError> {
1263 let hits = solo_query::themes(
1264 self.inner.tenant.read(),
1265 self.inner.tenant.audit(),
1266 self.inner.audit_principal.clone(),
1267 args.window_days,
1268 args.limit,
1269 )
1270 .await
1271 .map_err(solo_to_mcp)?;
1272 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1273 Ok(CallToolResult::success(vec![Content::text(body)]))
1274 }
1275
1276 async fn handle_facts_about(
1277 &self,
1278 args: FactsAboutArgs,
1279 ) -> std::result::Result<CallToolResult, McpError> {
1280 if args.subject.trim().is_empty() {
1281 return Err(McpError::invalid_params(
1282 "memory_facts_about: subject must not be empty".to_string(),
1283 None,
1284 ));
1285 }
1286 let hits = solo_query::facts_about(
1287 self.inner.tenant.read(),
1288 self.inner.tenant.audit(),
1289 self.inner.audit_principal.clone(),
1290 &args.subject,
1291 &self.inner.user_aliases,
1292 args.include_as_object,
1293 args.predicate.as_deref(),
1294 args.since_ms,
1295 args.until_ms,
1296 args.limit,
1297 )
1298 .await
1299 .map_err(solo_to_mcp)?;
1300 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1301 Ok(CallToolResult::success(vec![Content::text(body)]))
1302 }
1303
1304 async fn handle_contradictions(
1305 &self,
1306 args: ContradictionsArgs,
1307 ) -> std::result::Result<CallToolResult, McpError> {
1308 let hits = solo_query::contradictions(
1309 self.inner.tenant.read(),
1310 self.inner.tenant.audit(),
1311 self.inner.audit_principal.clone(),
1312 args.limit,
1313 )
1314 .await
1315 .map_err(solo_to_mcp)?;
1316 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1317 Ok(CallToolResult::success(vec![Content::text(body)]))
1318 }
1319
1320 async fn handle_inspect_cluster(
1321 &self,
1322 args: InspectClusterArgs,
1323 ) -> std::result::Result<CallToolResult, McpError> {
1324 if args.cluster_id.trim().is_empty() {
1325 return Err(McpError::invalid_params(
1326 "memory_inspect_cluster: cluster_id must not be empty".to_string(),
1327 None,
1328 ));
1329 }
1330 let record = solo_query::inspect_cluster(
1335 self.inner.tenant.read(),
1336 self.inner.tenant.audit(),
1337 self.inner.audit_principal.clone(),
1338 &args.cluster_id,
1339 args.full_content,
1340 )
1341 .await
1342 .map_err(solo_to_mcp)?;
1343 let body = serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
1344 Ok(CallToolResult::success(vec![Content::text(body)]))
1345 }
1346
1347 async fn handle_ingest_document(
1352 &self,
1353 args: IngestDocumentArgs,
1354 ) -> std::result::Result<CallToolResult, McpError> {
1355 if args.path.trim().is_empty() {
1356 return Err(McpError::invalid_params(
1357 "memory_ingest_document: path must not be empty".to_string(),
1358 None,
1359 ));
1360 }
1361 let path = std::path::PathBuf::from(args.path);
1362 let chunk_config = solo_storage::document::ChunkConfig::default();
1366 let report = self
1367 .inner
1368 .tenant
1369 .write()
1370 .ingest_document_as(self.inner.audit_principal.clone(), path, chunk_config)
1371 .await
1372 .map_err(solo_to_mcp)?;
1373 let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| String::new());
1374 Ok(CallToolResult::success(vec![Content::text(body)]))
1375 }
1376
1377 async fn handle_search_docs(
1378 &self,
1379 args: SearchDocsArgs,
1380 ) -> std::result::Result<CallToolResult, McpError> {
1381 let hits = solo_query::run_doc_search(
1385 self.inner.tenant.as_ref(),
1386 self.inner.audit_principal.clone(),
1387 &args.query,
1388 args.limit,
1389 )
1390 .await
1391 .map_err(solo_to_mcp)?;
1392 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1393 Ok(CallToolResult::success(vec![Content::text(body)]))
1394 }
1395
1396 async fn handle_inspect_document(
1397 &self,
1398 args: InspectDocumentArgs,
1399 ) -> std::result::Result<CallToolResult, McpError> {
1400 let doc_id = DocumentId::from_str(&args.doc_id).map_err(|e| {
1401 McpError::invalid_params(format!("invalid doc_id: {e}"), None)
1402 })?;
1403 let result_opt = solo_query::inspect_document(
1404 self.inner.tenant.read(),
1405 self.inner.tenant.audit(),
1406 self.inner.audit_principal.clone(),
1407 &doc_id,
1408 )
1409 .await
1410 .map_err(solo_to_mcp)?;
1411 match result_opt {
1412 Some(record) => {
1413 let body =
1414 serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
1415 Ok(CallToolResult::success(vec![Content::text(body)]))
1416 }
1417 None => Err(McpError::invalid_params(
1418 format!("document {doc_id} not found"),
1419 None,
1420 )),
1421 }
1422 }
1423
1424 async fn handle_list_documents(
1425 &self,
1426 args: ListDocumentsArgs,
1427 ) -> std::result::Result<CallToolResult, McpError> {
1428 let rows = solo_query::list_documents(
1429 self.inner.tenant.read(),
1430 self.inner.tenant.audit(),
1431 self.inner.audit_principal.clone(),
1432 args.limit,
1433 args.offset,
1434 args.include_forgotten,
1435 )
1436 .await
1437 .map_err(solo_to_mcp)?;
1438 let body = serde_json::to_string_pretty(&rows).unwrap_or_else(|_| String::new());
1439 Ok(CallToolResult::success(vec![Content::text(body)]))
1440 }
1441
1442 async fn handle_forget_document(
1443 &self,
1444 args: ForgetDocumentArgs,
1445 ) -> std::result::Result<CallToolResult, McpError> {
1446 let doc_id = DocumentId::from_str(&args.doc_id).map_err(|e| {
1447 McpError::invalid_params(format!("invalid doc_id: {e}"), None)
1448 })?;
1449 let report = self
1450 .inner
1451 .tenant
1452 .write()
1453 .forget_document_as(self.inner.audit_principal.clone(), doc_id)
1454 .await
1455 .map_err(solo_to_mcp)?;
1456 let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| String::new());
1457 Ok(CallToolResult::success(vec![Content::text(body)]))
1458 }
1459}
1460
1461#[cfg(test)]
1462mod dispatch_tests {
1463 use super::*;
1475 use serde_json::json;
1476 use solo_core::VectorIndex;
1477 use solo_storage::test_support::StubVectorIndex;
1478 use solo_storage::{
1479 EmbedderConfig, IdentityConfig, KeyMaterial, ReaderPool, SoloConfig,
1480 StubEmbedder, TenantHandle, TenantRegistry, WriterActor, WriterSpawn,
1481 };
1482 use std::sync::Arc as StdArc;
1483
1484 fn fake_config(dim: u32) -> SoloConfig {
1485 SoloConfig {
1486 schema_version: 1,
1487 salt_hex: "00000000000000000000000000000000".to_string(),
1488 embedder: EmbedderConfig {
1489 name: "stub".to_string(),
1490 version: "v1".to_string(),
1491 dim,
1492 dtype: "f32".to_string(),
1493 },
1494 identity: IdentityConfig::default(),
1495 documents: solo_storage::DocumentConfig::default(),
1496 auth: None,
1497 audit: solo_storage::AuditSettings::default(),
1498 redaction: solo_storage::RedactionConfig::default(),
1499 llm: None,
1500 triples: solo_storage::TriplesConfig::default(),
1501 sampling: solo_storage::SamplingConfig::default(),
1502 }
1503 }
1504
1505 struct Harness {
1506 server: SoloMcpServer,
1507 _tmp: tempfile::TempDir,
1508 write_handle_extra: Option<solo_storage::WriteHandle>,
1509 join: Option<std::thread::JoinHandle<()>>,
1510 }
1511
1512 impl Harness {
1513 fn new(runtime: &tokio::runtime::Runtime) -> Self {
1514 let tmp = tempfile::TempDir::new().unwrap();
1515 let dim = 16usize;
1516 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1517 let embedder: StdArc<dyn solo_core::Embedder> = StdArc::new(StubEmbedder::new("stub", "v1", dim));
1518
1519 let conn = solo_storage::test_support::open_test_db_at(&tmp.path().join("test.db"));
1520 let WriterSpawn { handle, join } = WriterActor::spawn(conn, hnsw.clone());
1521
1522 let path = tmp.path().join("test.db");
1525 let pool: ReaderPool =
1526 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1527
1528 let tenant_id = solo_core::TenantId::default_tenant();
1529 let tenant_handle = StdArc::new(
1530 TenantHandle::from_parts_for_tests(
1531 tenant_id.clone(),
1532 fake_config(dim as u32),
1533 path.clone(),
1534 tmp.path().to_path_buf(),
1535 0, hnsw,
1537 embedder.clone(),
1538 handle.clone(),
1539 std::thread::spawn(|| {}),
1540 pool,
1541 ),
1542 );
1543 let key = KeyMaterial::from_bytes_for_tests([0u8; 32]);
1544 let registry = StdArc::new(TenantRegistry::for_tests_with_single_tenant(
1545 tmp.path().to_path_buf(),
1546 key,
1547 embedder,
1548 tenant_handle.clone(),
1549 ));
1550 let server = SoloMcpServer::new_for_tenant(registry, tenant_handle, Vec::new());
1551 Harness {
1552 server,
1553 _tmp: tmp,
1554 write_handle_extra: Some(handle),
1555 join: Some(join),
1556 }
1557 }
1558
1559 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1560 let join = self.join.take();
1566 let extra = self.write_handle_extra.take();
1567 runtime.block_on(async move {
1568 drop(extra);
1569 drop(self.server);
1570 drop(self._tmp);
1571 if let Some(join) = join {
1572 let (tx, rx) = std::sync::mpsc::channel();
1573 std::thread::spawn(move || {
1574 let _ = tx.send(join.join());
1575 });
1576 tokio::task::spawn_blocking(move || {
1577 rx.recv_timeout(std::time::Duration::from_secs(5))
1578 })
1579 .await
1580 .expect("blocking task")
1581 .expect("writer thread did not exit within 5s")
1582 .expect("writer thread panicked");
1583 }
1584 });
1585 }
1586 }
1587
1588 fn rt() -> tokio::runtime::Runtime {
1589 tokio::runtime::Builder::new_multi_thread()
1590 .worker_threads(2)
1591 .enable_all()
1592 .build()
1593 .unwrap()
1594 }
1595
1596 fn first_text(r: &rmcp::model::CallToolResult) -> String {
1601 let first = r.content.first().expect("at least one content item");
1602 let v = serde_json::to_value(first).expect("content serialises");
1603 v.get("text")
1604 .and_then(|t| t.as_str())
1605 .map(|s| s.to_string())
1606 .unwrap_or_else(|| format!("{v}"))
1607 }
1608
1609 #[test]
1610 fn tools_list_returns_thirteen_canonical_tools() {
1611 let runtime = rt();
1612 let h = Harness::new(&runtime);
1613 let tools = h.server.dispatch_list_tools();
1614 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1615 assert_eq!(
1616 names,
1617 vec![
1618 "memory_remember",
1619 "memory_recall",
1620 "memory_forget",
1621 "memory_inspect",
1622 "memory_themes",
1624 "memory_facts_about",
1625 "memory_contradictions",
1626 "memory_inspect_cluster",
1628 "memory_ingest_document",
1630 "memory_search_docs",
1631 "memory_inspect_document",
1632 "memory_list_documents",
1633 "memory_forget_document",
1634 ]
1635 );
1636 for t in &tools {
1637 let desc = t.description.as_deref().unwrap_or("");
1639 assert!(!desc.is_empty(), "{} description empty", t.name);
1640 let _schema = t.schema_as_json_value();
1641 }
1648 h.shutdown(&runtime);
1649 }
1650
1651 #[test]
1652 fn themes_returns_json_array_on_empty_db() {
1653 let runtime = rt();
1654 let h = Harness::new(&runtime);
1655 runtime.block_on(async {
1656 let r = h
1657 .server
1658 .dispatch_tool("memory_themes", json!({}))
1659 .await
1660 .expect("themes succeeds");
1661 let text = first_text(&r);
1662 let v: serde_json::Value =
1664 serde_json::from_str(&text).expect("parses as json");
1665 assert!(v.is_array(), "expected array, got: {text}");
1666 assert_eq!(v.as_array().unwrap().len(), 0);
1667 });
1668 h.shutdown(&runtime);
1669 }
1670
1671 #[test]
1672 fn themes_passes_through_window_and_limit_args() {
1673 let runtime = rt();
1674 let h = Harness::new(&runtime);
1675 runtime.block_on(async {
1676 let r = h
1678 .server
1679 .dispatch_tool(
1680 "memory_themes",
1681 json!({ "window_days": 7, "limit": 20 }),
1682 )
1683 .await
1684 .expect("themes with args succeeds");
1685 let text = first_text(&r);
1686 let v: serde_json::Value =
1687 serde_json::from_str(&text).expect("parses as json");
1688 assert!(v.is_array());
1689 });
1690 h.shutdown(&runtime);
1691 }
1692
1693 #[test]
1694 fn facts_about_rejects_empty_subject() {
1695 let runtime = rt();
1696 let h = Harness::new(&runtime);
1697 runtime.block_on(async {
1698 let err = h
1699 .server
1700 .dispatch_tool(
1701 "memory_facts_about",
1702 json!({ "subject": " " }),
1703 )
1704 .await
1705 .expect_err("empty subject must error");
1706 let s = format!("{err:?}");
1709 assert!(
1710 s.to_lowercase().contains("subject")
1711 || s.to_lowercase().contains("invalid"),
1712 "got: {s}"
1713 );
1714 });
1715 h.shutdown(&runtime);
1716 }
1717
1718 #[test]
1719 fn facts_about_returns_array_for_unknown_subject() {
1720 let runtime = rt();
1721 let h = Harness::new(&runtime);
1722 runtime.block_on(async {
1723 let r = h
1724 .server
1725 .dispatch_tool(
1726 "memory_facts_about",
1727 json!({ "subject": "NobodyKnowsThisSubject" }),
1728 )
1729 .await
1730 .expect("facts_about with unknown subject succeeds");
1731 let text = first_text(&r);
1732 let v: serde_json::Value =
1733 serde_json::from_str(&text).expect("parses as json");
1734 assert_eq!(v.as_array().unwrap().len(), 0);
1735 });
1736 h.shutdown(&runtime);
1737 }
1738
1739 #[test]
1740 fn facts_about_accepts_include_as_object_arg() {
1741 let runtime = rt();
1749 let h = Harness::new(&runtime);
1750 runtime.block_on(async {
1751 let r = h
1753 .server
1754 .dispatch_tool(
1755 "memory_facts_about",
1756 json!({ "subject": "Maya", "include_as_object": true }),
1757 )
1758 .await
1759 .expect("dispatch with include_as_object=true succeeds");
1760 let v: serde_json::Value = serde_json::from_str(&first_text(&r))
1761 .expect("parses as json");
1762 assert_eq!(v.as_array().unwrap().len(), 0);
1763
1764 let r = h
1766 .server
1767 .dispatch_tool(
1768 "memory_facts_about",
1769 json!({ "subject": "Maya" }),
1770 )
1771 .await
1772 .expect("dispatch without include_as_object succeeds (default false)");
1773 let v: serde_json::Value = serde_json::from_str(&first_text(&r))
1774 .expect("parses as json");
1775 assert_eq!(v.as_array().unwrap().len(), 0);
1776 });
1777 h.shutdown(&runtime);
1778 }
1779
1780 #[test]
1781 fn contradictions_returns_json_array_on_empty_db() {
1782 let runtime = rt();
1783 let h = Harness::new(&runtime);
1784 runtime.block_on(async {
1785 let r = h
1786 .server
1787 .dispatch_tool("memory_contradictions", json!({}))
1788 .await
1789 .expect("contradictions succeeds");
1790 let text = first_text(&r);
1791 let v: serde_json::Value =
1792 serde_json::from_str(&text).expect("parses as json");
1793 assert!(v.is_array());
1794 assert_eq!(v.as_array().unwrap().len(), 0);
1795 });
1796 h.shutdown(&runtime);
1797 }
1798
1799 #[test]
1800 fn remember_then_recall_round_trip() {
1801 let runtime = rt();
1802 let h = Harness::new(&runtime);
1803 runtime.block_on(async {
1809 let r = h
1810 .server
1811 .dispatch_tool("memory_remember", json!({ "content": "the cat sat on the mat" }))
1812 .await
1813 .expect("remember succeeds");
1814 let text = first_text(&r);
1815 assert!(text.starts_with("remembered "), "got: {text}");
1816
1817 let r = h
1818 .server
1819 .dispatch_tool(
1820 "memory_recall",
1821 json!({ "query": "the cat sat on the mat", "limit": 5 }),
1822 )
1823 .await
1824 .expect("recall succeeds");
1825 let text = first_text(&r);
1826 assert!(text.contains("the cat sat on the mat"), "got: {text}");
1827 });
1828 h.shutdown(&runtime);
1829 }
1830
1831 #[test]
1832 fn forget_excludes_row_from_subsequent_recall() {
1833 let runtime = rt();
1834 let h = Harness::new(&runtime);
1835
1836 runtime.block_on(async {
1837 let r = h
1838 .server
1839 .dispatch_tool("memory_remember", json!({ "content": "to be forgotten" }))
1840 .await
1841 .unwrap();
1842 let text = first_text(&r);
1843 let mid = text.strip_prefix("remembered ").unwrap().to_string();
1844
1845 h.server
1846 .dispatch_tool(
1847 "memory_forget",
1848 json!({ "memory_id": mid, "reason": "test" }),
1849 )
1850 .await
1851 .expect("forget succeeds");
1852
1853 let r = h
1854 .server
1855 .dispatch_tool(
1856 "memory_recall",
1857 json!({ "query": "to be forgotten", "limit": 5 }),
1858 )
1859 .await
1860 .unwrap();
1861 let text = first_text(&r);
1862 assert!(
1863 !text.contains(r#""content": "to be forgotten""#),
1864 "forgotten row should be excluded; got: {text}"
1865 );
1866 });
1867 h.shutdown(&runtime);
1868 }
1869
1870 #[test]
1871 fn empty_remember_returns_invalid_params() {
1872 let runtime = rt();
1873 let h = Harness::new(&runtime);
1874 runtime.block_on(async {
1875 let err = h
1876 .server
1877 .dispatch_tool("memory_remember", json!({ "content": "" }))
1878 .await
1879 .unwrap_err();
1880 assert!(format!("{err:?}").contains("must not be empty"));
1881 });
1882 h.shutdown(&runtime);
1883 }
1884
1885 #[test]
1886 fn empty_recall_query_returns_invalid_params() {
1887 let runtime = rt();
1888 let h = Harness::new(&runtime);
1889 runtime.block_on(async {
1890 let err = h
1891 .server
1892 .dispatch_tool("memory_recall", json!({ "query": " " }))
1893 .await
1894 .unwrap_err();
1895 assert!(format!("{err:?}").contains("must not be empty"));
1896 });
1897 h.shutdown(&runtime);
1898 }
1899
1900 #[test]
1901 fn inspect_with_invalid_id_returns_invalid_params() {
1902 let runtime = rt();
1903 let h = Harness::new(&runtime);
1904 runtime.block_on(async {
1905 let err = h
1906 .server
1907 .dispatch_tool("memory_inspect", json!({ "memory_id": "not-a-uuid" }))
1908 .await
1909 .unwrap_err();
1910 assert!(format!("{err:?}").contains("invalid memory_id"));
1911 });
1912 h.shutdown(&runtime);
1913 }
1914
1915 #[test]
1916 fn forget_unknown_id_returns_invalid_params() {
1917 let runtime = rt();
1918 let h = Harness::new(&runtime);
1919 runtime.block_on(async {
1920 let err = h
1924 .server
1925 .dispatch_tool(
1926 "memory_forget",
1927 json!({ "memory_id": "00000000-0000-7000-8000-000000000000" }),
1928 )
1929 .await
1930 .unwrap_err();
1931 assert!(format!("{err:?}").contains("not found"));
1932 });
1933 h.shutdown(&runtime);
1934 }
1935
1936 #[test]
1937 fn unknown_tool_name_returns_invalid_params() {
1938 let runtime = rt();
1939 let h = Harness::new(&runtime);
1940 runtime.block_on(async {
1941 let err = h
1942 .server
1943 .dispatch_tool("memory.summon", json!({}))
1944 .await
1945 .unwrap_err();
1946 assert!(format!("{err:?}").contains("unknown tool"));
1947 });
1948 h.shutdown(&runtime);
1949 }
1950
1951 #[test]
1986 fn tool_names_match_cross_provider_regex() {
1987 fn passes_anthropic(name: &str) -> bool {
1989 let len = name.len();
1990 if !(1..=64).contains(&len) {
1991 return false;
1992 }
1993 name.chars()
1994 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1995 }
1996
1997 fn passes_openai(name: &str) -> bool {
2000 let len = name.len();
2001 if !(1..=64).contains(&len) {
2002 return false;
2003 }
2004 let mut chars = name.chars();
2005 let first = match chars.next() {
2006 Some(c) => c,
2007 None => return false,
2008 };
2009 if !(first.is_ascii_alphabetic() || first == '_') {
2010 return false;
2011 }
2012 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
2013 }
2014
2015 fn passes_gemini(name: &str) -> bool {
2020 let len = name.len();
2021 if !(1..=63).contains(&len) {
2022 return false;
2023 }
2024 let mut chars = name.chars();
2025 let first = match chars.next() {
2026 Some(c) => c,
2027 None => return false,
2028 };
2029 if !(first.is_ascii_alphabetic() || first == '_') {
2030 return false;
2031 }
2032 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
2033 }
2034
2035 let tools = build_tools();
2036 assert_eq!(
2037 tools.len(),
2038 13,
2039 "expected 13 tools in v0.7.0 (8 v0.5.x + 5 document tools)"
2040 );
2041 let tool_name_strings: Vec<String> =
2043 tools.iter().map(|t| t.name.to_string()).collect();
2044 let public_names: Vec<String> =
2045 super::tool_names().iter().map(|s| s.to_string()).collect();
2046 assert_eq!(
2047 tool_name_strings, public_names,
2048 "tool_names() drifted from build_tools() — keep them in sync"
2049 );
2050
2051 for t in tools {
2052 assert!(
2053 passes_anthropic(&t.name),
2054 "tool name {:?} fails Anthropic regex \
2055 ^[a-zA-Z0-9_-]{{1,64}}$ — see v0.3 lesson #8",
2056 t.name
2057 );
2058 assert!(
2059 passes_openai(&t.name),
2060 "tool name {:?} fails OpenAI function-calling regex \
2061 ^[a-zA-Z_][a-zA-Z0-9_-]*$ (len ≤ 64)",
2062 t.name
2063 );
2064 assert!(
2065 passes_gemini(&t.name),
2066 "tool name {:?} fails Gemini function-calling regex \
2067 ^[a-zA-Z_][a-zA-Z0-9_]*$ (len ≤ 63, strict)",
2068 t.name
2069 );
2070 }
2071 }
2072
2073 #[test]
2090 fn tool_descriptions_avoid_internal_jargon() {
2091 const FORBIDDEN: &[&str] = &[
2095 "SPO",
2096 "Steward",
2097 "Steward-flagged",
2098 "LEFT JOIN",
2099 "candidate pair",
2100 "candidate_pair",
2101 "tagged_with",
2102 ];
2103
2104 fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
2105 haystack.to_lowercase().contains(&needle.to_lowercase())
2106 }
2107
2108 for t in build_tools() {
2110 let desc = t.description.as_deref().unwrap_or("");
2111 for term in FORBIDDEN {
2112 assert!(
2113 !contains_case_insensitive(desc, term),
2114 "tool {:?} description contains forbidden jargon \
2115 {:?} — rewrite in plain English (see v0.5.0 \
2116 Priority 4)",
2117 t.name,
2118 term,
2119 );
2120 }
2121 }
2122
2123 let server_info = harness_server_info();
2126 let instructions = server_info
2127 .instructions
2128 .as_deref()
2129 .expect("get_info() must set instructions");
2130 for term in FORBIDDEN {
2131 assert!(
2132 !contains_case_insensitive(instructions, term),
2133 "get_info().instructions contains forbidden jargon \
2134 {:?} — rewrite in plain English",
2135 term,
2136 );
2137 }
2138 }
2139
2140 fn harness_server_info() -> rmcp::model::ServerInfo {
2147 let runtime = rt();
2148 let h = Harness::new(&runtime);
2149 let info = ServerHandler::get_info(&h.server);
2150 h.shutdown(&runtime);
2151 info
2152 }
2153
2154 #[test]
2157 fn inspect_cluster_unknown_id_returns_invalid_params() {
2158 let runtime = rt();
2162 let h = Harness::new(&runtime);
2163 runtime.block_on(async {
2164 let err = h
2165 .server
2166 .dispatch_tool(
2167 "memory_inspect_cluster",
2168 json!({ "cluster_id": "no-such-cluster" }),
2169 )
2170 .await
2171 .expect_err("unknown cluster must error");
2172 let s = format!("{err:?}");
2173 assert!(
2174 s.contains("no-such-cluster") || s.to_lowercase().contains("not found"),
2175 "expected error to mention the missing cluster id; got: {s}"
2176 );
2177 });
2178 h.shutdown(&runtime);
2179 }
2180
2181 #[test]
2182 fn inspect_cluster_rejects_empty_id() {
2183 let runtime = rt();
2184 let h = Harness::new(&runtime);
2185 runtime.block_on(async {
2186 let err = h
2187 .server
2188 .dispatch_tool(
2189 "memory_inspect_cluster",
2190 json!({ "cluster_id": " " }),
2191 )
2192 .await
2193 .expect_err("blank cluster_id must error");
2194 let s = format!("{err:?}");
2195 assert!(
2196 s.to_lowercase().contains("cluster_id")
2197 || s.to_lowercase().contains("must not be empty"),
2198 "got: {s}"
2199 );
2200 });
2201 h.shutdown(&runtime);
2202 }
2203
2204 #[test]
2220 fn ingest_document_args_parse_with_required_path() {
2221 let v: IngestDocumentArgs =
2222 serde_json::from_value(json!({ "path": "/tmp/notes.md" })).expect("parses");
2223 assert_eq!(v.path, "/tmp/notes.md");
2224 let err = serde_json::from_value::<IngestDocumentArgs>(json!({})).unwrap_err();
2226 assert!(format!("{err}").contains("path"));
2227 }
2228
2229 #[test]
2230 fn search_docs_args_parse_with_default_limit() {
2231 let v: SearchDocsArgs =
2232 serde_json::from_value(json!({ "query": "backups" })).expect("parses");
2233 assert_eq!(v.query, "backups");
2234 assert_eq!(v.limit, 5, "default limit must be 5");
2235 let v: SearchDocsArgs =
2236 serde_json::from_value(json!({ "query": "backups", "limit": 20 })).expect("parses");
2237 assert_eq!(v.limit, 20);
2238 }
2239
2240 #[test]
2241 fn inspect_document_args_parse_with_required_doc_id() {
2242 let v: InspectDocumentArgs =
2243 serde_json::from_value(json!({ "doc_id": "abc" })).expect("parses");
2244 assert_eq!(v.doc_id, "abc");
2245 let err = serde_json::from_value::<InspectDocumentArgs>(json!({})).unwrap_err();
2246 assert!(format!("{err}").contains("doc_id"));
2247 }
2248
2249 #[test]
2250 fn list_documents_args_parse_with_all_defaults() {
2251 let v: ListDocumentsArgs = serde_json::from_value(json!({})).expect("parses");
2252 assert_eq!(v.limit, 20, "default limit must be 20");
2253 assert_eq!(v.offset, 0, "default offset must be 0");
2254 assert!(!v.include_forgotten, "default include_forgotten must be false");
2255 let v: ListDocumentsArgs = serde_json::from_value(
2256 json!({ "limit": 5, "offset": 10, "include_forgotten": true }),
2257 )
2258 .expect("parses");
2259 assert_eq!(v.limit, 5);
2260 assert_eq!(v.offset, 10);
2261 assert!(v.include_forgotten);
2262 }
2263
2264 #[test]
2265 fn forget_document_args_parse_with_required_doc_id() {
2266 let v: ForgetDocumentArgs =
2267 serde_json::from_value(json!({ "doc_id": "abc" })).expect("parses");
2268 assert_eq!(v.doc_id, "abc");
2269 let err = serde_json::from_value::<ForgetDocumentArgs>(json!({})).unwrap_err();
2270 assert!(format!("{err}").contains("doc_id"));
2271 }
2272
2273 #[test]
2274 fn ingest_document_rejects_empty_path() {
2275 let runtime = rt();
2278 let h = Harness::new(&runtime);
2279 runtime.block_on(async {
2280 let err = h
2281 .server
2282 .dispatch_tool("memory_ingest_document", json!({ "path": "" }))
2283 .await
2284 .expect_err("empty path must error");
2285 let s = format!("{err:?}");
2286 assert!(
2287 s.to_lowercase().contains("path")
2288 || s.to_lowercase().contains("must not be empty"),
2289 "got: {s}"
2290 );
2291 });
2292 h.shutdown(&runtime);
2293 }
2294
2295 #[test]
2296 fn search_docs_rejects_empty_query() {
2297 let runtime = rt();
2300 let h = Harness::new(&runtime);
2301 runtime.block_on(async {
2302 let err = h
2303 .server
2304 .dispatch_tool("memory_search_docs", json!({ "query": " " }))
2305 .await
2306 .expect_err("empty query must error");
2307 let s = format!("{err:?}");
2308 assert!(
2309 s.to_lowercase().contains("must not be empty")
2310 || s.to_lowercase().contains("invalid"),
2311 "got: {s}"
2312 );
2313 });
2314 h.shutdown(&runtime);
2315 }
2316
2317 #[test]
2318 fn inspect_document_unknown_id_returns_invalid_params() {
2319 let runtime = rt();
2322 let h = Harness::new(&runtime);
2323 runtime.block_on(async {
2324 let err = h
2325 .server
2326 .dispatch_tool(
2327 "memory_inspect_document",
2328 json!({ "doc_id": "00000000-0000-7000-8000-000000000000" }),
2329 )
2330 .await
2331 .expect_err("unknown doc must error");
2332 let s = format!("{err:?}");
2333 assert!(
2334 s.to_lowercase().contains("not found"),
2335 "expected 'not found' message; got: {s}"
2336 );
2337 });
2338 h.shutdown(&runtime);
2339 }
2340
2341 #[test]
2342 fn inspect_document_rejects_malformed_id() {
2343 let runtime = rt();
2344 let h = Harness::new(&runtime);
2345 runtime.block_on(async {
2346 let err = h
2347 .server
2348 .dispatch_tool(
2349 "memory_inspect_document",
2350 json!({ "doc_id": "not-a-uuid" }),
2351 )
2352 .await
2353 .expect_err("malformed doc_id must error");
2354 let s = format!("{err:?}");
2355 assert!(s.contains("invalid doc_id"), "got: {s}");
2356 });
2357 h.shutdown(&runtime);
2358 }
2359
2360 #[test]
2361 fn list_documents_returns_empty_array_on_empty_db() {
2362 let runtime = rt();
2363 let h = Harness::new(&runtime);
2364 runtime.block_on(async {
2365 let r = h
2366 .server
2367 .dispatch_tool("memory_list_documents", json!({}))
2368 .await
2369 .expect("list succeeds");
2370 let text = first_text(&r);
2371 let v: serde_json::Value =
2372 serde_json::from_str(&text).expect("parses as json");
2373 assert!(v.is_array(), "expected array, got: {text}");
2374 assert_eq!(v.as_array().unwrap().len(), 0);
2375 });
2376 h.shutdown(&runtime);
2377 }
2378
2379 #[test]
2380 fn list_documents_passes_through_limit_offset_include_args() {
2381 let runtime = rt();
2382 let h = Harness::new(&runtime);
2383 runtime.block_on(async {
2384 let r = h
2385 .server
2386 .dispatch_tool(
2387 "memory_list_documents",
2388 json!({ "limit": 5, "offset": 10, "include_forgotten": true }),
2389 )
2390 .await
2391 .expect("list with args succeeds");
2392 let text = first_text(&r);
2393 let v: serde_json::Value =
2394 serde_json::from_str(&text).expect("parses as json");
2395 assert!(v.is_array());
2396 });
2397 h.shutdown(&runtime);
2398 }
2399
2400 #[test]
2401 fn forget_document_rejects_malformed_id() {
2402 let runtime = rt();
2403 let h = Harness::new(&runtime);
2404 runtime.block_on(async {
2405 let err = h
2406 .server
2407 .dispatch_tool(
2408 "memory_forget_document",
2409 json!({ "doc_id": "not-a-uuid" }),
2410 )
2411 .await
2412 .expect_err("malformed doc_id must error");
2413 let s = format!("{err:?}");
2414 assert!(s.contains("invalid doc_id"), "got: {s}");
2415 });
2416 h.shutdown(&runtime);
2417 }
2418}
2419
2420#[cfg(test)]
2431mod principal_extraction_tests {
2432 use super::*;
2433 use std::sync::Mutex;
2434
2435 static ENV_LOCK: Mutex<()> = Mutex::new(());
2439
2440 struct EnvGuard;
2443 impl Drop for EnvGuard {
2444 fn drop(&mut self) {
2445 unsafe { std::env::remove_var(ENV_MCP_PRINCIPAL_TOKEN) };
2447 }
2448 }
2449
2450 fn set_principal_env(val: &str) -> EnvGuard {
2451 unsafe { std::env::set_var(ENV_MCP_PRINCIPAL_TOKEN, val) };
2453 EnvGuard
2454 }
2455
2456 fn clear_principal_env() -> EnvGuard {
2457 unsafe { std::env::remove_var(ENV_MCP_PRINCIPAL_TOKEN) };
2459 EnvGuard
2460 }
2461
2462 #[test]
2465 fn stdio_env_var_resolves_to_principal() {
2466 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2467 let _g = set_principal_env("alice-token");
2468 let resolved = resolve_mcp_principal(None);
2469 assert_eq!(resolved.as_deref(), Some("alice-token"));
2470 }
2471
2472 #[test]
2475 fn stdio_no_env_var_resolves_to_none() {
2476 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2477 let _g = clear_principal_env();
2478 assert_eq!(resolve_mcp_principal(None), None);
2479 }
2480
2481 #[test]
2485 fn stdio_whitespace_env_var_resolves_to_none() {
2486 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2487 let _g = set_principal_env(" \t ");
2488 assert_eq!(resolve_mcp_principal(None), None);
2489 }
2490
2491 #[test]
2494 fn http_header_resolves_to_bearer_token_principal() {
2495 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2496 let _g = clear_principal_env();
2497 let resolved = resolve_mcp_principal(Some("Bearer api-token-xyz"));
2498 assert_eq!(resolved.as_deref(), Some("api-token-xyz"));
2499 }
2500
2501 #[test]
2505 fn http_header_beats_env_var() {
2506 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2507 let _g = set_principal_env("env-token");
2508 let resolved = resolve_mcp_principal(Some("Bearer header-token"));
2509 assert_eq!(
2510 resolved.as_deref(),
2511 Some("header-token"),
2512 "header MUST win over env var per documented precedence"
2513 );
2514 }
2515
2516 #[test]
2519 fn http_malformed_header_falls_through_to_env() {
2520 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2521 let _g = set_principal_env("env-fallback");
2522 let resolved = resolve_mcp_principal(Some("Basic dXNlcjpwYXNz"));
2523 assert_eq!(resolved.as_deref(), Some("env-fallback"));
2524 }
2525
2526 #[test]
2531 fn http_empty_bearer_header_falls_through_to_env() {
2532 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2533 let _g = set_principal_env("env-fallback");
2534 let resolved = resolve_mcp_principal(Some("Bearer "));
2535 assert_eq!(resolved.as_deref(), Some("env-fallback"));
2536 }
2537
2538 #[test]
2544 fn stable_across_multiple_resolutions() {
2545 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2546 let _g = set_principal_env("stable-token");
2547 for _ in 0..5 {
2548 assert_eq!(
2549 resolve_mcp_principal(None).as_deref(),
2550 Some("stable-token")
2551 );
2552 }
2553 }
2554}
2555
2556#[cfg(test)]
2567mod initialize_decision_tests {
2568 use super::*;
2569 use solo_storage::LlmSettings;
2570
2571 #[test]
2573 fn no_llm_block_allows_initialize_regardless_of_sampling_capability() {
2574 assert_eq!(initialize_decision(&None, false), InitializeDecision::Allow);
2575 assert_eq!(initialize_decision(&None, true), InitializeDecision::Allow);
2576 }
2577
2578 #[test]
2580 fn llm_none_allows_initialize_regardless_of_sampling_capability() {
2581 let s = Some(LlmSettings::None);
2582 assert_eq!(initialize_decision(&s, false), InitializeDecision::Allow);
2583 assert_eq!(initialize_decision(&s, true), InitializeDecision::Allow);
2584 }
2585
2586 #[test]
2588 fn llm_anthropic_allows_initialize_regardless_of_sampling_capability() {
2589 let s = Some(LlmSettings::Anthropic {
2590 api_key_env: "ANTHROPIC_API_KEY".into(),
2591 model: "claude-sonnet-4-6".into(),
2592 });
2593 assert_eq!(initialize_decision(&s, false), InitializeDecision::Allow);
2594 assert_eq!(initialize_decision(&s, true), InitializeDecision::Allow);
2595 }
2596
2597 #[test]
2599 fn llm_ollama_allows_initialize_regardless_of_sampling_capability() {
2600 let s = Some(LlmSettings::Ollama {
2601 base_url: "http://localhost:11434".into(),
2602 model: "qwen3-coder:30b".into(),
2603 });
2604 assert_eq!(initialize_decision(&s, false), InitializeDecision::Allow);
2605 assert_eq!(initialize_decision(&s, true), InitializeDecision::Allow);
2606 }
2607
2608 #[test]
2611 fn llm_mcp_sampling_with_sampling_capability_populates_slot() {
2612 let s = Some(LlmSettings::McpSampling);
2613 assert_eq!(
2614 initialize_decision(&s, true),
2615 InitializeDecision::PopulateSamplingSteward
2616 );
2617 }
2618
2619 #[test]
2622 fn llm_mcp_sampling_without_sampling_capability_rejects() {
2623 let s = Some(LlmSettings::McpSampling);
2624 assert_eq!(
2625 initialize_decision(&s, false),
2626 InitializeDecision::RejectMissingSamplingCapability
2627 );
2628 }
2629
2630 #[test]
2634 fn sampling_capability_missing_error_message_contains_all_alternatives() {
2635 let msg = sampling_capability_missing_error_message();
2636 assert!(msg.contains("LLM backend `mcp_sampling`"));
2638 assert!(msg.contains("mode = \"anthropic\""));
2639 assert!(msg.contains("api_key_env = \"ANTHROPIC_API_KEY\""));
2640 assert!(msg.contains("mode = \"openai\""));
2641 assert!(msg.contains("api_key_env = \"OPENAI_API_KEY\""));
2642 assert!(msg.contains("mode = \"ollama\""));
2643 assert!(msg.contains("base_url = \"http://localhost:11434\""));
2644 assert!(msg.contains("mode = \"none\""));
2645 assert!(msg.contains("docs/releases/v0.9.0.md"));
2647 }
2648}
2649
2650