1use std::sync::Arc;
61
62use rmcp::handler::server::ServerHandler;
63use rmcp::model::{
64 CallToolRequestParams as CallToolRequestParam, CallToolResult, Content, Implementation,
65 InitializeRequestParams, InitializeResult, ListToolsResult,
66 PaginatedRequestParams as PaginatedRequestParam, ProtocolVersion,
67 ServerCapabilities, ServerInfo, Tool,
68};
69use rmcp::service::{RequestContext, RoleServer};
70use rmcp::{ErrorData as McpError, ServiceExt};
71use serde::{Deserialize, Serialize};
72use solo_core::{
73 Confidence, DocumentId, EncodingContext, Episode, MemoryId, Tier,
74};
75use solo_storage::{TenantHandle, TenantRegistry};
76use std::str::FromStr;
77
78#[derive(Clone)]
88pub struct SoloMcpServer {
89 inner: Arc<Inner>,
90}
91
92struct Inner {
93 #[allow(dead_code)]
98 registry: Arc<TenantRegistry>,
99 tenant: Arc<TenantHandle>,
102 user_aliases: Vec<String>,
108 audit_principal: Option<String>,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum InitializeDecision {
127 Allow,
132 PopulateSamplingSteward,
136 RejectMissingSamplingCapability,
140}
141
142pub fn initialize_decision(
149 llm_settings: &Option<solo_storage::LlmSettings>,
150 peer_sampling_supported: bool,
151) -> InitializeDecision {
152 match llm_settings {
153 Some(settings) if settings.requires_mcp_peer() => {
154 if peer_sampling_supported {
155 InitializeDecision::PopulateSamplingSteward
156 } else {
157 InitializeDecision::RejectMissingSamplingCapability
158 }
159 }
160 _ => InitializeDecision::Allow,
161 }
162}
163
164pub fn sampling_capability_missing_error_message() -> String {
174 [
175 "LLM backend `mcp_sampling` requires a connected MCP client that",
176 "advertises the `sampling` capability at initialize. Either the",
177 "current MCP client does not support sampling, or this Solo",
178 "process is running in daemon-only mode (no peer to call back).",
179 "",
180 "Pick one of:",
181 "",
182 " # Anthropic (hosted):",
183 " [llm]",
184 " mode = \"anthropic\"",
185 " api_key_env = \"ANTHROPIC_API_KEY\"",
186 " model = \"claude-sonnet-4-6\"",
187 "",
188 " # OpenAI (hosted):",
189 " [llm]",
190 " mode = \"openai\"",
191 " api_key_env = \"OPENAI_API_KEY\"",
192 " model = \"gpt-5o\"",
193 "",
194 " # Ollama (local daemon):",
195 " [llm]",
196 " mode = \"ollama\"",
197 " base_url = \"http://localhost:11434\"",
198 " model = \"qwen3-coder:30b\"",
199 "",
200 " # None (cluster-only; abstractions skipped):",
201 " [llm]",
202 " mode = \"none\"",
203 "",
204 "See docs/releases/v0.9.0.md \u{00a7}LLM-backend selection for details.",
205 ]
206 .join("\n")
207}
208
209pub const ENV_MCP_PRINCIPAL_TOKEN: &str = "SOLO_MCP_PRINCIPAL_TOKEN";
224
225pub fn resolve_mcp_principal(header_value: Option<&str>) -> Option<String> {
242 if let Some(h) = header_value {
244 if let Some(token) = h.strip_prefix("Bearer ") {
245 let trimmed = token.trim();
246 if !trimmed.is_empty() {
247 return Some(trimmed.to_string());
253 }
254 }
255 }
256 match std::env::var(ENV_MCP_PRINCIPAL_TOKEN) {
258 Ok(v) => {
259 let trimmed = v.trim();
260 if trimmed.is_empty() {
261 None
262 } else {
263 Some(trimmed.to_string())
264 }
265 }
266 Err(_) => None,
267 }
268}
269
270impl SoloMcpServer {
271 pub fn new_for_tenant(
281 registry: Arc<TenantRegistry>,
282 tenant: Arc<TenantHandle>,
283 user_aliases: Vec<String>,
284 ) -> Self {
285 let principal = resolve_mcp_principal(None);
286 Self::new_for_tenant_with_principal(registry, tenant, user_aliases, principal)
287 }
288
289 pub fn new_for_tenant_with_principal(
302 registry: Arc<TenantRegistry>,
303 tenant: Arc<TenantHandle>,
304 user_aliases: Vec<String>,
305 audit_principal: Option<String>,
306 ) -> Self {
307 Self {
308 inner: Arc::new(Inner {
309 registry,
310 tenant,
311 user_aliases,
312 audit_principal,
313 }),
314 }
315 }
316}
317
318pub async fn serve_stdio(server: SoloMcpServer) -> anyhow::Result<()> {
321 use rmcp::transport::io::stdio;
322 let (stdin, stdout) = stdio();
323 let running = server.serve((stdin, stdout)).await?;
324 running.waiting().await?;
325 Ok(())
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct RememberArgs {
334 pub content: String,
335 #[serde(default)]
336 pub source_type: Option<String>,
337 #[serde(default)]
338 pub source_id: Option<String>,
339 #[serde(default)]
343 pub salience: Option<f32>,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct RememberItem {
356 pub content: String,
357 #[serde(default)]
358 pub source_type: Option<String>,
359 #[serde(default)]
360 pub source_id: Option<String>,
361 #[serde(default)]
364 pub salience: Option<f32>,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
373pub struct RememberBatchArgs {
374 pub items: Vec<RememberItem>,
375}
376
377fn validate_salience(salience: Option<f32>) -> std::result::Result<(), McpError> {
381 if let Some(s) = salience {
382 if !s.is_finite() || !(0.0..=1.0).contains(&s) {
383 return Err(McpError::invalid_params(
384 format!("salience must be in [0.0, 1.0]; got {s}"),
385 None,
386 ));
387 }
388 }
389 Ok(())
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct RecallArgs {
394 pub query: String,
395 #[serde(default = "default_limit")]
396 pub limit: usize,
397}
398
399fn default_limit() -> usize {
400 5
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct ForgetArgs {
405 pub memory_id: String,
406 #[serde(default = "default_forget_reason")]
407 pub reason: String,
408}
409
410fn default_forget_reason() -> String {
411 "user-initiated via MCP".into()
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct InspectArgs {
416 pub memory_id: String,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
425pub struct ThemesArgs {
426 #[serde(default)]
430 pub window_days: Option<i64>,
431 #[serde(default = "default_limit")]
432 pub limit: usize,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct FactsAboutArgs {
437 pub subject: String,
440 #[serde(default)]
441 pub predicate: Option<String>,
442 #[serde(default)]
443 pub since_ms: Option<i64>,
444 #[serde(default)]
445 pub until_ms: Option<i64>,
446 #[serde(default)]
451 pub include_as_object: bool,
452 #[serde(default = "default_limit")]
453 pub limit: usize,
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize)]
457pub struct ContradictionsArgs {
458 #[serde(default = "default_limit")]
459 pub limit: usize,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct InspectClusterArgs {
467 pub cluster_id: String,
468 #[serde(default)]
473 pub full_content: bool,
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct IngestDocumentArgs {
481 pub path: String,
486}
487
488#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct SearchDocsArgs {
490 pub query: String,
491 #[serde(default = "default_search_docs_limit")]
492 pub limit: usize,
493}
494
495fn default_search_docs_limit() -> usize {
496 5
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct InspectDocumentArgs {
501 pub doc_id: String,
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct ListDocumentsArgs {
506 #[serde(default = "default_list_documents_limit")]
507 pub limit: usize,
508 #[serde(default)]
509 pub offset: usize,
510 #[serde(default)]
514 pub include_forgotten: bool,
515}
516
517fn default_list_documents_limit() -> usize {
518 20
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize)]
522pub struct ForgetDocumentArgs {
523 pub doc_id: String,
524}
525
526impl ServerHandler for SoloMcpServer {
531 fn get_info(&self) -> ServerInfo {
532 let capabilities = ServerCapabilities::builder()
536 .enable_tools()
537 .build();
538 let mut info = ServerInfo::default();
539 info.protocol_version = ProtocolVersion::default();
540 info.capabilities = capabilities;
541 info.server_info = Implementation::new(
551 "solo".to_string(),
552 env!("CARGO_PKG_VERSION").to_string(),
553 );
554 info.instructions = Some(
555 "Solo gives you persistent memory across conversations \
556 with this user — what they've told you before, the \
557 people and projects in their life, and where their \
558 stated beliefs have shifted, plus a library of \
559 documents the user has ingested (notes, runbooks, \
560 PDFs). Reach for these tools whenever the user \
561 references something from earlier (\"like I \
562 mentioned\", \"the project I'm working on\", \"my \
563 friend Alex\", \"the notes I uploaded last week\") \
564 or asks a question that hinges on personal context \
565 or document content you don't have in the current \
566 chat. \
567 \n\nTools to write or look up specific moments: \
568 memory_remember (save something worth keeping), \
569 memory_recall (search past conversations by topic), \
570 memory_inspect (show one saved item by id), \
571 memory_forget (delete one saved item). \
572 \n\nTools for the bigger picture (populated as the \
573 user uses Solo over time): memory_themes (recent \
574 topics they've been thinking about), \
575 memory_facts_about (what you know about a person, \
576 project, or place — \"what do you know about \
577 Alex?\"), memory_contradictions (places where the \
578 user has said two things that disagree — surface \
579 these before answering), memory_inspect_cluster \
580 (the raw conversations behind one summary). \
581 \n\nTools for the user's documents: \
582 memory_ingest_document (read a file from disk and \
583 add it to Solo's library), memory_search_docs \
584 (search across ingested documents by topic — use \
585 when the user asks about something they wrote down \
586 or saved as a file), memory_inspect_document (show \
587 one document's metadata plus a preview of its \
588 chunks), memory_list_documents (browse documents \
589 by recency), memory_forget_document (drop a \
590 document from the library)."
591 .into(),
592 );
593 info
594 }
595
596 async fn initialize(
612 &self,
613 request: InitializeRequestParams,
614 context: RequestContext<RoleServer>,
615 ) -> std::result::Result<InitializeResult, McpError> {
616 if context.peer.peer_info().is_none() {
619 context.peer.set_peer_info(request.clone());
620 }
621
622 let llm_settings =
623 self.inner.tenant.config().llm.as_ref().cloned();
624 let peer_sampling_supported =
625 request.capabilities.sampling.is_some();
626 match initialize_decision(&llm_settings, peer_sampling_supported) {
627 InitializeDecision::Allow => {}
628 InitializeDecision::PopulateSamplingSteward => {
629 self.populate_sampling_steward(&context).await;
633 }
634 InitializeDecision::RejectMissingSamplingCapability => {
635 return Err(McpError::invalid_request(
636 sampling_capability_missing_error_message(),
637 None,
638 ));
639 }
640 }
641
642 Ok(self.get_info())
643 }
644
645 async fn list_tools(
646 &self,
647 _request: Option<PaginatedRequestParam>,
648 _context: RequestContext<RoleServer>,
649 ) -> std::result::Result<ListToolsResult, McpError> {
650 Ok(ListToolsResult {
651 tools: build_tools(),
652 next_cursor: None,
653 ..Default::default()
654 })
655 }
656
657 async fn call_tool(
658 &self,
659 request: CallToolRequestParam,
660 _context: RequestContext<RoleServer>,
661 ) -> std::result::Result<CallToolResult, McpError> {
662 let CallToolRequestParam { name, arguments, .. } = request;
663 let args_value = serde_json::Value::Object(arguments.unwrap_or_default());
664 self.dispatch_tool(&name, args_value, None).await
670 }
671}
672
673impl SoloMcpServer {
674 async fn populate_sampling_steward(
699 &self,
700 context: &RequestContext<RoleServer>,
701 ) {
702 let steward_config = solo_steward::StewardConfig::from_env()
703 .unwrap_or_else(|e| {
704 tracing::warn!(
705 error = %e,
706 "v0.9.0 P2: StewardConfig::from_env failed at MCP \
707 initialize; falling back to defaults"
708 );
709 solo_steward::StewardConfig::default()
710 });
711 let sampling_config = self.inner.tenant.config().sampling.clone();
717 let peer = context.peer.clone();
718 let write_handle = self.inner.tenant.write().clone();
719 let steward = crate::llm::build_sampling_steward(
720 peer,
721 write_handle,
722 self.inner.audit_principal.clone(),
723 steward_config,
724 sampling_config.clone(),
725 );
726 let slot = self.inner.tenant.steward_slot();
727 let mut guard = slot.write().await;
728 *guard = Some(steward);
729 tracing::info!(
730 tenant = %self.inner.tenant.tenant_id(),
731 coalesce_window_ms = sampling_config.coalesce_window_ms,
732 coalesce_max_requests = sampling_config.coalesce_max_requests,
733 "v0.9.0 P5: MCP-sampling Steward attached to tenant.steward_slot \
734 (PeerSamplingClient → SamplingCoordinator → SamplingLlmClient)"
735 );
736 }
737
738 pub async fn dispatch_tool(
752 &self,
753 name: &str,
754 args_value: serde_json::Value,
755 progress: Option<crate::mcp_progress::ProgressReporter>,
756 ) -> std::result::Result<CallToolResult, McpError> {
757 match name {
758 "memory_remember" => {
759 let args: RememberArgs = parse_args(&args_value)?;
760 self.handle_remember(args).await
761 }
762 "memory_remember_batch" => {
763 let args: RememberBatchArgs = parse_args(&args_value)?;
764 self.handle_remember_batch(args, progress).await
765 }
766 "memory_recall" => {
767 let args: RecallArgs = parse_args(&args_value)?;
768 self.handle_recall(args).await
769 }
770 "memory_forget" => {
771 let args: ForgetArgs = parse_args(&args_value)?;
772 self.handle_forget(args).await
773 }
774 "memory_inspect" => {
775 let args: InspectArgs = parse_args(&args_value)?;
776 self.handle_inspect(args).await
777 }
778 "memory_themes" => {
779 let args: ThemesArgs = parse_args(&args_value)?;
780 self.handle_themes(args).await
781 }
782 "memory_facts_about" => {
783 let args: FactsAboutArgs = parse_args(&args_value)?;
784 self.handle_facts_about(args).await
785 }
786 "memory_contradictions" => {
787 let args: ContradictionsArgs = parse_args(&args_value)?;
788 self.handle_contradictions(args).await
789 }
790 "memory_inspect_cluster" => {
791 let args: InspectClusterArgs = parse_args(&args_value)?;
792 self.handle_inspect_cluster(args).await
793 }
794 "memory_ingest_document" => {
795 let args: IngestDocumentArgs = parse_args(&args_value)?;
796 self.handle_ingest_document(args, progress).await
797 }
798 "memory_search_docs" => {
799 let args: SearchDocsArgs = parse_args(&args_value)?;
800 self.handle_search_docs(args, progress).await
801 }
802 "memory_inspect_document" => {
803 let args: InspectDocumentArgs = parse_args(&args_value)?;
804 self.handle_inspect_document(args).await
805 }
806 "memory_list_documents" => {
807 let args: ListDocumentsArgs = parse_args(&args_value)?;
808 self.handle_list_documents(args).await
809 }
810 "memory_forget_document" => {
811 let args: ForgetDocumentArgs = parse_args(&args_value)?;
812 self.handle_forget_document(args).await
813 }
814 other => Err(McpError::invalid_params(
815 format!("unknown tool `{other}`"),
816 None,
817 )),
818 }
819 }
820
821 pub fn dispatch_list_tools(&self) -> Vec<Tool> {
824 build_tools()
825 }
826}
827
828fn parse_args<T: serde::de::DeserializeOwned>(
829 v: &serde_json::Value,
830) -> std::result::Result<T, McpError> {
831 serde_json::from_value(v.clone()).map_err(|e| {
832 McpError::invalid_params(format!("invalid tool arguments: {e}"), None)
833 })
834}
835
836fn solo_to_mcp(e: solo_core::Error) -> McpError {
837 use solo_core::Error;
838 match e {
839 Error::NotFound(msg) => McpError::invalid_params(msg, None),
840 Error::InvalidInput(msg) => McpError::invalid_params(msg, None),
841 Error::Conflict(msg) => McpError::invalid_params(msg, None),
842 other => McpError::internal_error(other.to_string(), None),
843 }
844}
845
846fn build_tools() -> Vec<Tool> {
851 vec![
852 Tool::new(
853 "memory_remember",
854 "Save something the user has told you — a fact, a \
855 preference, a name, a date, a context — so you can pick \
856 it up next conversation. Use whenever the user mentions \
857 something they'd reasonably expect you to recall later \
858 (\"I just started at Quotient\", \"my partner is Maya\"). \
859 Returns the saved item's id.",
860 json_schema_object(serde_json::json!({
861 "type": "object",
862 "properties": {
863 "content": {
864 "type": "string",
865 "description": "The text to remember.",
866 },
867 "source_type": {
868 "type": "string",
869 "description": "Optional source-type tag (default: \"user_message\"). See docs/mcp/source-types.md for convention values.",
870 },
871 "source_id": {
872 "type": "string",
873 "description": "Optional upstream id for traceability.",
874 },
875 "salience": {
876 "type": "number",
877 "description": "Optional salience in [0.0, 1.0]; defaults to 0.5. Higher values bias toward recall ranking + retention. v0.9.2+.",
878 "minimum": 0.0,
879 "maximum": 1.0,
880 },
881 },
882 "required": ["content"],
883 })),
884 ),
885 Tool::new(
891 "memory_remember_batch",
892 "Save several items atomically in one transaction — either \
893 every item lands or none does. Use this when you have a \
894 collection of related episodes from one logical step (a \
895 conversation turn, a tool-output bundle, an ingest batch) \
896 and partial success would leave the user's memory in a \
897 confusing half-state. Each item carries the same fields as \
898 memory_remember (content + optional source_type, source_id, \
899 salience). Returns an ordered array of memory_ids matching \
900 the input items. v0.9.2+.",
901 json_schema_object(serde_json::json!({
902 "type": "object",
903 "properties": {
904 "items": {
905 "type": "array",
906 "description": "Items to remember atomically. Max 200 per call.",
907 "minItems": 1,
908 "maxItems": 200,
909 "items": {
910 "type": "object",
911 "properties": {
912 "content": {
913 "type": "string",
914 "description": "The text to remember.",
915 },
916 "source_type": {
917 "type": "string",
918 "description": "Optional source-type tag (default: \"user_message\"). See docs/mcp/source-types.md.",
919 },
920 "source_id": {
921 "type": "string",
922 "description": "Optional upstream id for traceability.",
923 },
924 "salience": {
925 "type": "number",
926 "description": "Optional salience in [0.0, 1.0]; defaults to 0.5.",
927 "minimum": 0.0,
928 "maximum": 1.0,
929 },
930 },
931 "required": ["content"],
932 },
933 },
934 },
935 "required": ["items"],
936 })),
937 ),
938 Tool::new(
939 "memory_recall",
940 "Search past conversations with this user by topic or \
941 phrase. Returns up to `limit` of the closest matches, \
942 best match first. Use when the user references \
943 something they said before (\"that book I told you \
944 about\", \"the bug we were debugging last week\"). \
945 Skips items the user has deleted.",
946 json_schema_object(serde_json::json!({
947 "type": "object",
948 "properties": {
949 "query": {
950 "type": "string",
951 "description": "The query text.",
952 },
953 "limit": {
954 "type": "integer",
955 "description": "Maximum results (default 5).",
956 "minimum": 1,
957 "maximum": 100,
958 },
959 },
960 "required": ["query"],
961 })),
962 ),
963 Tool::new(
964 "memory_forget",
965 "Delete one saved item by id. Use when the user asks you \
966 to forget something specific (\"forget that I said \
967 X\"). The item stops appearing in future recalls. \
968 Reversible only via backups.",
969 json_schema_object(serde_json::json!({
970 "type": "object",
971 "properties": {
972 "memory_id": {
973 "type": "string",
974 "description": "MemoryId to forget (UUID v7).",
975 },
976 "reason": {
977 "type": "string",
978 "description": "Optional free-form reason (logged, not yet persisted).",
979 },
980 },
981 "required": ["memory_id"],
982 })),
983 ),
984 Tool::new(
985 "memory_inspect",
986 "Show the full record for one saved item — when it was \
987 saved, where it came from, and the full text. Use after \
988 memory_recall when you want the complete content of a \
989 specific hit (recall results may be truncated).",
990 json_schema_object(serde_json::json!({
991 "type": "object",
992 "properties": {
993 "memory_id": {
994 "type": "string",
995 "description": "MemoryId to inspect (UUID v7).",
996 },
997 },
998 "required": ["memory_id"],
999 })),
1000 ),
1001 Tool::new(
1005 "memory_themes",
1006 "Recent topics the user has been thinking about. Use to \
1007 orient yourself at the start of a conversation, or when \
1008 the user asks \"what have I been up to\" / \"what was I \
1009 working on last week\". Pass `window_days` to scope \
1010 (e.g. 7 for last week); omit for all-time.",
1011 json_schema_object(serde_json::json!({
1012 "type": "object",
1013 "properties": {
1014 "window_days": {
1015 "type": "integer",
1016 "description": "Optional time window in days. Omit for unfiltered.",
1017 "minimum": 1,
1018 },
1019 "limit": {
1020 "type": "integer",
1021 "description": "Maximum results (default 5).",
1022 "minimum": 1,
1023 "maximum": 100,
1024 },
1025 },
1026 })),
1027 ),
1028 Tool::new(
1029 "memory_facts_about",
1030 "Look up what you remember about a person, project, or \
1031 topic — names, dates, preferences, relationships. Use \
1032 when the user asks \"what do you know about Alex?\", \
1033 \"when did I start at Quotient?\", \"who is Maya?\", or \
1034 whenever you need grounded facts about someone or \
1035 something before answering. Subject is required (the \
1036 person/place/thing you're asking about); narrow further \
1037 with `predicate` (\"works_at\", \"lives_in\") or a date \
1038 range. Set `include_as_object=true` to also surface \
1039 facts where the subject appears on the receiving side of \
1040 a relationship (e.g. \"Sam pushes back on PRs about \
1041 Maya\" surfaces under facts_about(subject=\"Maya\", \
1042 include_as_object=true)). (Backed by \
1043 subject-predicate-object triples distilled from past \
1044 conversations.) Clients should set a 30s timeout on this \
1045 call; if exceeded, retry once or fall back to \
1046 `memory_recall`.",
1047 json_schema_object(serde_json::json!({
1048 "type": "object",
1049 "properties": {
1050 "subject": {
1051 "type": "string",
1052 "description": "Subject id to query (e.g. 'Sam').",
1053 },
1054 "predicate": {
1055 "type": "string",
1056 "description": "Optional predicate filter (e.g. 'works_at').",
1057 },
1058 "since_ms": {
1059 "type": "integer",
1060 "description": "Optional valid_from_ms lower bound (epoch ms).",
1061 },
1062 "until_ms": {
1063 "type": "integer",
1064 "description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through.",
1065 },
1066 "include_as_object": {
1067 "type": "boolean",
1068 "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.",
1069 "default": false,
1070 },
1071 "limit": {
1072 "type": "integer",
1073 "description": "Maximum results (default 5).",
1074 "minimum": 1,
1075 "maximum": 100,
1076 },
1077 },
1078 "required": ["subject"],
1079 })),
1080 ),
1081 Tool::new(
1082 "memory_contradictions",
1083 "Find places where the user's stated beliefs or facts \
1084 disagree across conversations — flag disagreements \
1085 before answering. Use whenever you're about to rely on \
1086 a remembered fact that could have changed (jobs, \
1087 relationships, preferences, opinions); a disagreement \
1088 here means the user has told you both X and not-X over \
1089 time and you should ask which is current instead of \
1090 guessing. Each result shows both conflicting statements \
1091 with the topic.",
1092 json_schema_object(serde_json::json!({
1093 "type": "object",
1094 "properties": {
1095 "limit": {
1096 "type": "integer",
1097 "description": "Maximum results (default 5).",
1098 "minimum": 1,
1099 "maximum": 100,
1100 },
1101 },
1102 })),
1103 ),
1104 Tool::new(
1105 "memory_inspect_cluster",
1106 "Show the raw conversations behind one summary. Returns \
1107 the one-line topic (the LLM-generated summary) and the \
1108 source conversations the topic was built from. Use \
1109 after memory_themes when the user asks \"show me the \
1110 raw context behind this\" or \"why does Solo think \
1111 that about cluster Y\". Source items are truncated to \
1112 200 chars unless `full_content` is set.",
1113 json_schema_object(serde_json::json!({
1114 "type": "object",
1115 "properties": {
1116 "cluster_id": {
1117 "type": "string",
1118 "description": "Cluster id to inspect (from memory_themes hits).",
1119 },
1120 "full_content": {
1121 "type": "boolean",
1122 "description": "If true, episode content is returned verbatim. Default false (truncate to 200 chars + ellipsis).",
1123 },
1124 },
1125 "required": ["cluster_id"],
1126 })),
1127 ),
1128 Tool::new(
1132 "memory_ingest_document",
1133 "Read a file from disk and add it to the user's document \
1134 library so it becomes searchable alongside past \
1135 conversations. Use when the user asks you to remember a \
1136 whole file (\"add my notes/runbook.md\", \"ingest this \
1137 PDF\"). The file is split into ~500-token chunks and \
1138 each chunk is embedded; chunks then surface through \
1139 memory_search_docs. Returns the new document id, chunk \
1140 count, and a `deduped` flag (true if the same content \
1141 was already ingested under another id).",
1142 json_schema_object(serde_json::json!({
1143 "type": "object",
1144 "properties": {
1145 "path": {
1146 "type": "string",
1147 "description": "Server-side absolute path to the file to ingest. The file must be readable by the Solo process.",
1148 },
1149 },
1150 "required": ["path"],
1151 })),
1152 ),
1153 Tool::new(
1154 "memory_search_docs",
1155 "Search across the user's ingested documents by topic or \
1156 phrase. Returns up to `limit` matching chunks, best \
1157 match first, each with the parent document's title + \
1158 source path so you can cite where the answer came from. \
1159 Use when the user asks a question that hinges on \
1160 material they've added as a file (\"what does my \
1161 runbook say about backups?\", \"find the section in the \
1162 notes about the new policy\"). Forgotten documents are \
1163 skipped.",
1164 json_schema_object(serde_json::json!({
1165 "type": "object",
1166 "properties": {
1167 "query": {
1168 "type": "string",
1169 "description": "The query text.",
1170 },
1171 "limit": {
1172 "type": "integer",
1173 "description": "Maximum results (default 5).",
1174 "minimum": 1,
1175 "maximum": 100,
1176 },
1177 },
1178 "required": ["query"],
1179 })),
1180 ),
1181 Tool::new(
1182 "memory_inspect_document",
1183 "Show one document's metadata plus a preview of every \
1184 chunk it was split into. Use after memory_search_docs \
1185 when the user wants the bigger picture for one hit \
1186 (\"show me the whole document this came from\"), or \
1187 after memory_list_documents to drill into one entry. \
1188 Each chunk preview is truncated to 200 chars.",
1189 json_schema_object(serde_json::json!({
1190 "type": "object",
1191 "properties": {
1192 "doc_id": {
1193 "type": "string",
1194 "description": "Document id to inspect (UUID v7).",
1195 },
1196 },
1197 "required": ["doc_id"],
1198 })),
1199 ),
1200 Tool::new(
1201 "memory_list_documents",
1202 "List the user's ingested documents, newest first. Use \
1203 when the user asks \"what documents have I added?\" or \
1204 \"show me my files\". Returns a paginated index — pass \
1205 `offset` to page further back. Forgotten documents are \
1206 hidden by default; set `include_forgotten=true` to see \
1207 them too.",
1208 json_schema_object(serde_json::json!({
1209 "type": "object",
1210 "properties": {
1211 "limit": {
1212 "type": "integer",
1213 "description": "Maximum results per page (default 20).",
1214 "minimum": 1,
1215 "maximum": 100,
1216 },
1217 "offset": {
1218 "type": "integer",
1219 "description": "Number of rows to skip (for paging). Default 0.",
1220 "minimum": 0,
1221 },
1222 "include_forgotten": {
1223 "type": "boolean",
1224 "description": "If true, also include documents the user has forgotten. Default false.",
1225 },
1226 },
1227 })),
1228 ),
1229 Tool::new(
1230 "memory_forget_document",
1231 "Drop one document from the user's library by id. Use \
1232 when the user asks you to forget a specific file \
1233 (\"forget my old runbook\"). The document's chunks stop \
1234 appearing in memory_search_docs and the vectors are \
1235 tombstoned in the index. The chunk rows themselves are \
1236 kept for forensic value (a future restore command can \
1237 undo this).",
1238 json_schema_object(serde_json::json!({
1239 "type": "object",
1240 "properties": {
1241 "doc_id": {
1242 "type": "string",
1243 "description": "Document id to forget (UUID v7).",
1244 },
1245 },
1246 "required": ["doc_id"],
1247 })),
1248 ),
1249 ]
1250}
1251
1252fn json_schema_object(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
1253 match value {
1254 serde_json::Value::Object(map) => map,
1255 _ => panic!("json_schema_object: input must be an object"),
1256 }
1257}
1258
1259pub fn tool_names() -> Vec<&'static str> {
1268 vec![
1269 "memory_remember",
1270 "memory_remember_batch",
1272 "memory_recall",
1273 "memory_forget",
1274 "memory_inspect",
1275 "memory_themes",
1276 "memory_facts_about",
1277 "memory_contradictions",
1278 "memory_inspect_cluster",
1279 "memory_ingest_document",
1281 "memory_search_docs",
1282 "memory_inspect_document",
1283 "memory_list_documents",
1284 "memory_forget_document",
1285 ]
1286}
1287
1288impl SoloMcpServer {
1293 async fn handle_remember(
1294 &self,
1295 args: RememberArgs,
1296 ) -> std::result::Result<CallToolResult, McpError> {
1297 let content = args.content.trim_end().to_string();
1298 if content.is_empty() {
1299 return Err(McpError::invalid_params(
1300 "memory_remember: content must not be empty".to_string(),
1301 None,
1302 ));
1303 }
1304 validate_salience(args.salience)?;
1305 let embedding: solo_core::Embedding = self
1306 .inner
1307 .tenant
1308 .embedder()
1309 .embed(&content)
1310 .await
1311 .map_err(solo_to_mcp)?;
1312 let episode = Episode {
1313 memory_id: MemoryId::new(),
1314 ts_ms: chrono::Utc::now().timestamp_millis(),
1315 source_type: args.source_type.unwrap_or_else(|| "user_message".into()),
1316 source_id: args.source_id,
1317 content,
1318 encoding_context: EncodingContext::default(),
1319 provenance: None,
1320 confidence: Confidence::new(0.9).unwrap(),
1321 strength: 0.5,
1322 salience: args.salience.unwrap_or(0.5),
1326 tier: Tier::Hot,
1327 };
1328 let mid = self
1329 .inner
1330 .tenant
1331 .write()
1332 .remember_as(self.inner.audit_principal.clone(), episode, embedding)
1333 .await
1334 .map_err(solo_to_mcp)?;
1335 Ok(CallToolResult::success(vec![Content::text(format!(
1336 "remembered {mid}"
1337 ))]))
1338 }
1339
1340 async fn handle_remember_batch(
1360 &self,
1361 args: RememberBatchArgs,
1362 progress: Option<crate::mcp_progress::ProgressReporter>,
1363 ) -> std::result::Result<CallToolResult, McpError> {
1364 if args.items.is_empty() {
1370 return Err(McpError::invalid_params(
1371 "memory_remember_batch: items must not be empty".to_string(),
1372 None,
1373 ));
1374 }
1375 if args.items.len() > solo_storage::MAX_REMEMBER_BATCH_SIZE {
1376 return Err(McpError::invalid_params(
1377 format!(
1378 "memory_remember_batch: {} items exceeds MAX_REMEMBER_BATCH_SIZE = {}",
1379 args.items.len(),
1380 solo_storage::MAX_REMEMBER_BATCH_SIZE,
1381 ),
1382 None,
1383 ));
1384 }
1385 for (i, item) in args.items.iter().enumerate() {
1386 if item.content.trim_end().is_empty() {
1387 return Err(McpError::invalid_params(
1388 format!("memory_remember_batch: items[{i}].content must not be empty"),
1389 None,
1390 ));
1391 }
1392 validate_salience(item.salience).map_err(|e| {
1393 McpError::invalid_params(
1396 format!("memory_remember_batch: items[{i}].{}", e.message),
1397 None,
1398 )
1399 })?;
1400 }
1401
1402 let total = args.items.len() as u64;
1409 let progress_active = progress.is_some()
1410 && args.items.len() > crate::mcp_progress::MCP_REMEMBER_BATCH_PROGRESS_ITEM_THRESHOLD;
1411 let progress_reporter = if progress_active { progress.as_ref() } else { None };
1412
1413 let embedder = self.inner.tenant.embedder();
1415 let now_ms = chrono::Utc::now().timestamp_millis();
1416 let mut pairs: Vec<(Episode, solo_core::Embedding)> = Vec::with_capacity(args.items.len());
1417 for (i, item) in args.items.into_iter().enumerate() {
1418 let content = item.content.trim_end().to_string();
1419 let embedding = embedder.embed(&content).await.map_err(solo_to_mcp)?;
1420 let episode = Episode {
1421 memory_id: MemoryId::new(),
1422 ts_ms: now_ms,
1423 source_type: item.source_type.unwrap_or_else(|| "user_message".into()),
1424 source_id: item.source_id,
1425 content,
1426 encoding_context: EncodingContext::default(),
1427 provenance: None,
1428 confidence: Confidence::new(0.9).unwrap(),
1429 strength: 0.5,
1430 salience: item.salience.unwrap_or(0.5),
1431 tier: Tier::Hot,
1432 };
1433 pairs.push((episode, embedding));
1434 let done = (i + 1) as u64;
1438 if (i + 1) % crate::mcp_progress::MCP_REMEMBER_BATCH_PROGRESS_EMIT_EVERY == 0 {
1439 crate::mcp_progress::report_if_some(
1440 progress_reporter,
1441 done,
1442 Some(total),
1443 Some("embedding"),
1444 );
1445 }
1446 }
1447
1448 crate::mcp_progress::report_if_some(
1453 progress_reporter,
1454 total,
1455 Some(total),
1456 Some("embedded"),
1457 );
1458
1459 let memory_ids = self
1461 .inner
1462 .tenant
1463 .write()
1464 .remember_batch_as(self.inner.audit_principal.clone(), pairs)
1465 .await
1466 .map_err(solo_to_mcp)?;
1467
1468 crate::mcp_progress::report_if_some(
1475 progress_reporter,
1476 total,
1477 Some(total),
1478 Some("inserted"),
1479 );
1480
1481 let ids_as_strings: Vec<String> =
1486 memory_ids.iter().map(|m| m.to_string()).collect();
1487 let body = serde_json::to_string(&ids_as_strings).map_err(|e| {
1488 McpError::internal_error(format!("serialize batch reply: {e}"), None)
1489 })?;
1490 Ok(CallToolResult::success(vec![Content::text(body)]))
1491 }
1492
1493 async fn handle_recall(
1494 &self,
1495 args: RecallArgs,
1496 ) -> std::result::Result<CallToolResult, McpError> {
1497 let result = solo_query::run_recall(
1501 self.inner.tenant.as_ref(),
1502 self.inner.audit_principal.clone(),
1503 &args.query,
1504 args.limit,
1505 )
1506 .await
1507 .map_err(solo_to_mcp)?;
1508
1509 if result.hits.is_empty() {
1510 return Ok(CallToolResult::success(vec![Content::text(format!(
1511 "no matches (index has {} vectors)",
1512 result.index_len
1513 ))]));
1514 }
1515 let body = serde_json::to_string_pretty(&result.hits).unwrap_or_else(|_| String::new());
1516 Ok(CallToolResult::success(vec![Content::text(body)]))
1517 }
1518
1519 async fn handle_forget(
1520 &self,
1521 args: ForgetArgs,
1522 ) -> std::result::Result<CallToolResult, McpError> {
1523 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
1524 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
1525 })?;
1526 self.inner
1527 .tenant
1528 .write()
1529 .forget_as(self.inner.audit_principal.clone(), mid, args.reason)
1530 .await
1531 .map_err(solo_to_mcp)?;
1532 Ok(CallToolResult::success(vec![Content::text(format!(
1533 "forgotten {mid}"
1534 ))]))
1535 }
1536
1537 async fn handle_inspect(
1538 &self,
1539 args: InspectArgs,
1540 ) -> std::result::Result<CallToolResult, McpError> {
1541 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
1542 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
1543 })?;
1544 let row = solo_query::inspect_one(
1546 self.inner.tenant.read(),
1547 self.inner.tenant.audit(),
1548 self.inner.audit_principal.clone(),
1549 mid,
1550 )
1551 .await
1552 .map_err(solo_to_mcp)?;
1553 let body = serde_json::to_string_pretty(&row).unwrap_or_else(|_| String::new());
1554 Ok(CallToolResult::success(vec![Content::text(body)]))
1555 }
1556
1557 async fn handle_themes(
1564 &self,
1565 args: ThemesArgs,
1566 ) -> std::result::Result<CallToolResult, McpError> {
1567 let hits = solo_query::themes(
1568 self.inner.tenant.read(),
1569 self.inner.tenant.audit(),
1570 self.inner.audit_principal.clone(),
1571 args.window_days,
1572 args.limit,
1573 )
1574 .await
1575 .map_err(solo_to_mcp)?;
1576 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1577 Ok(CallToolResult::success(vec![Content::text(body)]))
1578 }
1579
1580 async fn handle_facts_about(
1581 &self,
1582 args: FactsAboutArgs,
1583 ) -> std::result::Result<CallToolResult, McpError> {
1584 if args.subject.trim().is_empty() {
1585 return Err(McpError::invalid_params(
1586 "memory_facts_about: subject must not be empty".to_string(),
1587 None,
1588 ));
1589 }
1590 let hits = solo_query::facts_about(
1591 self.inner.tenant.read(),
1592 self.inner.tenant.audit(),
1593 self.inner.audit_principal.clone(),
1594 &args.subject,
1595 &self.inner.user_aliases,
1596 args.include_as_object,
1597 args.predicate.as_deref(),
1598 args.since_ms,
1599 args.until_ms,
1600 args.limit,
1601 )
1602 .await
1603 .map_err(solo_to_mcp)?;
1604 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1605 Ok(CallToolResult::success(vec![Content::text(body)]))
1606 }
1607
1608 async fn handle_contradictions(
1609 &self,
1610 args: ContradictionsArgs,
1611 ) -> std::result::Result<CallToolResult, McpError> {
1612 let hits = solo_query::contradictions(
1613 self.inner.tenant.read(),
1614 self.inner.tenant.audit(),
1615 self.inner.audit_principal.clone(),
1616 args.limit,
1617 )
1618 .await
1619 .map_err(solo_to_mcp)?;
1620 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1621 Ok(CallToolResult::success(vec![Content::text(body)]))
1622 }
1623
1624 async fn handle_inspect_cluster(
1625 &self,
1626 args: InspectClusterArgs,
1627 ) -> std::result::Result<CallToolResult, McpError> {
1628 if args.cluster_id.trim().is_empty() {
1629 return Err(McpError::invalid_params(
1630 "memory_inspect_cluster: cluster_id must not be empty".to_string(),
1631 None,
1632 ));
1633 }
1634 let record = solo_query::inspect_cluster(
1639 self.inner.tenant.read(),
1640 self.inner.tenant.audit(),
1641 self.inner.audit_principal.clone(),
1642 &args.cluster_id,
1643 args.full_content,
1644 )
1645 .await
1646 .map_err(solo_to_mcp)?;
1647 let body = serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
1648 Ok(CallToolResult::success(vec![Content::text(body)]))
1649 }
1650
1651 async fn handle_ingest_document(
1656 &self,
1657 args: IngestDocumentArgs,
1658 progress: Option<crate::mcp_progress::ProgressReporter>,
1659 ) -> std::result::Result<CallToolResult, McpError> {
1660 if args.path.trim().is_empty() {
1661 return Err(McpError::invalid_params(
1662 "memory_ingest_document: path must not be empty".to_string(),
1663 None,
1664 ));
1665 }
1666 let path = std::path::PathBuf::from(args.path);
1667 let chunk_config = solo_storage::document::ChunkConfig::default();
1671
1672 const INGEST_TOTAL_PHASES: u64 = 4;
1683 crate::mcp_progress::report_if_some(
1684 progress.as_ref(),
1685 1,
1686 Some(INGEST_TOTAL_PHASES),
1687 Some("parsed"),
1688 );
1689 crate::mcp_progress::report_if_some(
1690 progress.as_ref(),
1691 2,
1692 Some(INGEST_TOTAL_PHASES),
1693 Some("chunked"),
1694 );
1695
1696 let report = self
1697 .inner
1698 .tenant
1699 .write()
1700 .ingest_document_as(self.inner.audit_principal.clone(), path, chunk_config)
1701 .await
1702 .map_err(solo_to_mcp)?;
1703
1704 crate::mcp_progress::report_if_some(
1705 progress.as_ref(),
1706 3,
1707 Some(INGEST_TOTAL_PHASES),
1708 Some("embedded"),
1709 );
1710 crate::mcp_progress::report_if_some(
1715 progress.as_ref(),
1716 INGEST_TOTAL_PHASES,
1717 Some(INGEST_TOTAL_PHASES),
1718 Some(&format!("inserted {} chunks", report.chunks_persisted)),
1719 );
1720
1721 let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| String::new());
1722 Ok(CallToolResult::success(vec![Content::text(body)]))
1723 }
1724
1725 async fn handle_search_docs(
1726 &self,
1727 args: SearchDocsArgs,
1728 progress: Option<crate::mcp_progress::ProgressReporter>,
1729 ) -> std::result::Result<CallToolResult, McpError> {
1730 let top_k = args.limit as u32;
1736 let progress_active = progress.is_some()
1737 && top_k > crate::mcp_progress::MCP_SEARCH_DOCS_PROGRESS_TOP_K_THRESHOLD;
1738 let progress_reporter = if progress_active { progress.as_ref() } else { None };
1739 const SEARCH_TOTAL_PHASES: u64 = 3;
1740 crate::mcp_progress::report_if_some(
1741 progress_reporter,
1742 1,
1743 Some(SEARCH_TOTAL_PHASES),
1744 Some("hnsw_lookup"),
1745 );
1746
1747 let hits = solo_query::run_doc_search(
1751 self.inner.tenant.as_ref(),
1752 self.inner.audit_principal.clone(),
1753 &args.query,
1754 args.limit,
1755 )
1756 .await
1757 .map_err(solo_to_mcp)?;
1758
1759 crate::mcp_progress::report_if_some(
1760 progress_reporter,
1761 2,
1762 Some(SEARCH_TOTAL_PHASES),
1763 Some("reranked"),
1764 );
1765 crate::mcp_progress::report_if_some(
1766 progress_reporter,
1767 SEARCH_TOTAL_PHASES,
1768 Some(SEARCH_TOTAL_PHASES),
1769 Some(&format!("returning {} hits", hits.len())),
1770 );
1771
1772 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1773 Ok(CallToolResult::success(vec![Content::text(body)]))
1774 }
1775
1776 async fn handle_inspect_document(
1777 &self,
1778 args: InspectDocumentArgs,
1779 ) -> std::result::Result<CallToolResult, McpError> {
1780 let doc_id = DocumentId::from_str(&args.doc_id).map_err(|e| {
1781 McpError::invalid_params(format!("invalid doc_id: {e}"), None)
1782 })?;
1783 let result_opt = solo_query::inspect_document(
1784 self.inner.tenant.read(),
1785 self.inner.tenant.audit(),
1786 self.inner.audit_principal.clone(),
1787 &doc_id,
1788 )
1789 .await
1790 .map_err(solo_to_mcp)?;
1791 match result_opt {
1792 Some(record) => {
1793 let body =
1794 serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
1795 Ok(CallToolResult::success(vec![Content::text(body)]))
1796 }
1797 None => Err(McpError::invalid_params(
1798 format!("document {doc_id} not found"),
1799 None,
1800 )),
1801 }
1802 }
1803
1804 async fn handle_list_documents(
1805 &self,
1806 args: ListDocumentsArgs,
1807 ) -> std::result::Result<CallToolResult, McpError> {
1808 let rows = solo_query::list_documents(
1809 self.inner.tenant.read(),
1810 self.inner.tenant.audit(),
1811 self.inner.audit_principal.clone(),
1812 args.limit,
1813 args.offset,
1814 args.include_forgotten,
1815 )
1816 .await
1817 .map_err(solo_to_mcp)?;
1818 let body = serde_json::to_string_pretty(&rows).unwrap_or_else(|_| String::new());
1819 Ok(CallToolResult::success(vec![Content::text(body)]))
1820 }
1821
1822 async fn handle_forget_document(
1823 &self,
1824 args: ForgetDocumentArgs,
1825 ) -> std::result::Result<CallToolResult, McpError> {
1826 let doc_id = DocumentId::from_str(&args.doc_id).map_err(|e| {
1827 McpError::invalid_params(format!("invalid doc_id: {e}"), None)
1828 })?;
1829 let report = self
1830 .inner
1831 .tenant
1832 .write()
1833 .forget_document_as(self.inner.audit_principal.clone(), doc_id)
1834 .await
1835 .map_err(solo_to_mcp)?;
1836 let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| String::new());
1837 Ok(CallToolResult::success(vec![Content::text(body)]))
1838 }
1839}
1840
1841#[cfg(test)]
1842mod dispatch_tests {
1843 use super::*;
1855 use serde_json::json;
1856 use solo_core::VectorIndex;
1857 use solo_storage::test_support::StubVectorIndex;
1858 use solo_storage::{
1859 EmbedderConfig, IdentityConfig, KeyMaterial, ReaderPool, SoloConfig,
1860 StubEmbedder, TenantHandle, TenantRegistry, WriterActor, WriterSpawn,
1861 };
1862 use std::sync::Arc as StdArc;
1863
1864 fn fake_config(dim: u32) -> SoloConfig {
1865 SoloConfig {
1866 schema_version: 1,
1867 salt_hex: "00000000000000000000000000000000".to_string(),
1868 embedder: EmbedderConfig {
1869 name: "stub".to_string(),
1870 version: "v1".to_string(),
1871 dim,
1872 dtype: "f32".to_string(),
1873 },
1874 identity: IdentityConfig::default(),
1875 documents: solo_storage::DocumentConfig::default(),
1876 auth: None,
1877 audit: solo_storage::AuditSettings::default(),
1878 redaction: solo_storage::RedactionConfig::default(),
1879 llm: None,
1880 triples: solo_storage::TriplesConfig::default(),
1881 sampling: solo_storage::SamplingConfig::default(),
1882 }
1883 }
1884
1885 struct Harness {
1886 server: SoloMcpServer,
1887 _tmp: tempfile::TempDir,
1888 write_handle_extra: Option<solo_storage::WriteHandle>,
1889 join: Option<std::thread::JoinHandle<()>>,
1890 }
1891
1892 impl Harness {
1893 fn new(runtime: &tokio::runtime::Runtime) -> Self {
1894 let tmp = tempfile::TempDir::new().unwrap();
1895 let dim = 16usize;
1896 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1897 let embedder: StdArc<dyn solo_core::Embedder> = StdArc::new(StubEmbedder::new("stub", "v1", dim));
1898
1899 let conn = solo_storage::test_support::open_test_db_at(&tmp.path().join("test.db"));
1900 let WriterSpawn { handle, join } = WriterActor::spawn(conn, hnsw.clone());
1901
1902 let path = tmp.path().join("test.db");
1905 let pool: ReaderPool =
1906 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1907
1908 let tenant_id = solo_core::TenantId::default_tenant();
1909 let tenant_handle = StdArc::new(
1910 TenantHandle::from_parts_for_tests(
1911 tenant_id.clone(),
1912 fake_config(dim as u32),
1913 path.clone(),
1914 tmp.path().to_path_buf(),
1915 0, hnsw,
1917 embedder.clone(),
1918 handle.clone(),
1919 std::thread::spawn(|| {}),
1920 pool,
1921 ),
1922 );
1923 let key = KeyMaterial::from_bytes_for_tests([0u8; 32]);
1924 let registry = StdArc::new(TenantRegistry::for_tests_with_single_tenant(
1925 tmp.path().to_path_buf(),
1926 key,
1927 embedder,
1928 tenant_handle.clone(),
1929 ));
1930 let server = SoloMcpServer::new_for_tenant(registry, tenant_handle, Vec::new());
1931 Harness {
1932 server,
1933 _tmp: tmp,
1934 write_handle_extra: Some(handle),
1935 join: Some(join),
1936 }
1937 }
1938
1939 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1940 let join = self.join.take();
1946 let extra = self.write_handle_extra.take();
1947 runtime.block_on(async move {
1948 drop(extra);
1949 drop(self.server);
1950 drop(self._tmp);
1951 if let Some(join) = join {
1952 let (tx, rx) = std::sync::mpsc::channel();
1953 std::thread::spawn(move || {
1954 let _ = tx.send(join.join());
1955 });
1956 tokio::task::spawn_blocking(move || {
1957 rx.recv_timeout(std::time::Duration::from_secs(5))
1958 })
1959 .await
1960 .expect("blocking task")
1961 .expect("writer thread did not exit within 5s")
1962 .expect("writer thread panicked");
1963 }
1964 });
1965 }
1966 }
1967
1968 fn rt() -> tokio::runtime::Runtime {
1969 tokio::runtime::Builder::new_multi_thread()
1970 .worker_threads(2)
1971 .enable_all()
1972 .build()
1973 .unwrap()
1974 }
1975
1976 fn first_text(r: &rmcp::model::CallToolResult) -> String {
1981 let first = r.content.first().expect("at least one content item");
1982 let v = serde_json::to_value(first).expect("content serialises");
1983 v.get("text")
1984 .and_then(|t| t.as_str())
1985 .map(|s| s.to_string())
1986 .unwrap_or_else(|| format!("{v}"))
1987 }
1988
1989 #[test]
1990 fn tools_list_returns_fourteen_canonical_tools() {
1991 let runtime = rt();
1992 let h = Harness::new(&runtime);
1993 let tools = h.server.dispatch_list_tools();
1994 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1995 assert_eq!(
1996 names,
1997 vec![
1998 "memory_remember",
1999 "memory_remember_batch",
2001 "memory_recall",
2002 "memory_forget",
2003 "memory_inspect",
2004 "memory_themes",
2006 "memory_facts_about",
2007 "memory_contradictions",
2008 "memory_inspect_cluster",
2010 "memory_ingest_document",
2012 "memory_search_docs",
2013 "memory_inspect_document",
2014 "memory_list_documents",
2015 "memory_forget_document",
2016 ]
2017 );
2018 for t in &tools {
2019 let desc = t.description.as_deref().unwrap_or("");
2021 assert!(!desc.is_empty(), "{} description empty", t.name);
2022 let _schema = t.schema_as_json_value();
2023 }
2030 h.shutdown(&runtime);
2031 }
2032
2033 #[test]
2034 fn themes_returns_json_array_on_empty_db() {
2035 let runtime = rt();
2036 let h = Harness::new(&runtime);
2037 runtime.block_on(async {
2038 let r = h
2039 .server
2040 .dispatch_tool("memory_themes", json!({}), None)
2041 .await
2042 .expect("themes succeeds");
2043 let text = first_text(&r);
2044 let v: serde_json::Value =
2046 serde_json::from_str(&text).expect("parses as json");
2047 assert!(v.is_array(), "expected array, got: {text}");
2048 assert_eq!(v.as_array().unwrap().len(), 0);
2049 });
2050 h.shutdown(&runtime);
2051 }
2052
2053 #[test]
2054 fn themes_passes_through_window_and_limit_args() {
2055 let runtime = rt();
2056 let h = Harness::new(&runtime);
2057 runtime.block_on(async {
2058 let r = h
2060 .server
2061 .dispatch_tool(
2062 "memory_themes",
2063 json!({ "window_days": 7, "limit": 20 }),
2064 None,
2065 )
2066 .await
2067 .expect("themes with args succeeds");
2068 let text = first_text(&r);
2069 let v: serde_json::Value =
2070 serde_json::from_str(&text).expect("parses as json");
2071 assert!(v.is_array());
2072 });
2073 h.shutdown(&runtime);
2074 }
2075
2076 #[test]
2077 fn facts_about_rejects_empty_subject() {
2078 let runtime = rt();
2079 let h = Harness::new(&runtime);
2080 runtime.block_on(async {
2081 let err = h
2082 .server
2083 .dispatch_tool(
2084 "memory_facts_about",
2085 json!({ "subject": " " }),
2086 None,
2087 )
2088 .await
2089 .expect_err("empty subject must error");
2090 let s = format!("{err:?}");
2093 assert!(
2094 s.to_lowercase().contains("subject")
2095 || s.to_lowercase().contains("invalid"),
2096 "got: {s}"
2097 );
2098 });
2099 h.shutdown(&runtime);
2100 }
2101
2102 #[test]
2103 fn facts_about_returns_array_for_unknown_subject() {
2104 let runtime = rt();
2105 let h = Harness::new(&runtime);
2106 runtime.block_on(async {
2107 let r = h
2108 .server
2109 .dispatch_tool(
2110 "memory_facts_about",
2111 json!({ "subject": "NobodyKnowsThisSubject" }),
2112 None,
2113 )
2114 .await
2115 .expect("facts_about with unknown subject succeeds");
2116 let text = first_text(&r);
2117 let v: serde_json::Value =
2118 serde_json::from_str(&text).expect("parses as json");
2119 assert_eq!(v.as_array().unwrap().len(), 0);
2120 });
2121 h.shutdown(&runtime);
2122 }
2123
2124 #[test]
2125 fn facts_about_accepts_include_as_object_arg() {
2126 let runtime = rt();
2134 let h = Harness::new(&runtime);
2135 runtime.block_on(async {
2136 let r = h
2138 .server
2139 .dispatch_tool(
2140 "memory_facts_about",
2141 json!({ "subject": "Maya", "include_as_object": true }),
2142 None,
2143 )
2144 .await
2145 .expect("dispatch with include_as_object=true succeeds");
2146 let v: serde_json::Value = serde_json::from_str(&first_text(&r))
2147 .expect("parses as json");
2148 assert_eq!(v.as_array().unwrap().len(), 0);
2149
2150 let r = h
2152 .server
2153 .dispatch_tool(
2154 "memory_facts_about",
2155 json!({ "subject": "Maya" }),
2156 None,
2157 )
2158 .await
2159 .expect("dispatch without include_as_object succeeds (default false)");
2160 let v: serde_json::Value = serde_json::from_str(&first_text(&r))
2161 .expect("parses as json");
2162 assert_eq!(v.as_array().unwrap().len(), 0);
2163 });
2164 h.shutdown(&runtime);
2165 }
2166
2167 #[test]
2168 fn contradictions_returns_json_array_on_empty_db() {
2169 let runtime = rt();
2170 let h = Harness::new(&runtime);
2171 runtime.block_on(async {
2172 let r = h
2173 .server
2174 .dispatch_tool("memory_contradictions", json!({}), None)
2175 .await
2176 .expect("contradictions succeeds");
2177 let text = first_text(&r);
2178 let v: serde_json::Value =
2179 serde_json::from_str(&text).expect("parses as json");
2180 assert!(v.is_array());
2181 assert_eq!(v.as_array().unwrap().len(), 0);
2182 });
2183 h.shutdown(&runtime);
2184 }
2185
2186 #[test]
2187 fn remember_then_recall_round_trip() {
2188 let runtime = rt();
2189 let h = Harness::new(&runtime);
2190 runtime.block_on(async {
2196 let r = h
2197 .server
2198 .dispatch_tool("memory_remember", json!({ "content": "the cat sat on the mat" }), None)
2199 .await
2200 .expect("remember succeeds");
2201 let text = first_text(&r);
2202 assert!(text.starts_with("remembered "), "got: {text}");
2203
2204 let r = h
2205 .server
2206 .dispatch_tool(
2207 "memory_recall",
2208 json!({ "query": "the cat sat on the mat", "limit": 5 }),
2209 None,
2210 )
2211 .await
2212 .expect("recall succeeds");
2213 let text = first_text(&r);
2214 assert!(text.contains("the cat sat on the mat"), "got: {text}");
2215 });
2216 h.shutdown(&runtime);
2217 }
2218
2219 #[test]
2220 fn forget_excludes_row_from_subsequent_recall() {
2221 let runtime = rt();
2222 let h = Harness::new(&runtime);
2223
2224 runtime.block_on(async {
2225 let r = h
2226 .server
2227 .dispatch_tool("memory_remember", json!({ "content": "to be forgotten" }), None)
2228 .await
2229 .unwrap();
2230 let text = first_text(&r);
2231 let mid = text.strip_prefix("remembered ").unwrap().to_string();
2232
2233 h.server
2234 .dispatch_tool(
2235 "memory_forget",
2236 json!({ "memory_id": mid, "reason": "test" }),
2237 None,
2238 )
2239 .await
2240 .expect("forget succeeds");
2241
2242 let r = h
2243 .server
2244 .dispatch_tool(
2245 "memory_recall",
2246 json!({ "query": "to be forgotten", "limit": 5 }),
2247 None,
2248 )
2249 .await
2250 .unwrap();
2251 let text = first_text(&r);
2252 assert!(
2253 !text.contains(r#""content": "to be forgotten""#),
2254 "forgotten row should be excluded; got: {text}"
2255 );
2256 });
2257 h.shutdown(&runtime);
2258 }
2259
2260 #[test]
2261 fn empty_remember_returns_invalid_params() {
2262 let runtime = rt();
2263 let h = Harness::new(&runtime);
2264 runtime.block_on(async {
2265 let err = h
2266 .server
2267 .dispatch_tool("memory_remember", json!({ "content": "" }), None)
2268 .await
2269 .unwrap_err();
2270 assert!(format!("{err:?}").contains("must not be empty"));
2271 });
2272 h.shutdown(&runtime);
2273 }
2274
2275 #[test]
2276 fn empty_recall_query_returns_invalid_params() {
2277 let runtime = rt();
2278 let h = Harness::new(&runtime);
2279 runtime.block_on(async {
2280 let err = h
2281 .server
2282 .dispatch_tool("memory_recall", json!({ "query": " " }), None)
2283 .await
2284 .unwrap_err();
2285 assert!(format!("{err:?}").contains("must not be empty"));
2286 });
2287 h.shutdown(&runtime);
2288 }
2289
2290 #[test]
2291 fn inspect_with_invalid_id_returns_invalid_params() {
2292 let runtime = rt();
2293 let h = Harness::new(&runtime);
2294 runtime.block_on(async {
2295 let err = h
2296 .server
2297 .dispatch_tool("memory_inspect", json!({ "memory_id": "not-a-uuid" }), None)
2298 .await
2299 .unwrap_err();
2300 assert!(format!("{err:?}").contains("invalid memory_id"));
2301 });
2302 h.shutdown(&runtime);
2303 }
2304
2305 #[test]
2306 fn forget_unknown_id_returns_invalid_params() {
2307 let runtime = rt();
2308 let h = Harness::new(&runtime);
2309 runtime.block_on(async {
2310 let err = h
2314 .server
2315 .dispatch_tool(
2316 "memory_forget",
2317 json!({ "memory_id": "00000000-0000-7000-8000-000000000000" }),
2318 None,
2319 )
2320 .await
2321 .unwrap_err();
2322 assert!(format!("{err:?}").contains("not found"));
2323 });
2324 h.shutdown(&runtime);
2325 }
2326
2327 #[test]
2328 fn unknown_tool_name_returns_invalid_params() {
2329 let runtime = rt();
2330 let h = Harness::new(&runtime);
2331 runtime.block_on(async {
2332 let err = h
2333 .server
2334 .dispatch_tool("memory.summon", json!({}), None)
2335 .await
2336 .unwrap_err();
2337 assert!(format!("{err:?}").contains("unknown tool"));
2338 });
2339 h.shutdown(&runtime);
2340 }
2341
2342 #[test]
2377 fn tool_names_match_cross_provider_regex() {
2378 fn passes_anthropic(name: &str) -> bool {
2380 let len = name.len();
2381 if !(1..=64).contains(&len) {
2382 return false;
2383 }
2384 name.chars()
2385 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
2386 }
2387
2388 fn passes_openai(name: &str) -> bool {
2391 let len = name.len();
2392 if !(1..=64).contains(&len) {
2393 return false;
2394 }
2395 let mut chars = name.chars();
2396 let first = match chars.next() {
2397 Some(c) => c,
2398 None => return false,
2399 };
2400 if !(first.is_ascii_alphabetic() || first == '_') {
2401 return false;
2402 }
2403 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
2404 }
2405
2406 fn passes_gemini(name: &str) -> bool {
2411 let len = name.len();
2412 if !(1..=63).contains(&len) {
2413 return false;
2414 }
2415 let mut chars = name.chars();
2416 let first = match chars.next() {
2417 Some(c) => c,
2418 None => return false,
2419 };
2420 if !(first.is_ascii_alphabetic() || first == '_') {
2421 return false;
2422 }
2423 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
2424 }
2425
2426 let tools = build_tools();
2427 assert_eq!(
2428 tools.len(),
2429 14,
2430 "expected 14 tools in v0.9.2 (8 v0.5.x + 5 document tools + remember_batch)"
2431 );
2432 let tool_name_strings: Vec<String> =
2434 tools.iter().map(|t| t.name.to_string()).collect();
2435 let public_names: Vec<String> =
2436 super::tool_names().iter().map(|s| s.to_string()).collect();
2437 assert_eq!(
2438 tool_name_strings, public_names,
2439 "tool_names() drifted from build_tools() — keep them in sync"
2440 );
2441
2442 for t in tools {
2443 assert!(
2444 passes_anthropic(&t.name),
2445 "tool name {:?} fails Anthropic regex \
2446 ^[a-zA-Z0-9_-]{{1,64}}$ — see v0.3 lesson #8",
2447 t.name
2448 );
2449 assert!(
2450 passes_openai(&t.name),
2451 "tool name {:?} fails OpenAI function-calling regex \
2452 ^[a-zA-Z_][a-zA-Z0-9_-]*$ (len ≤ 64)",
2453 t.name
2454 );
2455 assert!(
2456 passes_gemini(&t.name),
2457 "tool name {:?} fails Gemini function-calling regex \
2458 ^[a-zA-Z_][a-zA-Z0-9_]*$ (len ≤ 63, strict)",
2459 t.name
2460 );
2461 }
2462 }
2463
2464 #[test]
2481 fn tool_descriptions_avoid_internal_jargon() {
2482 const FORBIDDEN: &[&str] = &[
2486 "SPO",
2487 "Steward",
2488 "Steward-flagged",
2489 "LEFT JOIN",
2490 "candidate pair",
2491 "candidate_pair",
2492 "tagged_with",
2493 ];
2494
2495 fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
2496 haystack.to_lowercase().contains(&needle.to_lowercase())
2497 }
2498
2499 for t in build_tools() {
2501 let desc = t.description.as_deref().unwrap_or("");
2502 for term in FORBIDDEN {
2503 assert!(
2504 !contains_case_insensitive(desc, term),
2505 "tool {:?} description contains forbidden jargon \
2506 {:?} — rewrite in plain English (see v0.5.0 \
2507 Priority 4)",
2508 t.name,
2509 term,
2510 );
2511 }
2512 }
2513
2514 let server_info = harness_server_info();
2517 let instructions = server_info
2518 .instructions
2519 .as_deref()
2520 .expect("get_info() must set instructions");
2521 for term in FORBIDDEN {
2522 assert!(
2523 !contains_case_insensitive(instructions, term),
2524 "get_info().instructions contains forbidden jargon \
2525 {:?} — rewrite in plain English",
2526 term,
2527 );
2528 }
2529 }
2530
2531 fn harness_server_info() -> rmcp::model::ServerInfo {
2538 let runtime = rt();
2539 let h = Harness::new(&runtime);
2540 let info = ServerHandler::get_info(&h.server);
2541 h.shutdown(&runtime);
2542 info
2543 }
2544
2545 #[test]
2566 fn server_info_identity_is_solo_not_rmcp_or_solo_api() {
2567 let info = harness_server_info();
2568 let name = info.server_info.name.as_str();
2569 let version = info.server_info.version.as_str();
2570 assert_eq!(
2571 name, "solo",
2572 "MCP serverInfo.name must be \"solo\" (not \"rmcp\" or \
2573 \"solo-api\"). got name={name:?} version={version:?}"
2574 );
2575 assert_eq!(
2576 version,
2577 env!("CARGO_PKG_VERSION"),
2578 "MCP serverInfo.version must match solo-api's compile-time \
2579 CARGO_PKG_VERSION (i.e. the workspace.package version); \
2580 a mismatch means we regressed back to rmcp's build env. \
2581 got version={version:?}"
2582 );
2583 }
2584
2585 #[test]
2588 fn inspect_cluster_unknown_id_returns_invalid_params() {
2589 let runtime = rt();
2593 let h = Harness::new(&runtime);
2594 runtime.block_on(async {
2595 let err = h
2596 .server
2597 .dispatch_tool(
2598 "memory_inspect_cluster",
2599 json!({ "cluster_id": "no-such-cluster" }),
2600 None,
2601 )
2602 .await
2603 .expect_err("unknown cluster must error");
2604 let s = format!("{err:?}");
2605 assert!(
2606 s.contains("no-such-cluster") || s.to_lowercase().contains("not found"),
2607 "expected error to mention the missing cluster id; got: {s}"
2608 );
2609 });
2610 h.shutdown(&runtime);
2611 }
2612
2613 #[test]
2614 fn inspect_cluster_rejects_empty_id() {
2615 let runtime = rt();
2616 let h = Harness::new(&runtime);
2617 runtime.block_on(async {
2618 let err = h
2619 .server
2620 .dispatch_tool(
2621 "memory_inspect_cluster",
2622 json!({ "cluster_id": " " }),
2623 None,
2624 )
2625 .await
2626 .expect_err("blank cluster_id must error");
2627 let s = format!("{err:?}");
2628 assert!(
2629 s.to_lowercase().contains("cluster_id")
2630 || s.to_lowercase().contains("must not be empty"),
2631 "got: {s}"
2632 );
2633 });
2634 h.shutdown(&runtime);
2635 }
2636
2637 #[test]
2653 fn ingest_document_args_parse_with_required_path() {
2654 let v: IngestDocumentArgs =
2655 serde_json::from_value(json!({ "path": "/tmp/notes.md" })).expect("parses");
2656 assert_eq!(v.path, "/tmp/notes.md");
2657 let err = serde_json::from_value::<IngestDocumentArgs>(json!({})).unwrap_err();
2659 assert!(format!("{err}").contains("path"));
2660 }
2661
2662 #[test]
2663 fn search_docs_args_parse_with_default_limit() {
2664 let v: SearchDocsArgs =
2665 serde_json::from_value(json!({ "query": "backups" })).expect("parses");
2666 assert_eq!(v.query, "backups");
2667 assert_eq!(v.limit, 5, "default limit must be 5");
2668 let v: SearchDocsArgs =
2669 serde_json::from_value(json!({ "query": "backups", "limit": 20 })).expect("parses");
2670 assert_eq!(v.limit, 20);
2671 }
2672
2673 #[test]
2674 fn inspect_document_args_parse_with_required_doc_id() {
2675 let v: InspectDocumentArgs =
2676 serde_json::from_value(json!({ "doc_id": "abc" })).expect("parses");
2677 assert_eq!(v.doc_id, "abc");
2678 let err = serde_json::from_value::<InspectDocumentArgs>(json!({})).unwrap_err();
2679 assert!(format!("{err}").contains("doc_id"));
2680 }
2681
2682 #[test]
2683 fn list_documents_args_parse_with_all_defaults() {
2684 let v: ListDocumentsArgs = serde_json::from_value(json!({})).expect("parses");
2685 assert_eq!(v.limit, 20, "default limit must be 20");
2686 assert_eq!(v.offset, 0, "default offset must be 0");
2687 assert!(!v.include_forgotten, "default include_forgotten must be false");
2688 let v: ListDocumentsArgs = serde_json::from_value(
2689 json!({ "limit": 5, "offset": 10, "include_forgotten": true }),
2690 )
2691 .expect("parses");
2692 assert_eq!(v.limit, 5);
2693 assert_eq!(v.offset, 10);
2694 assert!(v.include_forgotten);
2695 }
2696
2697 #[test]
2698 fn forget_document_args_parse_with_required_doc_id() {
2699 let v: ForgetDocumentArgs =
2700 serde_json::from_value(json!({ "doc_id": "abc" })).expect("parses");
2701 assert_eq!(v.doc_id, "abc");
2702 let err = serde_json::from_value::<ForgetDocumentArgs>(json!({})).unwrap_err();
2703 assert!(format!("{err}").contains("doc_id"));
2704 }
2705
2706 #[test]
2707 fn ingest_document_rejects_empty_path() {
2708 let runtime = rt();
2711 let h = Harness::new(&runtime);
2712 runtime.block_on(async {
2713 let err = h
2714 .server
2715 .dispatch_tool("memory_ingest_document", json!({ "path": "" }), None)
2716 .await
2717 .expect_err("empty path must error");
2718 let s = format!("{err:?}");
2719 assert!(
2720 s.to_lowercase().contains("path")
2721 || s.to_lowercase().contains("must not be empty"),
2722 "got: {s}"
2723 );
2724 });
2725 h.shutdown(&runtime);
2726 }
2727
2728 #[test]
2729 fn search_docs_rejects_empty_query() {
2730 let runtime = rt();
2733 let h = Harness::new(&runtime);
2734 runtime.block_on(async {
2735 let err = h
2736 .server
2737 .dispatch_tool("memory_search_docs", json!({ "query": " " }), None)
2738 .await
2739 .expect_err("empty query must error");
2740 let s = format!("{err:?}");
2741 assert!(
2742 s.to_lowercase().contains("must not be empty")
2743 || s.to_lowercase().contains("invalid"),
2744 "got: {s}"
2745 );
2746 });
2747 h.shutdown(&runtime);
2748 }
2749
2750 #[test]
2751 fn inspect_document_unknown_id_returns_invalid_params() {
2752 let runtime = rt();
2755 let h = Harness::new(&runtime);
2756 runtime.block_on(async {
2757 let err = h
2758 .server
2759 .dispatch_tool(
2760 "memory_inspect_document",
2761 json!({ "doc_id": "00000000-0000-7000-8000-000000000000" }),
2762 None,
2763 )
2764 .await
2765 .expect_err("unknown doc must error");
2766 let s = format!("{err:?}");
2767 assert!(
2768 s.to_lowercase().contains("not found"),
2769 "expected 'not found' message; got: {s}"
2770 );
2771 });
2772 h.shutdown(&runtime);
2773 }
2774
2775 #[test]
2776 fn inspect_document_rejects_malformed_id() {
2777 let runtime = rt();
2778 let h = Harness::new(&runtime);
2779 runtime.block_on(async {
2780 let err = h
2781 .server
2782 .dispatch_tool(
2783 "memory_inspect_document",
2784 json!({ "doc_id": "not-a-uuid" }),
2785 None,
2786 )
2787 .await
2788 .expect_err("malformed doc_id must error");
2789 let s = format!("{err:?}");
2790 assert!(s.contains("invalid doc_id"), "got: {s}");
2791 });
2792 h.shutdown(&runtime);
2793 }
2794
2795 #[test]
2796 fn list_documents_returns_empty_array_on_empty_db() {
2797 let runtime = rt();
2798 let h = Harness::new(&runtime);
2799 runtime.block_on(async {
2800 let r = h
2801 .server
2802 .dispatch_tool("memory_list_documents", json!({}), None)
2803 .await
2804 .expect("list succeeds");
2805 let text = first_text(&r);
2806 let v: serde_json::Value =
2807 serde_json::from_str(&text).expect("parses as json");
2808 assert!(v.is_array(), "expected array, got: {text}");
2809 assert_eq!(v.as_array().unwrap().len(), 0);
2810 });
2811 h.shutdown(&runtime);
2812 }
2813
2814 #[test]
2815 fn list_documents_passes_through_limit_offset_include_args() {
2816 let runtime = rt();
2817 let h = Harness::new(&runtime);
2818 runtime.block_on(async {
2819 let r = h
2820 .server
2821 .dispatch_tool(
2822 "memory_list_documents",
2823 json!({ "limit": 5, "offset": 10, "include_forgotten": true }),
2824 None,
2825 )
2826 .await
2827 .expect("list with args succeeds");
2828 let text = first_text(&r);
2829 let v: serde_json::Value =
2830 serde_json::from_str(&text).expect("parses as json");
2831 assert!(v.is_array());
2832 });
2833 h.shutdown(&runtime);
2834 }
2835
2836 #[test]
2837 fn forget_document_rejects_malformed_id() {
2838 let runtime = rt();
2839 let h = Harness::new(&runtime);
2840 runtime.block_on(async {
2841 let err = h
2842 .server
2843 .dispatch_tool(
2844 "memory_forget_document",
2845 json!({ "doc_id": "not-a-uuid" }),
2846 None,
2847 )
2848 .await
2849 .expect_err("malformed doc_id must error");
2850 let s = format!("{err:?}");
2851 assert!(s.contains("invalid doc_id"), "got: {s}");
2852 });
2853 h.shutdown(&runtime);
2854 }
2855
2856 #[test]
2864 fn remember_with_explicit_salience_round_trips() {
2865 let runtime = rt();
2866 let h = Harness::new(&runtime);
2867 runtime.block_on(async {
2868 let r = h
2869 .server
2870 .dispatch_tool(
2871 "memory_remember",
2872 json!({ "content": "with salience", "salience": 0.83 }),
2873 None,
2874 )
2875 .await
2876 .expect("remember w/ salience succeeds");
2877 let text = first_text(&r);
2878 assert!(text.starts_with("remembered "), "got: {text}");
2880 });
2881 h.shutdown(&runtime);
2882 }
2883
2884 #[test]
2885 fn remember_with_out_of_range_salience_returns_invalid_params() {
2886 let runtime = rt();
2887 let h = Harness::new(&runtime);
2888 runtime.block_on(async {
2889 let err = h
2890 .server
2891 .dispatch_tool(
2892 "memory_remember",
2893 json!({ "content": "out of range", "salience": 1.5 }),
2894 None,
2895 )
2896 .await
2897 .unwrap_err();
2898 let s = format!("{err:?}");
2899 assert!(s.contains("salience must be"), "got: {s}");
2900 });
2901 h.shutdown(&runtime);
2902 }
2903
2904 #[test]
2906 fn remember_with_boundary_salience_succeeds() {
2907 let runtime = rt();
2908 let h = Harness::new(&runtime);
2909 runtime.block_on(async {
2910 for s in [0.0_f64, 1.0_f64] {
2911 let r = h
2912 .server
2913 .dispatch_tool(
2914 "memory_remember",
2915 json!({ "content": format!("boundary-{s}"), "salience": s }),
2916 None,
2917 )
2918 .await
2919 .expect("boundary salience succeeds");
2920 assert!(first_text(&r).starts_with("remembered "));
2921 }
2922 });
2923 h.shutdown(&runtime);
2924 }
2925
2926 #[test]
2928 fn remember_batch_returns_ids_in_order() {
2929 let runtime = rt();
2930 let h = Harness::new(&runtime);
2931 runtime.block_on(async {
2932 let items = json!([
2933 { "content": "batch-a" },
2934 { "content": "batch-b", "source_type": "user_preference", "salience": 0.9 },
2935 { "content": "batch-c", "salience": 0.1 },
2936 ]);
2937 let r = h
2938 .server
2939 .dispatch_tool(
2940 "memory_remember_batch",
2941 json!({ "items": items }),
2942 None,
2943 )
2944 .await
2945 .expect("batch succeeds");
2946 let text = first_text(&r);
2947 let parsed: serde_json::Value =
2948 serde_json::from_str(&text).expect("reply is JSON");
2949 let arr = parsed.as_array().expect("reply is array");
2950 assert_eq!(arr.len(), 3, "3 items in → 3 ids out: {text}");
2951 for v in arr {
2953 let s = v.as_str().unwrap_or_else(|| panic!("non-string id: {v}"));
2954 assert_eq!(s.len(), 36, "UUID-shaped id expected: {s}");
2955 }
2956 let mut ids: Vec<&str> = arr.iter().map(|v| v.as_str().unwrap()).collect();
2958 ids.sort();
2959 ids.dedup();
2960 assert_eq!(ids.len(), 3, "ids must be distinct: {text}");
2961 });
2962 h.shutdown(&runtime);
2963 }
2964
2965 #[test]
2967 fn remember_batch_empty_items_returns_invalid_params() {
2968 let runtime = rt();
2969 let h = Harness::new(&runtime);
2970 runtime.block_on(async {
2971 let err = h
2972 .server
2973 .dispatch_tool(
2974 "memory_remember_batch",
2975 json!({ "items": [] }),
2976 None,
2977 )
2978 .await
2979 .unwrap_err();
2980 let s = format!("{err:?}");
2981 assert!(s.contains("must not be empty"), "got: {s}");
2982 });
2983 h.shutdown(&runtime);
2984 }
2985
2986 #[test]
2989 fn remember_batch_rejects_per_item_empty_content() {
2990 let runtime = rt();
2991 let h = Harness::new(&runtime);
2992 runtime.block_on(async {
2993 let items = json!([
2994 { "content": "ok-1" },
2995 { "content": " " },
2996 { "content": "ok-3" },
2997 ]);
2998 let err = h
2999 .server
3000 .dispatch_tool(
3001 "memory_remember_batch",
3002 json!({ "items": items }),
3003 None,
3004 )
3005 .await
3006 .unwrap_err();
3007 let s = format!("{err:?}");
3008 assert!(s.contains("items[1]"), "must mention items[1]: {s}");
3009 assert!(s.contains("must not be empty"), "got: {s}");
3010 });
3011 h.shutdown(&runtime);
3012 }
3013
3014 #[test]
3017 fn remember_batch_rejects_per_item_salience_out_of_range() {
3018 let runtime = rt();
3019 let h = Harness::new(&runtime);
3020 runtime.block_on(async {
3021 let items = json!([
3022 { "content": "ok-1", "salience": 0.5 },
3023 { "content": "out-of-range", "salience": -0.1 },
3024 ]);
3025 let err = h
3026 .server
3027 .dispatch_tool(
3028 "memory_remember_batch",
3029 json!({ "items": items }),
3030 None,
3031 )
3032 .await
3033 .unwrap_err();
3034 let s = format!("{err:?}");
3035 assert!(s.contains("items[1]"), "must mention items[1]: {s}");
3036 assert!(s.contains("salience must be"), "got: {s}");
3037 });
3038 h.shutdown(&runtime);
3039 }
3040
3041 #[test]
3044 fn remember_batch_over_cap_returns_invalid_params() {
3045 let runtime = rt();
3046 let h = Harness::new(&runtime);
3047 runtime.block_on(async {
3048 let items: Vec<serde_json::Value> =
3049 (0..(solo_storage::MAX_REMEMBER_BATCH_SIZE + 1))
3050 .map(|i| json!({ "content": format!("over-{i}") }))
3051 .collect();
3052 let err = h
3053 .server
3054 .dispatch_tool(
3055 "memory_remember_batch",
3056 json!({ "items": items }),
3057 None,
3058 )
3059 .await
3060 .unwrap_err();
3061 let s = format!("{err:?}");
3062 assert!(
3063 s.contains("MAX_REMEMBER_BATCH_SIZE"),
3064 "must mention the cap: {s}"
3065 );
3066 });
3067 h.shutdown(&runtime);
3068 }
3069
3070 use crate::mcp_progress::{ProgressReporter, ProgressToken};
3082 use crate::mcp_session::SessionState;
3083 use std::sync::Arc as StdArc2;
3084
3085 fn fresh_progress_session() -> StdArc2<SessionState> {
3086 StdArc2::new(SessionState::new(solo_core::TenantId::default_tenant(), None))
3087 }
3088
3089 fn drain_progress_events(
3090 rx: &mut tokio::sync::broadcast::Receiver<crate::mcp_session::McpStreamEvent>,
3091 ) -> Vec<crate::mcp_session::McpStreamEvent> {
3092 let mut out = Vec::new();
3093 while let Ok(ev) = rx.try_recv() {
3094 out.push(ev);
3095 }
3096 out
3097 }
3098
3099 #[test]
3109 fn search_docs_emits_progress_only_when_top_k_above_100() {
3110 let runtime = rt();
3111 let h = Harness::new(&runtime);
3112 runtime.block_on(async {
3113 let session = fresh_progress_session();
3114 let mut rx = session.subscribe_events();
3115 let reporter = ProgressReporter::new(session.clone(), ProgressToken(json!(42)));
3116 let _r = h
3117 .server
3118 .dispatch_tool(
3119 "memory_search_docs",
3120 json!({ "query": "anything", "limit": 150 }),
3121 Some(reporter),
3122 )
3123 .await
3124 .expect("search succeeds");
3125 let events = drain_progress_events(&mut rx);
3126 assert_eq!(
3127 events.len(),
3128 3,
3129 "expected 3 search progress events at top_k=150, got {}",
3130 events.len()
3131 );
3132 for (i, ev) in events.iter().enumerate() {
3135 let params = &ev.data["params"];
3136 assert_eq!(params["progressToken"], json!(42));
3137 assert_eq!(params["total"], json!(3));
3138 assert_eq!(params["progress"], json!((i + 1) as u64));
3139 }
3140 });
3141 h.shutdown(&runtime);
3142 }
3143
3144 #[test]
3148 fn search_docs_emits_no_progress_when_top_k_below_threshold() {
3149 let runtime = rt();
3150 let h = Harness::new(&runtime);
3151 runtime.block_on(async {
3152 let session = fresh_progress_session();
3153 let mut rx = session.subscribe_events();
3154 let reporter = ProgressReporter::new(session.clone(), ProgressToken(json!("t")));
3155 let _r = h
3156 .server
3157 .dispatch_tool(
3158 "memory_search_docs",
3159 json!({ "query": "anything", "limit": 50 }),
3160 Some(reporter),
3161 )
3162 .await
3163 .expect("search succeeds");
3164 let events = drain_progress_events(&mut rx);
3165 assert!(
3166 events.is_empty(),
3167 "expected no progress events at top_k=50, got {events:?}"
3168 );
3169 });
3170 h.shutdown(&runtime);
3171 }
3172
3173 #[test]
3178 fn remember_batch_emits_progress_only_when_size_above_50() {
3179 let runtime = rt();
3180 let h = Harness::new(&runtime);
3181 runtime.block_on(async {
3182 let session = fresh_progress_session();
3183 let mut rx = session.subscribe_events();
3184 let reporter = ProgressReporter::new(session.clone(), ProgressToken(json!("batch")));
3185 let items: Vec<serde_json::Value> = (0..51)
3186 .map(|i| json!({ "content": format!("item-{i}") }))
3187 .collect();
3188 let _r = h
3189 .server
3190 .dispatch_tool(
3191 "memory_remember_batch",
3192 json!({ "items": items }),
3193 Some(reporter),
3194 )
3195 .await
3196 .expect("batch succeeds");
3197 let events = drain_progress_events(&mut rx);
3198 assert_eq!(
3199 events.len(),
3200 4,
3201 "expected 4 batch progress events for 51 items, got {}: {events:?}",
3202 events.len()
3203 );
3204 let progresses: Vec<u64> = events
3207 .iter()
3208 .map(|e| e.data["params"]["progress"].as_u64().unwrap_or(0))
3209 .collect();
3210 assert_eq!(progresses, vec![25, 50, 51, 51]);
3211 assert_eq!(
3212 events.last().unwrap().data["params"]["message"],
3213 json!("inserted")
3214 );
3215 for ev in &events {
3216 assert_eq!(ev.data["params"]["progressToken"], json!("batch"));
3217 assert_eq!(ev.data["params"]["total"], json!(51));
3218 }
3219 });
3220 h.shutdown(&runtime);
3221 }
3222
3223 #[test]
3226 fn remember_batch_emits_no_progress_when_size_below_threshold() {
3227 let runtime = rt();
3228 let h = Harness::new(&runtime);
3229 runtime.block_on(async {
3230 let session = fresh_progress_session();
3231 let mut rx = session.subscribe_events();
3232 let reporter = ProgressReporter::new(session.clone(), ProgressToken(json!("t")));
3233 let items: Vec<serde_json::Value> = (0..5)
3235 .map(|i| json!({ "content": format!("small-{i}") }))
3236 .collect();
3237 let _r = h
3238 .server
3239 .dispatch_tool(
3240 "memory_remember_batch",
3241 json!({ "items": items }),
3242 Some(reporter),
3243 )
3244 .await
3245 .expect("batch succeeds");
3246 let events = drain_progress_events(&mut rx);
3247 assert!(
3248 events.is_empty(),
3249 "expected no progress events for 5-item batch, got {events:?}"
3250 );
3251 });
3252 h.shutdown(&runtime);
3253 }
3254
3255 #[test]
3263 fn stdio_transport_does_not_emit_progress_events() {
3264 let runtime = rt();
3265 let h = Harness::new(&runtime);
3266 runtime.block_on(async {
3267 let session = fresh_progress_session();
3270 let mut rx = session.subscribe_events();
3271 let _r = h
3272 .server
3273 .dispatch_tool(
3274 "memory_search_docs",
3275 json!({ "query": "anything", "limit": 200 }),
3278 None, )
3280 .await
3281 .expect("search succeeds without reporter");
3282 let events = drain_progress_events(&mut rx);
3283 assert!(
3284 events.is_empty(),
3285 "stdio path (no reporter) must not publish to ANY session: {events:?}"
3286 );
3287 });
3288 h.shutdown(&runtime);
3289 }
3290
3291 #[test]
3295 fn progress_event_id_monotonic_per_session() {
3296 let runtime = rt();
3297 let h = Harness::new(&runtime);
3298 runtime.block_on(async {
3299 let session = fresh_progress_session();
3300 let mut rx = session.subscribe_events();
3301 let r1 = ProgressReporter::new(session.clone(), ProgressToken(json!("a")));
3304 let r2 = ProgressReporter::new(session.clone(), ProgressToken(json!("b")));
3305 let _ = h
3306 .server
3307 .dispatch_tool(
3308 "memory_search_docs",
3309 json!({ "query": "q1", "limit": 150 }),
3310 Some(r1),
3311 )
3312 .await;
3313 let _ = h
3314 .server
3315 .dispatch_tool(
3316 "memory_search_docs",
3317 json!({ "query": "q2", "limit": 150 }),
3318 Some(r2),
3319 )
3320 .await;
3321 let events = drain_progress_events(&mut rx);
3322 assert!(events.len() >= 6, "expected at least 6 events: {events:?}");
3323 let ids: Vec<u64> = events.iter().map(|e| e.id).collect();
3324 for w in ids.windows(2) {
3325 assert!(
3326 w[0] < w[1],
3327 "event ids must be strictly monotonic: {ids:?}"
3328 );
3329 }
3330 });
3331 h.shutdown(&runtime);
3332 }
3333}
3334
3335#[cfg(test)]
3346mod principal_extraction_tests {
3347 use super::*;
3348 use std::sync::Mutex;
3349
3350 static ENV_LOCK: Mutex<()> = Mutex::new(());
3354
3355 struct EnvGuard;
3358 impl Drop for EnvGuard {
3359 fn drop(&mut self) {
3360 unsafe { std::env::remove_var(ENV_MCP_PRINCIPAL_TOKEN) };
3362 }
3363 }
3364
3365 fn set_principal_env(val: &str) -> EnvGuard {
3366 unsafe { std::env::set_var(ENV_MCP_PRINCIPAL_TOKEN, val) };
3368 EnvGuard
3369 }
3370
3371 fn clear_principal_env() -> EnvGuard {
3372 unsafe { std::env::remove_var(ENV_MCP_PRINCIPAL_TOKEN) };
3374 EnvGuard
3375 }
3376
3377 #[test]
3380 fn stdio_env_var_resolves_to_principal() {
3381 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
3382 let _g = set_principal_env("alice-token");
3383 let resolved = resolve_mcp_principal(None);
3384 assert_eq!(resolved.as_deref(), Some("alice-token"));
3385 }
3386
3387 #[test]
3390 fn stdio_no_env_var_resolves_to_none() {
3391 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
3392 let _g = clear_principal_env();
3393 assert_eq!(resolve_mcp_principal(None), None);
3394 }
3395
3396 #[test]
3400 fn stdio_whitespace_env_var_resolves_to_none() {
3401 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
3402 let _g = set_principal_env(" \t ");
3403 assert_eq!(resolve_mcp_principal(None), None);
3404 }
3405
3406 #[test]
3409 fn http_header_resolves_to_bearer_token_principal() {
3410 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
3411 let _g = clear_principal_env();
3412 let resolved = resolve_mcp_principal(Some("Bearer api-token-xyz"));
3413 assert_eq!(resolved.as_deref(), Some("api-token-xyz"));
3414 }
3415
3416 #[test]
3420 fn http_header_beats_env_var() {
3421 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
3422 let _g = set_principal_env("env-token");
3423 let resolved = resolve_mcp_principal(Some("Bearer header-token"));
3424 assert_eq!(
3425 resolved.as_deref(),
3426 Some("header-token"),
3427 "header MUST win over env var per documented precedence"
3428 );
3429 }
3430
3431 #[test]
3434 fn http_malformed_header_falls_through_to_env() {
3435 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
3436 let _g = set_principal_env("env-fallback");
3437 let resolved = resolve_mcp_principal(Some("Basic dXNlcjpwYXNz"));
3438 assert_eq!(resolved.as_deref(), Some("env-fallback"));
3439 }
3440
3441 #[test]
3446 fn http_empty_bearer_header_falls_through_to_env() {
3447 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
3448 let _g = set_principal_env("env-fallback");
3449 let resolved = resolve_mcp_principal(Some("Bearer "));
3450 assert_eq!(resolved.as_deref(), Some("env-fallback"));
3451 }
3452
3453 #[test]
3459 fn stable_across_multiple_resolutions() {
3460 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
3461 let _g = set_principal_env("stable-token");
3462 for _ in 0..5 {
3463 assert_eq!(
3464 resolve_mcp_principal(None).as_deref(),
3465 Some("stable-token")
3466 );
3467 }
3468 }
3469}
3470
3471#[cfg(test)]
3482mod initialize_decision_tests {
3483 use super::*;
3484 use solo_storage::LlmSettings;
3485
3486 #[test]
3488 fn no_llm_block_allows_initialize_regardless_of_sampling_capability() {
3489 assert_eq!(initialize_decision(&None, false), InitializeDecision::Allow);
3490 assert_eq!(initialize_decision(&None, true), InitializeDecision::Allow);
3491 }
3492
3493 #[test]
3495 fn llm_none_allows_initialize_regardless_of_sampling_capability() {
3496 let s = Some(LlmSettings::None);
3497 assert_eq!(initialize_decision(&s, false), InitializeDecision::Allow);
3498 assert_eq!(initialize_decision(&s, true), InitializeDecision::Allow);
3499 }
3500
3501 #[test]
3503 fn llm_anthropic_allows_initialize_regardless_of_sampling_capability() {
3504 let s = Some(LlmSettings::Anthropic {
3505 api_key_env: "ANTHROPIC_API_KEY".into(),
3506 model: "claude-sonnet-4-6".into(),
3507 });
3508 assert_eq!(initialize_decision(&s, false), InitializeDecision::Allow);
3509 assert_eq!(initialize_decision(&s, true), InitializeDecision::Allow);
3510 }
3511
3512 #[test]
3514 fn llm_ollama_allows_initialize_regardless_of_sampling_capability() {
3515 let s = Some(LlmSettings::Ollama {
3516 base_url: "http://localhost:11434".into(),
3517 model: "qwen3-coder:30b".into(),
3518 });
3519 assert_eq!(initialize_decision(&s, false), InitializeDecision::Allow);
3520 assert_eq!(initialize_decision(&s, true), InitializeDecision::Allow);
3521 }
3522
3523 #[test]
3526 fn llm_mcp_sampling_with_sampling_capability_populates_slot() {
3527 let s = Some(LlmSettings::McpSampling);
3528 assert_eq!(
3529 initialize_decision(&s, true),
3530 InitializeDecision::PopulateSamplingSteward
3531 );
3532 }
3533
3534 #[test]
3537 fn llm_mcp_sampling_without_sampling_capability_rejects() {
3538 let s = Some(LlmSettings::McpSampling);
3539 assert_eq!(
3540 initialize_decision(&s, false),
3541 InitializeDecision::RejectMissingSamplingCapability
3542 );
3543 }
3544
3545 #[test]
3549 fn sampling_capability_missing_error_message_contains_all_alternatives() {
3550 let msg = sampling_capability_missing_error_message();
3551 assert!(msg.contains("LLM backend `mcp_sampling`"));
3553 assert!(msg.contains("mode = \"anthropic\""));
3554 assert!(msg.contains("api_key_env = \"ANTHROPIC_API_KEY\""));
3555 assert!(msg.contains("mode = \"openai\""));
3556 assert!(msg.contains("api_key_env = \"OPENAI_API_KEY\""));
3557 assert!(msg.contains("mode = \"ollama\""));
3558 assert!(msg.contains("base_url = \"http://localhost:11434\""));
3559 assert!(msg.contains("mode = \"none\""));
3560 assert!(msg.contains("docs/releases/v0.9.0.md"));
3562 }
3563}
3564
3565