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).await
665 }
666}
667
668impl SoloMcpServer {
669 async fn populate_sampling_steward(
694 &self,
695 context: &RequestContext<RoleServer>,
696 ) {
697 let steward_config = solo_steward::StewardConfig::from_env()
698 .unwrap_or_else(|e| {
699 tracing::warn!(
700 error = %e,
701 "v0.9.0 P2: StewardConfig::from_env failed at MCP \
702 initialize; falling back to defaults"
703 );
704 solo_steward::StewardConfig::default()
705 });
706 let sampling_config = self.inner.tenant.config().sampling.clone();
712 let peer = context.peer.clone();
713 let write_handle = self.inner.tenant.write().clone();
714 let steward = crate::llm::build_sampling_steward(
715 peer,
716 write_handle,
717 self.inner.audit_principal.clone(),
718 steward_config,
719 sampling_config.clone(),
720 );
721 let slot = self.inner.tenant.steward_slot();
722 let mut guard = slot.write().await;
723 *guard = Some(steward);
724 tracing::info!(
725 tenant = %self.inner.tenant.tenant_id(),
726 coalesce_window_ms = sampling_config.coalesce_window_ms,
727 coalesce_max_requests = sampling_config.coalesce_max_requests,
728 "v0.9.0 P5: MCP-sampling Steward attached to tenant.steward_slot \
729 (PeerSamplingClient → SamplingCoordinator → SamplingLlmClient)"
730 );
731 }
732
733 pub async fn dispatch_tool(
739 &self,
740 name: &str,
741 args_value: serde_json::Value,
742 ) -> std::result::Result<CallToolResult, McpError> {
743 match name {
744 "memory_remember" => {
745 let args: RememberArgs = parse_args(&args_value)?;
746 self.handle_remember(args).await
747 }
748 "memory_remember_batch" => {
749 let args: RememberBatchArgs = parse_args(&args_value)?;
750 self.handle_remember_batch(args).await
751 }
752 "memory_recall" => {
753 let args: RecallArgs = parse_args(&args_value)?;
754 self.handle_recall(args).await
755 }
756 "memory_forget" => {
757 let args: ForgetArgs = parse_args(&args_value)?;
758 self.handle_forget(args).await
759 }
760 "memory_inspect" => {
761 let args: InspectArgs = parse_args(&args_value)?;
762 self.handle_inspect(args).await
763 }
764 "memory_themes" => {
765 let args: ThemesArgs = parse_args(&args_value)?;
766 self.handle_themes(args).await
767 }
768 "memory_facts_about" => {
769 let args: FactsAboutArgs = parse_args(&args_value)?;
770 self.handle_facts_about(args).await
771 }
772 "memory_contradictions" => {
773 let args: ContradictionsArgs = parse_args(&args_value)?;
774 self.handle_contradictions(args).await
775 }
776 "memory_inspect_cluster" => {
777 let args: InspectClusterArgs = parse_args(&args_value)?;
778 self.handle_inspect_cluster(args).await
779 }
780 "memory_ingest_document" => {
781 let args: IngestDocumentArgs = parse_args(&args_value)?;
782 self.handle_ingest_document(args).await
783 }
784 "memory_search_docs" => {
785 let args: SearchDocsArgs = parse_args(&args_value)?;
786 self.handle_search_docs(args).await
787 }
788 "memory_inspect_document" => {
789 let args: InspectDocumentArgs = parse_args(&args_value)?;
790 self.handle_inspect_document(args).await
791 }
792 "memory_list_documents" => {
793 let args: ListDocumentsArgs = parse_args(&args_value)?;
794 self.handle_list_documents(args).await
795 }
796 "memory_forget_document" => {
797 let args: ForgetDocumentArgs = parse_args(&args_value)?;
798 self.handle_forget_document(args).await
799 }
800 other => Err(McpError::invalid_params(
801 format!("unknown tool `{other}`"),
802 None,
803 )),
804 }
805 }
806
807 pub fn dispatch_list_tools(&self) -> Vec<Tool> {
810 build_tools()
811 }
812}
813
814fn parse_args<T: serde::de::DeserializeOwned>(
815 v: &serde_json::Value,
816) -> std::result::Result<T, McpError> {
817 serde_json::from_value(v.clone()).map_err(|e| {
818 McpError::invalid_params(format!("invalid tool arguments: {e}"), None)
819 })
820}
821
822fn solo_to_mcp(e: solo_core::Error) -> McpError {
823 use solo_core::Error;
824 match e {
825 Error::NotFound(msg) => McpError::invalid_params(msg, None),
826 Error::InvalidInput(msg) => McpError::invalid_params(msg, None),
827 Error::Conflict(msg) => McpError::invalid_params(msg, None),
828 other => McpError::internal_error(other.to_string(), None),
829 }
830}
831
832fn build_tools() -> Vec<Tool> {
837 vec![
838 Tool::new(
839 "memory_remember",
840 "Save something the user has told you — a fact, a \
841 preference, a name, a date, a context — so you can pick \
842 it up next conversation. Use whenever the user mentions \
843 something they'd reasonably expect you to recall later \
844 (\"I just started at Quotient\", \"my partner is Maya\"). \
845 Returns the saved item's id.",
846 json_schema_object(serde_json::json!({
847 "type": "object",
848 "properties": {
849 "content": {
850 "type": "string",
851 "description": "The text to remember.",
852 },
853 "source_type": {
854 "type": "string",
855 "description": "Optional source-type tag (default: \"user_message\"). See docs/mcp/source-types.md for convention values.",
856 },
857 "source_id": {
858 "type": "string",
859 "description": "Optional upstream id for traceability.",
860 },
861 "salience": {
862 "type": "number",
863 "description": "Optional salience in [0.0, 1.0]; defaults to 0.5. Higher values bias toward recall ranking + retention. v0.9.2+.",
864 "minimum": 0.0,
865 "maximum": 1.0,
866 },
867 },
868 "required": ["content"],
869 })),
870 ),
871 Tool::new(
877 "memory_remember_batch",
878 "Save several items atomically in one transaction — either \
879 every item lands or none does. Use this when you have a \
880 collection of related episodes from one logical step (a \
881 conversation turn, a tool-output bundle, an ingest batch) \
882 and partial success would leave the user's memory in a \
883 confusing half-state. Each item carries the same fields as \
884 memory_remember (content + optional source_type, source_id, \
885 salience). Returns an ordered array of memory_ids matching \
886 the input items. v0.9.2+.",
887 json_schema_object(serde_json::json!({
888 "type": "object",
889 "properties": {
890 "items": {
891 "type": "array",
892 "description": "Items to remember atomically. Max 200 per call.",
893 "minItems": 1,
894 "maxItems": 200,
895 "items": {
896 "type": "object",
897 "properties": {
898 "content": {
899 "type": "string",
900 "description": "The text to remember.",
901 },
902 "source_type": {
903 "type": "string",
904 "description": "Optional source-type tag (default: \"user_message\"). See docs/mcp/source-types.md.",
905 },
906 "source_id": {
907 "type": "string",
908 "description": "Optional upstream id for traceability.",
909 },
910 "salience": {
911 "type": "number",
912 "description": "Optional salience in [0.0, 1.0]; defaults to 0.5.",
913 "minimum": 0.0,
914 "maximum": 1.0,
915 },
916 },
917 "required": ["content"],
918 },
919 },
920 },
921 "required": ["items"],
922 })),
923 ),
924 Tool::new(
925 "memory_recall",
926 "Search past conversations with this user by topic or \
927 phrase. Returns up to `limit` of the closest matches, \
928 best match first. Use when the user references \
929 something they said before (\"that book I told you \
930 about\", \"the bug we were debugging last week\"). \
931 Skips items the user has deleted.",
932 json_schema_object(serde_json::json!({
933 "type": "object",
934 "properties": {
935 "query": {
936 "type": "string",
937 "description": "The query text.",
938 },
939 "limit": {
940 "type": "integer",
941 "description": "Maximum results (default 5).",
942 "minimum": 1,
943 "maximum": 100,
944 },
945 },
946 "required": ["query"],
947 })),
948 ),
949 Tool::new(
950 "memory_forget",
951 "Delete one saved item by id. Use when the user asks you \
952 to forget something specific (\"forget that I said \
953 X\"). The item stops appearing in future recalls. \
954 Reversible only via backups.",
955 json_schema_object(serde_json::json!({
956 "type": "object",
957 "properties": {
958 "memory_id": {
959 "type": "string",
960 "description": "MemoryId to forget (UUID v7).",
961 },
962 "reason": {
963 "type": "string",
964 "description": "Optional free-form reason (logged, not yet persisted).",
965 },
966 },
967 "required": ["memory_id"],
968 })),
969 ),
970 Tool::new(
971 "memory_inspect",
972 "Show the full record for one saved item — when it was \
973 saved, where it came from, and the full text. Use after \
974 memory_recall when you want the complete content of a \
975 specific hit (recall results may be truncated).",
976 json_schema_object(serde_json::json!({
977 "type": "object",
978 "properties": {
979 "memory_id": {
980 "type": "string",
981 "description": "MemoryId to inspect (UUID v7).",
982 },
983 },
984 "required": ["memory_id"],
985 })),
986 ),
987 Tool::new(
991 "memory_themes",
992 "Recent topics the user has been thinking about. Use to \
993 orient yourself at the start of a conversation, or when \
994 the user asks \"what have I been up to\" / \"what was I \
995 working on last week\". Pass `window_days` to scope \
996 (e.g. 7 for last week); omit for all-time.",
997 json_schema_object(serde_json::json!({
998 "type": "object",
999 "properties": {
1000 "window_days": {
1001 "type": "integer",
1002 "description": "Optional time window in days. Omit for unfiltered.",
1003 "minimum": 1,
1004 },
1005 "limit": {
1006 "type": "integer",
1007 "description": "Maximum results (default 5).",
1008 "minimum": 1,
1009 "maximum": 100,
1010 },
1011 },
1012 })),
1013 ),
1014 Tool::new(
1015 "memory_facts_about",
1016 "Look up what you remember about a person, project, or \
1017 topic — names, dates, preferences, relationships. Use \
1018 when the user asks \"what do you know about Alex?\", \
1019 \"when did I start at Quotient?\", \"who is Maya?\", or \
1020 whenever you need grounded facts about someone or \
1021 something before answering. Subject is required (the \
1022 person/place/thing you're asking about); narrow further \
1023 with `predicate` (\"works_at\", \"lives_in\") or a date \
1024 range. Set `include_as_object=true` to also surface \
1025 facts where the subject appears on the receiving side of \
1026 a relationship (e.g. \"Sam pushes back on PRs about \
1027 Maya\" surfaces under facts_about(subject=\"Maya\", \
1028 include_as_object=true)). (Backed by \
1029 subject-predicate-object triples distilled from past \
1030 conversations.) Clients should set a 30s timeout on this \
1031 call; if exceeded, retry once or fall back to \
1032 `memory_recall`.",
1033 json_schema_object(serde_json::json!({
1034 "type": "object",
1035 "properties": {
1036 "subject": {
1037 "type": "string",
1038 "description": "Subject id to query (e.g. 'Sam').",
1039 },
1040 "predicate": {
1041 "type": "string",
1042 "description": "Optional predicate filter (e.g. 'works_at').",
1043 },
1044 "since_ms": {
1045 "type": "integer",
1046 "description": "Optional valid_from_ms lower bound (epoch ms).",
1047 },
1048 "until_ms": {
1049 "type": "integer",
1050 "description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through.",
1051 },
1052 "include_as_object": {
1053 "type": "boolean",
1054 "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.",
1055 "default": false,
1056 },
1057 "limit": {
1058 "type": "integer",
1059 "description": "Maximum results (default 5).",
1060 "minimum": 1,
1061 "maximum": 100,
1062 },
1063 },
1064 "required": ["subject"],
1065 })),
1066 ),
1067 Tool::new(
1068 "memory_contradictions",
1069 "Find places where the user's stated beliefs or facts \
1070 disagree across conversations — flag disagreements \
1071 before answering. Use whenever you're about to rely on \
1072 a remembered fact that could have changed (jobs, \
1073 relationships, preferences, opinions); a disagreement \
1074 here means the user has told you both X and not-X over \
1075 time and you should ask which is current instead of \
1076 guessing. Each result shows both conflicting statements \
1077 with the topic.",
1078 json_schema_object(serde_json::json!({
1079 "type": "object",
1080 "properties": {
1081 "limit": {
1082 "type": "integer",
1083 "description": "Maximum results (default 5).",
1084 "minimum": 1,
1085 "maximum": 100,
1086 },
1087 },
1088 })),
1089 ),
1090 Tool::new(
1091 "memory_inspect_cluster",
1092 "Show the raw conversations behind one summary. Returns \
1093 the one-line topic (the LLM-generated summary) and the \
1094 source conversations the topic was built from. Use \
1095 after memory_themes when the user asks \"show me the \
1096 raw context behind this\" or \"why does Solo think \
1097 that about cluster Y\". Source items are truncated to \
1098 200 chars unless `full_content` is set.",
1099 json_schema_object(serde_json::json!({
1100 "type": "object",
1101 "properties": {
1102 "cluster_id": {
1103 "type": "string",
1104 "description": "Cluster id to inspect (from memory_themes hits).",
1105 },
1106 "full_content": {
1107 "type": "boolean",
1108 "description": "If true, episode content is returned verbatim. Default false (truncate to 200 chars + ellipsis).",
1109 },
1110 },
1111 "required": ["cluster_id"],
1112 })),
1113 ),
1114 Tool::new(
1118 "memory_ingest_document",
1119 "Read a file from disk and add it to the user's document \
1120 library so it becomes searchable alongside past \
1121 conversations. Use when the user asks you to remember a \
1122 whole file (\"add my notes/runbook.md\", \"ingest this \
1123 PDF\"). The file is split into ~500-token chunks and \
1124 each chunk is embedded; chunks then surface through \
1125 memory_search_docs. Returns the new document id, chunk \
1126 count, and a `deduped` flag (true if the same content \
1127 was already ingested under another id).",
1128 json_schema_object(serde_json::json!({
1129 "type": "object",
1130 "properties": {
1131 "path": {
1132 "type": "string",
1133 "description": "Server-side absolute path to the file to ingest. The file must be readable by the Solo process.",
1134 },
1135 },
1136 "required": ["path"],
1137 })),
1138 ),
1139 Tool::new(
1140 "memory_search_docs",
1141 "Search across the user's ingested documents by topic or \
1142 phrase. Returns up to `limit` matching chunks, best \
1143 match first, each with the parent document's title + \
1144 source path so you can cite where the answer came from. \
1145 Use when the user asks a question that hinges on \
1146 material they've added as a file (\"what does my \
1147 runbook say about backups?\", \"find the section in the \
1148 notes about the new policy\"). Forgotten documents are \
1149 skipped.",
1150 json_schema_object(serde_json::json!({
1151 "type": "object",
1152 "properties": {
1153 "query": {
1154 "type": "string",
1155 "description": "The query text.",
1156 },
1157 "limit": {
1158 "type": "integer",
1159 "description": "Maximum results (default 5).",
1160 "minimum": 1,
1161 "maximum": 100,
1162 },
1163 },
1164 "required": ["query"],
1165 })),
1166 ),
1167 Tool::new(
1168 "memory_inspect_document",
1169 "Show one document's metadata plus a preview of every \
1170 chunk it was split into. Use after memory_search_docs \
1171 when the user wants the bigger picture for one hit \
1172 (\"show me the whole document this came from\"), or \
1173 after memory_list_documents to drill into one entry. \
1174 Each chunk preview is truncated to 200 chars.",
1175 json_schema_object(serde_json::json!({
1176 "type": "object",
1177 "properties": {
1178 "doc_id": {
1179 "type": "string",
1180 "description": "Document id to inspect (UUID v7).",
1181 },
1182 },
1183 "required": ["doc_id"],
1184 })),
1185 ),
1186 Tool::new(
1187 "memory_list_documents",
1188 "List the user's ingested documents, newest first. Use \
1189 when the user asks \"what documents have I added?\" or \
1190 \"show me my files\". Returns a paginated index — pass \
1191 `offset` to page further back. Forgotten documents are \
1192 hidden by default; set `include_forgotten=true` to see \
1193 them too.",
1194 json_schema_object(serde_json::json!({
1195 "type": "object",
1196 "properties": {
1197 "limit": {
1198 "type": "integer",
1199 "description": "Maximum results per page (default 20).",
1200 "minimum": 1,
1201 "maximum": 100,
1202 },
1203 "offset": {
1204 "type": "integer",
1205 "description": "Number of rows to skip (for paging). Default 0.",
1206 "minimum": 0,
1207 },
1208 "include_forgotten": {
1209 "type": "boolean",
1210 "description": "If true, also include documents the user has forgotten. Default false.",
1211 },
1212 },
1213 })),
1214 ),
1215 Tool::new(
1216 "memory_forget_document",
1217 "Drop one document from the user's library by id. Use \
1218 when the user asks you to forget a specific file \
1219 (\"forget my old runbook\"). The document's chunks stop \
1220 appearing in memory_search_docs and the vectors are \
1221 tombstoned in the index. The chunk rows themselves are \
1222 kept for forensic value (a future restore command can \
1223 undo this).",
1224 json_schema_object(serde_json::json!({
1225 "type": "object",
1226 "properties": {
1227 "doc_id": {
1228 "type": "string",
1229 "description": "Document id to forget (UUID v7).",
1230 },
1231 },
1232 "required": ["doc_id"],
1233 })),
1234 ),
1235 ]
1236}
1237
1238fn json_schema_object(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
1239 match value {
1240 serde_json::Value::Object(map) => map,
1241 _ => panic!("json_schema_object: input must be an object"),
1242 }
1243}
1244
1245pub fn tool_names() -> Vec<&'static str> {
1254 vec![
1255 "memory_remember",
1256 "memory_remember_batch",
1258 "memory_recall",
1259 "memory_forget",
1260 "memory_inspect",
1261 "memory_themes",
1262 "memory_facts_about",
1263 "memory_contradictions",
1264 "memory_inspect_cluster",
1265 "memory_ingest_document",
1267 "memory_search_docs",
1268 "memory_inspect_document",
1269 "memory_list_documents",
1270 "memory_forget_document",
1271 ]
1272}
1273
1274impl SoloMcpServer {
1279 async fn handle_remember(
1280 &self,
1281 args: RememberArgs,
1282 ) -> std::result::Result<CallToolResult, McpError> {
1283 let content = args.content.trim_end().to_string();
1284 if content.is_empty() {
1285 return Err(McpError::invalid_params(
1286 "memory_remember: content must not be empty".to_string(),
1287 None,
1288 ));
1289 }
1290 validate_salience(args.salience)?;
1291 let embedding: solo_core::Embedding = self
1292 .inner
1293 .tenant
1294 .embedder()
1295 .embed(&content)
1296 .await
1297 .map_err(solo_to_mcp)?;
1298 let episode = Episode {
1299 memory_id: MemoryId::new(),
1300 ts_ms: chrono::Utc::now().timestamp_millis(),
1301 source_type: args.source_type.unwrap_or_else(|| "user_message".into()),
1302 source_id: args.source_id,
1303 content,
1304 encoding_context: EncodingContext::default(),
1305 provenance: None,
1306 confidence: Confidence::new(0.9).unwrap(),
1307 strength: 0.5,
1308 salience: args.salience.unwrap_or(0.5),
1312 tier: Tier::Hot,
1313 };
1314 let mid = self
1315 .inner
1316 .tenant
1317 .write()
1318 .remember_as(self.inner.audit_principal.clone(), episode, embedding)
1319 .await
1320 .map_err(solo_to_mcp)?;
1321 Ok(CallToolResult::success(vec![Content::text(format!(
1322 "remembered {mid}"
1323 ))]))
1324 }
1325
1326 async fn handle_remember_batch(
1346 &self,
1347 args: RememberBatchArgs,
1348 ) -> std::result::Result<CallToolResult, McpError> {
1349 if args.items.is_empty() {
1355 return Err(McpError::invalid_params(
1356 "memory_remember_batch: items must not be empty".to_string(),
1357 None,
1358 ));
1359 }
1360 if args.items.len() > solo_storage::MAX_REMEMBER_BATCH_SIZE {
1361 return Err(McpError::invalid_params(
1362 format!(
1363 "memory_remember_batch: {} items exceeds MAX_REMEMBER_BATCH_SIZE = {}",
1364 args.items.len(),
1365 solo_storage::MAX_REMEMBER_BATCH_SIZE,
1366 ),
1367 None,
1368 ));
1369 }
1370 for (i, item) in args.items.iter().enumerate() {
1371 if item.content.trim_end().is_empty() {
1372 return Err(McpError::invalid_params(
1373 format!("memory_remember_batch: items[{i}].content must not be empty"),
1374 None,
1375 ));
1376 }
1377 validate_salience(item.salience).map_err(|e| {
1378 McpError::invalid_params(
1381 format!("memory_remember_batch: items[{i}].{}", e.message),
1382 None,
1383 )
1384 })?;
1385 }
1386
1387 let embedder = self.inner.tenant.embedder();
1389 let now_ms = chrono::Utc::now().timestamp_millis();
1390 let mut pairs: Vec<(Episode, solo_core::Embedding)> = Vec::with_capacity(args.items.len());
1391 for item in args.items.into_iter() {
1392 let content = item.content.trim_end().to_string();
1393 let embedding = embedder.embed(&content).await.map_err(solo_to_mcp)?;
1394 let episode = Episode {
1395 memory_id: MemoryId::new(),
1396 ts_ms: now_ms,
1397 source_type: item.source_type.unwrap_or_else(|| "user_message".into()),
1398 source_id: item.source_id,
1399 content,
1400 encoding_context: EncodingContext::default(),
1401 provenance: None,
1402 confidence: Confidence::new(0.9).unwrap(),
1403 strength: 0.5,
1404 salience: item.salience.unwrap_or(0.5),
1405 tier: Tier::Hot,
1406 };
1407 pairs.push((episode, embedding));
1408 }
1409
1410 let memory_ids = self
1412 .inner
1413 .tenant
1414 .write()
1415 .remember_batch_as(self.inner.audit_principal.clone(), pairs)
1416 .await
1417 .map_err(solo_to_mcp)?;
1418
1419 let ids_as_strings: Vec<String> =
1424 memory_ids.iter().map(|m| m.to_string()).collect();
1425 let body = serde_json::to_string(&ids_as_strings).map_err(|e| {
1426 McpError::internal_error(format!("serialize batch reply: {e}"), None)
1427 })?;
1428 Ok(CallToolResult::success(vec![Content::text(body)]))
1429 }
1430
1431 async fn handle_recall(
1432 &self,
1433 args: RecallArgs,
1434 ) -> std::result::Result<CallToolResult, McpError> {
1435 let result = solo_query::run_recall(
1439 self.inner.tenant.as_ref(),
1440 self.inner.audit_principal.clone(),
1441 &args.query,
1442 args.limit,
1443 )
1444 .await
1445 .map_err(solo_to_mcp)?;
1446
1447 if result.hits.is_empty() {
1448 return Ok(CallToolResult::success(vec![Content::text(format!(
1449 "no matches (index has {} vectors)",
1450 result.index_len
1451 ))]));
1452 }
1453 let body = serde_json::to_string_pretty(&result.hits).unwrap_or_else(|_| String::new());
1454 Ok(CallToolResult::success(vec![Content::text(body)]))
1455 }
1456
1457 async fn handle_forget(
1458 &self,
1459 args: ForgetArgs,
1460 ) -> std::result::Result<CallToolResult, McpError> {
1461 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
1462 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
1463 })?;
1464 self.inner
1465 .tenant
1466 .write()
1467 .forget_as(self.inner.audit_principal.clone(), mid, args.reason)
1468 .await
1469 .map_err(solo_to_mcp)?;
1470 Ok(CallToolResult::success(vec![Content::text(format!(
1471 "forgotten {mid}"
1472 ))]))
1473 }
1474
1475 async fn handle_inspect(
1476 &self,
1477 args: InspectArgs,
1478 ) -> std::result::Result<CallToolResult, McpError> {
1479 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
1480 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
1481 })?;
1482 let row = solo_query::inspect_one(
1484 self.inner.tenant.read(),
1485 self.inner.tenant.audit(),
1486 self.inner.audit_principal.clone(),
1487 mid,
1488 )
1489 .await
1490 .map_err(solo_to_mcp)?;
1491 let body = serde_json::to_string_pretty(&row).unwrap_or_else(|_| String::new());
1492 Ok(CallToolResult::success(vec![Content::text(body)]))
1493 }
1494
1495 async fn handle_themes(
1502 &self,
1503 args: ThemesArgs,
1504 ) -> std::result::Result<CallToolResult, McpError> {
1505 let hits = solo_query::themes(
1506 self.inner.tenant.read(),
1507 self.inner.tenant.audit(),
1508 self.inner.audit_principal.clone(),
1509 args.window_days,
1510 args.limit,
1511 )
1512 .await
1513 .map_err(solo_to_mcp)?;
1514 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1515 Ok(CallToolResult::success(vec![Content::text(body)]))
1516 }
1517
1518 async fn handle_facts_about(
1519 &self,
1520 args: FactsAboutArgs,
1521 ) -> std::result::Result<CallToolResult, McpError> {
1522 if args.subject.trim().is_empty() {
1523 return Err(McpError::invalid_params(
1524 "memory_facts_about: subject must not be empty".to_string(),
1525 None,
1526 ));
1527 }
1528 let hits = solo_query::facts_about(
1529 self.inner.tenant.read(),
1530 self.inner.tenant.audit(),
1531 self.inner.audit_principal.clone(),
1532 &args.subject,
1533 &self.inner.user_aliases,
1534 args.include_as_object,
1535 args.predicate.as_deref(),
1536 args.since_ms,
1537 args.until_ms,
1538 args.limit,
1539 )
1540 .await
1541 .map_err(solo_to_mcp)?;
1542 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1543 Ok(CallToolResult::success(vec![Content::text(body)]))
1544 }
1545
1546 async fn handle_contradictions(
1547 &self,
1548 args: ContradictionsArgs,
1549 ) -> std::result::Result<CallToolResult, McpError> {
1550 let hits = solo_query::contradictions(
1551 self.inner.tenant.read(),
1552 self.inner.tenant.audit(),
1553 self.inner.audit_principal.clone(),
1554 args.limit,
1555 )
1556 .await
1557 .map_err(solo_to_mcp)?;
1558 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1559 Ok(CallToolResult::success(vec![Content::text(body)]))
1560 }
1561
1562 async fn handle_inspect_cluster(
1563 &self,
1564 args: InspectClusterArgs,
1565 ) -> std::result::Result<CallToolResult, McpError> {
1566 if args.cluster_id.trim().is_empty() {
1567 return Err(McpError::invalid_params(
1568 "memory_inspect_cluster: cluster_id must not be empty".to_string(),
1569 None,
1570 ));
1571 }
1572 let record = solo_query::inspect_cluster(
1577 self.inner.tenant.read(),
1578 self.inner.tenant.audit(),
1579 self.inner.audit_principal.clone(),
1580 &args.cluster_id,
1581 args.full_content,
1582 )
1583 .await
1584 .map_err(solo_to_mcp)?;
1585 let body = serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
1586 Ok(CallToolResult::success(vec![Content::text(body)]))
1587 }
1588
1589 async fn handle_ingest_document(
1594 &self,
1595 args: IngestDocumentArgs,
1596 ) -> std::result::Result<CallToolResult, McpError> {
1597 if args.path.trim().is_empty() {
1598 return Err(McpError::invalid_params(
1599 "memory_ingest_document: path must not be empty".to_string(),
1600 None,
1601 ));
1602 }
1603 let path = std::path::PathBuf::from(args.path);
1604 let chunk_config = solo_storage::document::ChunkConfig::default();
1608 let report = self
1609 .inner
1610 .tenant
1611 .write()
1612 .ingest_document_as(self.inner.audit_principal.clone(), path, chunk_config)
1613 .await
1614 .map_err(solo_to_mcp)?;
1615 let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| String::new());
1616 Ok(CallToolResult::success(vec![Content::text(body)]))
1617 }
1618
1619 async fn handle_search_docs(
1620 &self,
1621 args: SearchDocsArgs,
1622 ) -> std::result::Result<CallToolResult, McpError> {
1623 let hits = solo_query::run_doc_search(
1627 self.inner.tenant.as_ref(),
1628 self.inner.audit_principal.clone(),
1629 &args.query,
1630 args.limit,
1631 )
1632 .await
1633 .map_err(solo_to_mcp)?;
1634 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1635 Ok(CallToolResult::success(vec![Content::text(body)]))
1636 }
1637
1638 async fn handle_inspect_document(
1639 &self,
1640 args: InspectDocumentArgs,
1641 ) -> std::result::Result<CallToolResult, McpError> {
1642 let doc_id = DocumentId::from_str(&args.doc_id).map_err(|e| {
1643 McpError::invalid_params(format!("invalid doc_id: {e}"), None)
1644 })?;
1645 let result_opt = solo_query::inspect_document(
1646 self.inner.tenant.read(),
1647 self.inner.tenant.audit(),
1648 self.inner.audit_principal.clone(),
1649 &doc_id,
1650 )
1651 .await
1652 .map_err(solo_to_mcp)?;
1653 match result_opt {
1654 Some(record) => {
1655 let body =
1656 serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
1657 Ok(CallToolResult::success(vec![Content::text(body)]))
1658 }
1659 None => Err(McpError::invalid_params(
1660 format!("document {doc_id} not found"),
1661 None,
1662 )),
1663 }
1664 }
1665
1666 async fn handle_list_documents(
1667 &self,
1668 args: ListDocumentsArgs,
1669 ) -> std::result::Result<CallToolResult, McpError> {
1670 let rows = solo_query::list_documents(
1671 self.inner.tenant.read(),
1672 self.inner.tenant.audit(),
1673 self.inner.audit_principal.clone(),
1674 args.limit,
1675 args.offset,
1676 args.include_forgotten,
1677 )
1678 .await
1679 .map_err(solo_to_mcp)?;
1680 let body = serde_json::to_string_pretty(&rows).unwrap_or_else(|_| String::new());
1681 Ok(CallToolResult::success(vec![Content::text(body)]))
1682 }
1683
1684 async fn handle_forget_document(
1685 &self,
1686 args: ForgetDocumentArgs,
1687 ) -> std::result::Result<CallToolResult, McpError> {
1688 let doc_id = DocumentId::from_str(&args.doc_id).map_err(|e| {
1689 McpError::invalid_params(format!("invalid doc_id: {e}"), None)
1690 })?;
1691 let report = self
1692 .inner
1693 .tenant
1694 .write()
1695 .forget_document_as(self.inner.audit_principal.clone(), doc_id)
1696 .await
1697 .map_err(solo_to_mcp)?;
1698 let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| String::new());
1699 Ok(CallToolResult::success(vec![Content::text(body)]))
1700 }
1701}
1702
1703#[cfg(test)]
1704mod dispatch_tests {
1705 use super::*;
1717 use serde_json::json;
1718 use solo_core::VectorIndex;
1719 use solo_storage::test_support::StubVectorIndex;
1720 use solo_storage::{
1721 EmbedderConfig, IdentityConfig, KeyMaterial, ReaderPool, SoloConfig,
1722 StubEmbedder, TenantHandle, TenantRegistry, WriterActor, WriterSpawn,
1723 };
1724 use std::sync::Arc as StdArc;
1725
1726 fn fake_config(dim: u32) -> SoloConfig {
1727 SoloConfig {
1728 schema_version: 1,
1729 salt_hex: "00000000000000000000000000000000".to_string(),
1730 embedder: EmbedderConfig {
1731 name: "stub".to_string(),
1732 version: "v1".to_string(),
1733 dim,
1734 dtype: "f32".to_string(),
1735 },
1736 identity: IdentityConfig::default(),
1737 documents: solo_storage::DocumentConfig::default(),
1738 auth: None,
1739 audit: solo_storage::AuditSettings::default(),
1740 redaction: solo_storage::RedactionConfig::default(),
1741 llm: None,
1742 triples: solo_storage::TriplesConfig::default(),
1743 sampling: solo_storage::SamplingConfig::default(),
1744 }
1745 }
1746
1747 struct Harness {
1748 server: SoloMcpServer,
1749 _tmp: tempfile::TempDir,
1750 write_handle_extra: Option<solo_storage::WriteHandle>,
1751 join: Option<std::thread::JoinHandle<()>>,
1752 }
1753
1754 impl Harness {
1755 fn new(runtime: &tokio::runtime::Runtime) -> Self {
1756 let tmp = tempfile::TempDir::new().unwrap();
1757 let dim = 16usize;
1758 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1759 let embedder: StdArc<dyn solo_core::Embedder> = StdArc::new(StubEmbedder::new("stub", "v1", dim));
1760
1761 let conn = solo_storage::test_support::open_test_db_at(&tmp.path().join("test.db"));
1762 let WriterSpawn { handle, join } = WriterActor::spawn(conn, hnsw.clone());
1763
1764 let path = tmp.path().join("test.db");
1767 let pool: ReaderPool =
1768 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1769
1770 let tenant_id = solo_core::TenantId::default_tenant();
1771 let tenant_handle = StdArc::new(
1772 TenantHandle::from_parts_for_tests(
1773 tenant_id.clone(),
1774 fake_config(dim as u32),
1775 path.clone(),
1776 tmp.path().to_path_buf(),
1777 0, hnsw,
1779 embedder.clone(),
1780 handle.clone(),
1781 std::thread::spawn(|| {}),
1782 pool,
1783 ),
1784 );
1785 let key = KeyMaterial::from_bytes_for_tests([0u8; 32]);
1786 let registry = StdArc::new(TenantRegistry::for_tests_with_single_tenant(
1787 tmp.path().to_path_buf(),
1788 key,
1789 embedder,
1790 tenant_handle.clone(),
1791 ));
1792 let server = SoloMcpServer::new_for_tenant(registry, tenant_handle, Vec::new());
1793 Harness {
1794 server,
1795 _tmp: tmp,
1796 write_handle_extra: Some(handle),
1797 join: Some(join),
1798 }
1799 }
1800
1801 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1802 let join = self.join.take();
1808 let extra = self.write_handle_extra.take();
1809 runtime.block_on(async move {
1810 drop(extra);
1811 drop(self.server);
1812 drop(self._tmp);
1813 if let Some(join) = join {
1814 let (tx, rx) = std::sync::mpsc::channel();
1815 std::thread::spawn(move || {
1816 let _ = tx.send(join.join());
1817 });
1818 tokio::task::spawn_blocking(move || {
1819 rx.recv_timeout(std::time::Duration::from_secs(5))
1820 })
1821 .await
1822 .expect("blocking task")
1823 .expect("writer thread did not exit within 5s")
1824 .expect("writer thread panicked");
1825 }
1826 });
1827 }
1828 }
1829
1830 fn rt() -> tokio::runtime::Runtime {
1831 tokio::runtime::Builder::new_multi_thread()
1832 .worker_threads(2)
1833 .enable_all()
1834 .build()
1835 .unwrap()
1836 }
1837
1838 fn first_text(r: &rmcp::model::CallToolResult) -> String {
1843 let first = r.content.first().expect("at least one content item");
1844 let v = serde_json::to_value(first).expect("content serialises");
1845 v.get("text")
1846 .and_then(|t| t.as_str())
1847 .map(|s| s.to_string())
1848 .unwrap_or_else(|| format!("{v}"))
1849 }
1850
1851 #[test]
1852 fn tools_list_returns_fourteen_canonical_tools() {
1853 let runtime = rt();
1854 let h = Harness::new(&runtime);
1855 let tools = h.server.dispatch_list_tools();
1856 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1857 assert_eq!(
1858 names,
1859 vec![
1860 "memory_remember",
1861 "memory_remember_batch",
1863 "memory_recall",
1864 "memory_forget",
1865 "memory_inspect",
1866 "memory_themes",
1868 "memory_facts_about",
1869 "memory_contradictions",
1870 "memory_inspect_cluster",
1872 "memory_ingest_document",
1874 "memory_search_docs",
1875 "memory_inspect_document",
1876 "memory_list_documents",
1877 "memory_forget_document",
1878 ]
1879 );
1880 for t in &tools {
1881 let desc = t.description.as_deref().unwrap_or("");
1883 assert!(!desc.is_empty(), "{} description empty", t.name);
1884 let _schema = t.schema_as_json_value();
1885 }
1892 h.shutdown(&runtime);
1893 }
1894
1895 #[test]
1896 fn themes_returns_json_array_on_empty_db() {
1897 let runtime = rt();
1898 let h = Harness::new(&runtime);
1899 runtime.block_on(async {
1900 let r = h
1901 .server
1902 .dispatch_tool("memory_themes", json!({}))
1903 .await
1904 .expect("themes succeeds");
1905 let text = first_text(&r);
1906 let v: serde_json::Value =
1908 serde_json::from_str(&text).expect("parses as json");
1909 assert!(v.is_array(), "expected array, got: {text}");
1910 assert_eq!(v.as_array().unwrap().len(), 0);
1911 });
1912 h.shutdown(&runtime);
1913 }
1914
1915 #[test]
1916 fn themes_passes_through_window_and_limit_args() {
1917 let runtime = rt();
1918 let h = Harness::new(&runtime);
1919 runtime.block_on(async {
1920 let r = h
1922 .server
1923 .dispatch_tool(
1924 "memory_themes",
1925 json!({ "window_days": 7, "limit": 20 }),
1926 )
1927 .await
1928 .expect("themes with args succeeds");
1929 let text = first_text(&r);
1930 let v: serde_json::Value =
1931 serde_json::from_str(&text).expect("parses as json");
1932 assert!(v.is_array());
1933 });
1934 h.shutdown(&runtime);
1935 }
1936
1937 #[test]
1938 fn facts_about_rejects_empty_subject() {
1939 let runtime = rt();
1940 let h = Harness::new(&runtime);
1941 runtime.block_on(async {
1942 let err = h
1943 .server
1944 .dispatch_tool(
1945 "memory_facts_about",
1946 json!({ "subject": " " }),
1947 )
1948 .await
1949 .expect_err("empty subject must error");
1950 let s = format!("{err:?}");
1953 assert!(
1954 s.to_lowercase().contains("subject")
1955 || s.to_lowercase().contains("invalid"),
1956 "got: {s}"
1957 );
1958 });
1959 h.shutdown(&runtime);
1960 }
1961
1962 #[test]
1963 fn facts_about_returns_array_for_unknown_subject() {
1964 let runtime = rt();
1965 let h = Harness::new(&runtime);
1966 runtime.block_on(async {
1967 let r = h
1968 .server
1969 .dispatch_tool(
1970 "memory_facts_about",
1971 json!({ "subject": "NobodyKnowsThisSubject" }),
1972 )
1973 .await
1974 .expect("facts_about with unknown subject succeeds");
1975 let text = first_text(&r);
1976 let v: serde_json::Value =
1977 serde_json::from_str(&text).expect("parses as json");
1978 assert_eq!(v.as_array().unwrap().len(), 0);
1979 });
1980 h.shutdown(&runtime);
1981 }
1982
1983 #[test]
1984 fn facts_about_accepts_include_as_object_arg() {
1985 let runtime = rt();
1993 let h = Harness::new(&runtime);
1994 runtime.block_on(async {
1995 let r = h
1997 .server
1998 .dispatch_tool(
1999 "memory_facts_about",
2000 json!({ "subject": "Maya", "include_as_object": true }),
2001 )
2002 .await
2003 .expect("dispatch with include_as_object=true succeeds");
2004 let v: serde_json::Value = serde_json::from_str(&first_text(&r))
2005 .expect("parses as json");
2006 assert_eq!(v.as_array().unwrap().len(), 0);
2007
2008 let r = h
2010 .server
2011 .dispatch_tool(
2012 "memory_facts_about",
2013 json!({ "subject": "Maya" }),
2014 )
2015 .await
2016 .expect("dispatch without include_as_object succeeds (default false)");
2017 let v: serde_json::Value = serde_json::from_str(&first_text(&r))
2018 .expect("parses as json");
2019 assert_eq!(v.as_array().unwrap().len(), 0);
2020 });
2021 h.shutdown(&runtime);
2022 }
2023
2024 #[test]
2025 fn contradictions_returns_json_array_on_empty_db() {
2026 let runtime = rt();
2027 let h = Harness::new(&runtime);
2028 runtime.block_on(async {
2029 let r = h
2030 .server
2031 .dispatch_tool("memory_contradictions", json!({}))
2032 .await
2033 .expect("contradictions succeeds");
2034 let text = first_text(&r);
2035 let v: serde_json::Value =
2036 serde_json::from_str(&text).expect("parses as json");
2037 assert!(v.is_array());
2038 assert_eq!(v.as_array().unwrap().len(), 0);
2039 });
2040 h.shutdown(&runtime);
2041 }
2042
2043 #[test]
2044 fn remember_then_recall_round_trip() {
2045 let runtime = rt();
2046 let h = Harness::new(&runtime);
2047 runtime.block_on(async {
2053 let r = h
2054 .server
2055 .dispatch_tool("memory_remember", json!({ "content": "the cat sat on the mat" }))
2056 .await
2057 .expect("remember succeeds");
2058 let text = first_text(&r);
2059 assert!(text.starts_with("remembered "), "got: {text}");
2060
2061 let r = h
2062 .server
2063 .dispatch_tool(
2064 "memory_recall",
2065 json!({ "query": "the cat sat on the mat", "limit": 5 }),
2066 )
2067 .await
2068 .expect("recall succeeds");
2069 let text = first_text(&r);
2070 assert!(text.contains("the cat sat on the mat"), "got: {text}");
2071 });
2072 h.shutdown(&runtime);
2073 }
2074
2075 #[test]
2076 fn forget_excludes_row_from_subsequent_recall() {
2077 let runtime = rt();
2078 let h = Harness::new(&runtime);
2079
2080 runtime.block_on(async {
2081 let r = h
2082 .server
2083 .dispatch_tool("memory_remember", json!({ "content": "to be forgotten" }))
2084 .await
2085 .unwrap();
2086 let text = first_text(&r);
2087 let mid = text.strip_prefix("remembered ").unwrap().to_string();
2088
2089 h.server
2090 .dispatch_tool(
2091 "memory_forget",
2092 json!({ "memory_id": mid, "reason": "test" }),
2093 )
2094 .await
2095 .expect("forget succeeds");
2096
2097 let r = h
2098 .server
2099 .dispatch_tool(
2100 "memory_recall",
2101 json!({ "query": "to be forgotten", "limit": 5 }),
2102 )
2103 .await
2104 .unwrap();
2105 let text = first_text(&r);
2106 assert!(
2107 !text.contains(r#""content": "to be forgotten""#),
2108 "forgotten row should be excluded; got: {text}"
2109 );
2110 });
2111 h.shutdown(&runtime);
2112 }
2113
2114 #[test]
2115 fn empty_remember_returns_invalid_params() {
2116 let runtime = rt();
2117 let h = Harness::new(&runtime);
2118 runtime.block_on(async {
2119 let err = h
2120 .server
2121 .dispatch_tool("memory_remember", json!({ "content": "" }))
2122 .await
2123 .unwrap_err();
2124 assert!(format!("{err:?}").contains("must not be empty"));
2125 });
2126 h.shutdown(&runtime);
2127 }
2128
2129 #[test]
2130 fn empty_recall_query_returns_invalid_params() {
2131 let runtime = rt();
2132 let h = Harness::new(&runtime);
2133 runtime.block_on(async {
2134 let err = h
2135 .server
2136 .dispatch_tool("memory_recall", json!({ "query": " " }))
2137 .await
2138 .unwrap_err();
2139 assert!(format!("{err:?}").contains("must not be empty"));
2140 });
2141 h.shutdown(&runtime);
2142 }
2143
2144 #[test]
2145 fn inspect_with_invalid_id_returns_invalid_params() {
2146 let runtime = rt();
2147 let h = Harness::new(&runtime);
2148 runtime.block_on(async {
2149 let err = h
2150 .server
2151 .dispatch_tool("memory_inspect", json!({ "memory_id": "not-a-uuid" }))
2152 .await
2153 .unwrap_err();
2154 assert!(format!("{err:?}").contains("invalid memory_id"));
2155 });
2156 h.shutdown(&runtime);
2157 }
2158
2159 #[test]
2160 fn forget_unknown_id_returns_invalid_params() {
2161 let runtime = rt();
2162 let h = Harness::new(&runtime);
2163 runtime.block_on(async {
2164 let err = h
2168 .server
2169 .dispatch_tool(
2170 "memory_forget",
2171 json!({ "memory_id": "00000000-0000-7000-8000-000000000000" }),
2172 )
2173 .await
2174 .unwrap_err();
2175 assert!(format!("{err:?}").contains("not found"));
2176 });
2177 h.shutdown(&runtime);
2178 }
2179
2180 #[test]
2181 fn unknown_tool_name_returns_invalid_params() {
2182 let runtime = rt();
2183 let h = Harness::new(&runtime);
2184 runtime.block_on(async {
2185 let err = h
2186 .server
2187 .dispatch_tool("memory.summon", json!({}))
2188 .await
2189 .unwrap_err();
2190 assert!(format!("{err:?}").contains("unknown tool"));
2191 });
2192 h.shutdown(&runtime);
2193 }
2194
2195 #[test]
2230 fn tool_names_match_cross_provider_regex() {
2231 fn passes_anthropic(name: &str) -> bool {
2233 let len = name.len();
2234 if !(1..=64).contains(&len) {
2235 return false;
2236 }
2237 name.chars()
2238 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
2239 }
2240
2241 fn passes_openai(name: &str) -> bool {
2244 let len = name.len();
2245 if !(1..=64).contains(&len) {
2246 return false;
2247 }
2248 let mut chars = name.chars();
2249 let first = match chars.next() {
2250 Some(c) => c,
2251 None => return false,
2252 };
2253 if !(first.is_ascii_alphabetic() || first == '_') {
2254 return false;
2255 }
2256 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
2257 }
2258
2259 fn passes_gemini(name: &str) -> bool {
2264 let len = name.len();
2265 if !(1..=63).contains(&len) {
2266 return false;
2267 }
2268 let mut chars = name.chars();
2269 let first = match chars.next() {
2270 Some(c) => c,
2271 None => return false,
2272 };
2273 if !(first.is_ascii_alphabetic() || first == '_') {
2274 return false;
2275 }
2276 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
2277 }
2278
2279 let tools = build_tools();
2280 assert_eq!(
2281 tools.len(),
2282 14,
2283 "expected 14 tools in v0.9.2 (8 v0.5.x + 5 document tools + remember_batch)"
2284 );
2285 let tool_name_strings: Vec<String> =
2287 tools.iter().map(|t| t.name.to_string()).collect();
2288 let public_names: Vec<String> =
2289 super::tool_names().iter().map(|s| s.to_string()).collect();
2290 assert_eq!(
2291 tool_name_strings, public_names,
2292 "tool_names() drifted from build_tools() — keep them in sync"
2293 );
2294
2295 for t in tools {
2296 assert!(
2297 passes_anthropic(&t.name),
2298 "tool name {:?} fails Anthropic regex \
2299 ^[a-zA-Z0-9_-]{{1,64}}$ — see v0.3 lesson #8",
2300 t.name
2301 );
2302 assert!(
2303 passes_openai(&t.name),
2304 "tool name {:?} fails OpenAI function-calling regex \
2305 ^[a-zA-Z_][a-zA-Z0-9_-]*$ (len ≤ 64)",
2306 t.name
2307 );
2308 assert!(
2309 passes_gemini(&t.name),
2310 "tool name {:?} fails Gemini function-calling regex \
2311 ^[a-zA-Z_][a-zA-Z0-9_]*$ (len ≤ 63, strict)",
2312 t.name
2313 );
2314 }
2315 }
2316
2317 #[test]
2334 fn tool_descriptions_avoid_internal_jargon() {
2335 const FORBIDDEN: &[&str] = &[
2339 "SPO",
2340 "Steward",
2341 "Steward-flagged",
2342 "LEFT JOIN",
2343 "candidate pair",
2344 "candidate_pair",
2345 "tagged_with",
2346 ];
2347
2348 fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
2349 haystack.to_lowercase().contains(&needle.to_lowercase())
2350 }
2351
2352 for t in build_tools() {
2354 let desc = t.description.as_deref().unwrap_or("");
2355 for term in FORBIDDEN {
2356 assert!(
2357 !contains_case_insensitive(desc, term),
2358 "tool {:?} description contains forbidden jargon \
2359 {:?} — rewrite in plain English (see v0.5.0 \
2360 Priority 4)",
2361 t.name,
2362 term,
2363 );
2364 }
2365 }
2366
2367 let server_info = harness_server_info();
2370 let instructions = server_info
2371 .instructions
2372 .as_deref()
2373 .expect("get_info() must set instructions");
2374 for term in FORBIDDEN {
2375 assert!(
2376 !contains_case_insensitive(instructions, term),
2377 "get_info().instructions contains forbidden jargon \
2378 {:?} — rewrite in plain English",
2379 term,
2380 );
2381 }
2382 }
2383
2384 fn harness_server_info() -> rmcp::model::ServerInfo {
2391 let runtime = rt();
2392 let h = Harness::new(&runtime);
2393 let info = ServerHandler::get_info(&h.server);
2394 h.shutdown(&runtime);
2395 info
2396 }
2397
2398 #[test]
2419 fn server_info_identity_is_solo_not_rmcp_or_solo_api() {
2420 let info = harness_server_info();
2421 let name = info.server_info.name.as_str();
2422 let version = info.server_info.version.as_str();
2423 assert_eq!(
2424 name, "solo",
2425 "MCP serverInfo.name must be \"solo\" (not \"rmcp\" or \
2426 \"solo-api\"). got name={name:?} version={version:?}"
2427 );
2428 assert_eq!(
2429 version,
2430 env!("CARGO_PKG_VERSION"),
2431 "MCP serverInfo.version must match solo-api's compile-time \
2432 CARGO_PKG_VERSION (i.e. the workspace.package version); \
2433 a mismatch means we regressed back to rmcp's build env. \
2434 got version={version:?}"
2435 );
2436 }
2437
2438 #[test]
2441 fn inspect_cluster_unknown_id_returns_invalid_params() {
2442 let runtime = rt();
2446 let h = Harness::new(&runtime);
2447 runtime.block_on(async {
2448 let err = h
2449 .server
2450 .dispatch_tool(
2451 "memory_inspect_cluster",
2452 json!({ "cluster_id": "no-such-cluster" }),
2453 )
2454 .await
2455 .expect_err("unknown cluster must error");
2456 let s = format!("{err:?}");
2457 assert!(
2458 s.contains("no-such-cluster") || s.to_lowercase().contains("not found"),
2459 "expected error to mention the missing cluster id; got: {s}"
2460 );
2461 });
2462 h.shutdown(&runtime);
2463 }
2464
2465 #[test]
2466 fn inspect_cluster_rejects_empty_id() {
2467 let runtime = rt();
2468 let h = Harness::new(&runtime);
2469 runtime.block_on(async {
2470 let err = h
2471 .server
2472 .dispatch_tool(
2473 "memory_inspect_cluster",
2474 json!({ "cluster_id": " " }),
2475 )
2476 .await
2477 .expect_err("blank cluster_id must error");
2478 let s = format!("{err:?}");
2479 assert!(
2480 s.to_lowercase().contains("cluster_id")
2481 || s.to_lowercase().contains("must not be empty"),
2482 "got: {s}"
2483 );
2484 });
2485 h.shutdown(&runtime);
2486 }
2487
2488 #[test]
2504 fn ingest_document_args_parse_with_required_path() {
2505 let v: IngestDocumentArgs =
2506 serde_json::from_value(json!({ "path": "/tmp/notes.md" })).expect("parses");
2507 assert_eq!(v.path, "/tmp/notes.md");
2508 let err = serde_json::from_value::<IngestDocumentArgs>(json!({})).unwrap_err();
2510 assert!(format!("{err}").contains("path"));
2511 }
2512
2513 #[test]
2514 fn search_docs_args_parse_with_default_limit() {
2515 let v: SearchDocsArgs =
2516 serde_json::from_value(json!({ "query": "backups" })).expect("parses");
2517 assert_eq!(v.query, "backups");
2518 assert_eq!(v.limit, 5, "default limit must be 5");
2519 let v: SearchDocsArgs =
2520 serde_json::from_value(json!({ "query": "backups", "limit": 20 })).expect("parses");
2521 assert_eq!(v.limit, 20);
2522 }
2523
2524 #[test]
2525 fn inspect_document_args_parse_with_required_doc_id() {
2526 let v: InspectDocumentArgs =
2527 serde_json::from_value(json!({ "doc_id": "abc" })).expect("parses");
2528 assert_eq!(v.doc_id, "abc");
2529 let err = serde_json::from_value::<InspectDocumentArgs>(json!({})).unwrap_err();
2530 assert!(format!("{err}").contains("doc_id"));
2531 }
2532
2533 #[test]
2534 fn list_documents_args_parse_with_all_defaults() {
2535 let v: ListDocumentsArgs = serde_json::from_value(json!({})).expect("parses");
2536 assert_eq!(v.limit, 20, "default limit must be 20");
2537 assert_eq!(v.offset, 0, "default offset must be 0");
2538 assert!(!v.include_forgotten, "default include_forgotten must be false");
2539 let v: ListDocumentsArgs = serde_json::from_value(
2540 json!({ "limit": 5, "offset": 10, "include_forgotten": true }),
2541 )
2542 .expect("parses");
2543 assert_eq!(v.limit, 5);
2544 assert_eq!(v.offset, 10);
2545 assert!(v.include_forgotten);
2546 }
2547
2548 #[test]
2549 fn forget_document_args_parse_with_required_doc_id() {
2550 let v: ForgetDocumentArgs =
2551 serde_json::from_value(json!({ "doc_id": "abc" })).expect("parses");
2552 assert_eq!(v.doc_id, "abc");
2553 let err = serde_json::from_value::<ForgetDocumentArgs>(json!({})).unwrap_err();
2554 assert!(format!("{err}").contains("doc_id"));
2555 }
2556
2557 #[test]
2558 fn ingest_document_rejects_empty_path() {
2559 let runtime = rt();
2562 let h = Harness::new(&runtime);
2563 runtime.block_on(async {
2564 let err = h
2565 .server
2566 .dispatch_tool("memory_ingest_document", json!({ "path": "" }))
2567 .await
2568 .expect_err("empty path must error");
2569 let s = format!("{err:?}");
2570 assert!(
2571 s.to_lowercase().contains("path")
2572 || s.to_lowercase().contains("must not be empty"),
2573 "got: {s}"
2574 );
2575 });
2576 h.shutdown(&runtime);
2577 }
2578
2579 #[test]
2580 fn search_docs_rejects_empty_query() {
2581 let runtime = rt();
2584 let h = Harness::new(&runtime);
2585 runtime.block_on(async {
2586 let err = h
2587 .server
2588 .dispatch_tool("memory_search_docs", json!({ "query": " " }))
2589 .await
2590 .expect_err("empty query must error");
2591 let s = format!("{err:?}");
2592 assert!(
2593 s.to_lowercase().contains("must not be empty")
2594 || s.to_lowercase().contains("invalid"),
2595 "got: {s}"
2596 );
2597 });
2598 h.shutdown(&runtime);
2599 }
2600
2601 #[test]
2602 fn inspect_document_unknown_id_returns_invalid_params() {
2603 let runtime = rt();
2606 let h = Harness::new(&runtime);
2607 runtime.block_on(async {
2608 let err = h
2609 .server
2610 .dispatch_tool(
2611 "memory_inspect_document",
2612 json!({ "doc_id": "00000000-0000-7000-8000-000000000000" }),
2613 )
2614 .await
2615 .expect_err("unknown doc must error");
2616 let s = format!("{err:?}");
2617 assert!(
2618 s.to_lowercase().contains("not found"),
2619 "expected 'not found' message; got: {s}"
2620 );
2621 });
2622 h.shutdown(&runtime);
2623 }
2624
2625 #[test]
2626 fn inspect_document_rejects_malformed_id() {
2627 let runtime = rt();
2628 let h = Harness::new(&runtime);
2629 runtime.block_on(async {
2630 let err = h
2631 .server
2632 .dispatch_tool(
2633 "memory_inspect_document",
2634 json!({ "doc_id": "not-a-uuid" }),
2635 )
2636 .await
2637 .expect_err("malformed doc_id must error");
2638 let s = format!("{err:?}");
2639 assert!(s.contains("invalid doc_id"), "got: {s}");
2640 });
2641 h.shutdown(&runtime);
2642 }
2643
2644 #[test]
2645 fn list_documents_returns_empty_array_on_empty_db() {
2646 let runtime = rt();
2647 let h = Harness::new(&runtime);
2648 runtime.block_on(async {
2649 let r = h
2650 .server
2651 .dispatch_tool("memory_list_documents", json!({}))
2652 .await
2653 .expect("list succeeds");
2654 let text = first_text(&r);
2655 let v: serde_json::Value =
2656 serde_json::from_str(&text).expect("parses as json");
2657 assert!(v.is_array(), "expected array, got: {text}");
2658 assert_eq!(v.as_array().unwrap().len(), 0);
2659 });
2660 h.shutdown(&runtime);
2661 }
2662
2663 #[test]
2664 fn list_documents_passes_through_limit_offset_include_args() {
2665 let runtime = rt();
2666 let h = Harness::new(&runtime);
2667 runtime.block_on(async {
2668 let r = h
2669 .server
2670 .dispatch_tool(
2671 "memory_list_documents",
2672 json!({ "limit": 5, "offset": 10, "include_forgotten": true }),
2673 )
2674 .await
2675 .expect("list with args succeeds");
2676 let text = first_text(&r);
2677 let v: serde_json::Value =
2678 serde_json::from_str(&text).expect("parses as json");
2679 assert!(v.is_array());
2680 });
2681 h.shutdown(&runtime);
2682 }
2683
2684 #[test]
2685 fn forget_document_rejects_malformed_id() {
2686 let runtime = rt();
2687 let h = Harness::new(&runtime);
2688 runtime.block_on(async {
2689 let err = h
2690 .server
2691 .dispatch_tool(
2692 "memory_forget_document",
2693 json!({ "doc_id": "not-a-uuid" }),
2694 )
2695 .await
2696 .expect_err("malformed doc_id must error");
2697 let s = format!("{err:?}");
2698 assert!(s.contains("invalid doc_id"), "got: {s}");
2699 });
2700 h.shutdown(&runtime);
2701 }
2702
2703 #[test]
2711 fn remember_with_explicit_salience_round_trips() {
2712 let runtime = rt();
2713 let h = Harness::new(&runtime);
2714 runtime.block_on(async {
2715 let r = h
2716 .server
2717 .dispatch_tool(
2718 "memory_remember",
2719 json!({ "content": "with salience", "salience": 0.83 }),
2720 )
2721 .await
2722 .expect("remember w/ salience succeeds");
2723 let text = first_text(&r);
2724 assert!(text.starts_with("remembered "), "got: {text}");
2726 });
2727 h.shutdown(&runtime);
2728 }
2729
2730 #[test]
2731 fn remember_with_out_of_range_salience_returns_invalid_params() {
2732 let runtime = rt();
2733 let h = Harness::new(&runtime);
2734 runtime.block_on(async {
2735 let err = h
2736 .server
2737 .dispatch_tool(
2738 "memory_remember",
2739 json!({ "content": "out of range", "salience": 1.5 }),
2740 )
2741 .await
2742 .unwrap_err();
2743 let s = format!("{err:?}");
2744 assert!(s.contains("salience must be"), "got: {s}");
2745 });
2746 h.shutdown(&runtime);
2747 }
2748
2749 #[test]
2751 fn remember_with_boundary_salience_succeeds() {
2752 let runtime = rt();
2753 let h = Harness::new(&runtime);
2754 runtime.block_on(async {
2755 for s in [0.0_f64, 1.0_f64] {
2756 let r = h
2757 .server
2758 .dispatch_tool(
2759 "memory_remember",
2760 json!({ "content": format!("boundary-{s}"), "salience": s }),
2761 )
2762 .await
2763 .expect("boundary salience succeeds");
2764 assert!(first_text(&r).starts_with("remembered "));
2765 }
2766 });
2767 h.shutdown(&runtime);
2768 }
2769
2770 #[test]
2772 fn remember_batch_returns_ids_in_order() {
2773 let runtime = rt();
2774 let h = Harness::new(&runtime);
2775 runtime.block_on(async {
2776 let items = json!([
2777 { "content": "batch-a" },
2778 { "content": "batch-b", "source_type": "user_preference", "salience": 0.9 },
2779 { "content": "batch-c", "salience": 0.1 },
2780 ]);
2781 let r = h
2782 .server
2783 .dispatch_tool(
2784 "memory_remember_batch",
2785 json!({ "items": items }),
2786 )
2787 .await
2788 .expect("batch succeeds");
2789 let text = first_text(&r);
2790 let parsed: serde_json::Value =
2791 serde_json::from_str(&text).expect("reply is JSON");
2792 let arr = parsed.as_array().expect("reply is array");
2793 assert_eq!(arr.len(), 3, "3 items in → 3 ids out: {text}");
2794 for v in arr {
2796 let s = v.as_str().unwrap_or_else(|| panic!("non-string id: {v}"));
2797 assert_eq!(s.len(), 36, "UUID-shaped id expected: {s}");
2798 }
2799 let mut ids: Vec<&str> = arr.iter().map(|v| v.as_str().unwrap()).collect();
2801 ids.sort();
2802 ids.dedup();
2803 assert_eq!(ids.len(), 3, "ids must be distinct: {text}");
2804 });
2805 h.shutdown(&runtime);
2806 }
2807
2808 #[test]
2810 fn remember_batch_empty_items_returns_invalid_params() {
2811 let runtime = rt();
2812 let h = Harness::new(&runtime);
2813 runtime.block_on(async {
2814 let err = h
2815 .server
2816 .dispatch_tool(
2817 "memory_remember_batch",
2818 json!({ "items": [] }),
2819 )
2820 .await
2821 .unwrap_err();
2822 let s = format!("{err:?}");
2823 assert!(s.contains("must not be empty"), "got: {s}");
2824 });
2825 h.shutdown(&runtime);
2826 }
2827
2828 #[test]
2831 fn remember_batch_rejects_per_item_empty_content() {
2832 let runtime = rt();
2833 let h = Harness::new(&runtime);
2834 runtime.block_on(async {
2835 let items = json!([
2836 { "content": "ok-1" },
2837 { "content": " " },
2838 { "content": "ok-3" },
2839 ]);
2840 let err = h
2841 .server
2842 .dispatch_tool(
2843 "memory_remember_batch",
2844 json!({ "items": items }),
2845 )
2846 .await
2847 .unwrap_err();
2848 let s = format!("{err:?}");
2849 assert!(s.contains("items[1]"), "must mention items[1]: {s}");
2850 assert!(s.contains("must not be empty"), "got: {s}");
2851 });
2852 h.shutdown(&runtime);
2853 }
2854
2855 #[test]
2858 fn remember_batch_rejects_per_item_salience_out_of_range() {
2859 let runtime = rt();
2860 let h = Harness::new(&runtime);
2861 runtime.block_on(async {
2862 let items = json!([
2863 { "content": "ok-1", "salience": 0.5 },
2864 { "content": "out-of-range", "salience": -0.1 },
2865 ]);
2866 let err = h
2867 .server
2868 .dispatch_tool(
2869 "memory_remember_batch",
2870 json!({ "items": items }),
2871 )
2872 .await
2873 .unwrap_err();
2874 let s = format!("{err:?}");
2875 assert!(s.contains("items[1]"), "must mention items[1]: {s}");
2876 assert!(s.contains("salience must be"), "got: {s}");
2877 });
2878 h.shutdown(&runtime);
2879 }
2880
2881 #[test]
2884 fn remember_batch_over_cap_returns_invalid_params() {
2885 let runtime = rt();
2886 let h = Harness::new(&runtime);
2887 runtime.block_on(async {
2888 let items: Vec<serde_json::Value> =
2889 (0..(solo_storage::MAX_REMEMBER_BATCH_SIZE + 1))
2890 .map(|i| json!({ "content": format!("over-{i}") }))
2891 .collect();
2892 let err = h
2893 .server
2894 .dispatch_tool(
2895 "memory_remember_batch",
2896 json!({ "items": items }),
2897 )
2898 .await
2899 .unwrap_err();
2900 let s = format!("{err:?}");
2901 assert!(
2902 s.contains("MAX_REMEMBER_BATCH_SIZE"),
2903 "must mention the cap: {s}"
2904 );
2905 });
2906 h.shutdown(&runtime);
2907 }
2908}
2909
2910#[cfg(test)]
2921mod principal_extraction_tests {
2922 use super::*;
2923 use std::sync::Mutex;
2924
2925 static ENV_LOCK: Mutex<()> = Mutex::new(());
2929
2930 struct EnvGuard;
2933 impl Drop for EnvGuard {
2934 fn drop(&mut self) {
2935 unsafe { std::env::remove_var(ENV_MCP_PRINCIPAL_TOKEN) };
2937 }
2938 }
2939
2940 fn set_principal_env(val: &str) -> EnvGuard {
2941 unsafe { std::env::set_var(ENV_MCP_PRINCIPAL_TOKEN, val) };
2943 EnvGuard
2944 }
2945
2946 fn clear_principal_env() -> EnvGuard {
2947 unsafe { std::env::remove_var(ENV_MCP_PRINCIPAL_TOKEN) };
2949 EnvGuard
2950 }
2951
2952 #[test]
2955 fn stdio_env_var_resolves_to_principal() {
2956 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2957 let _g = set_principal_env("alice-token");
2958 let resolved = resolve_mcp_principal(None);
2959 assert_eq!(resolved.as_deref(), Some("alice-token"));
2960 }
2961
2962 #[test]
2965 fn stdio_no_env_var_resolves_to_none() {
2966 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2967 let _g = clear_principal_env();
2968 assert_eq!(resolve_mcp_principal(None), None);
2969 }
2970
2971 #[test]
2975 fn stdio_whitespace_env_var_resolves_to_none() {
2976 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2977 let _g = set_principal_env(" \t ");
2978 assert_eq!(resolve_mcp_principal(None), None);
2979 }
2980
2981 #[test]
2984 fn http_header_resolves_to_bearer_token_principal() {
2985 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2986 let _g = clear_principal_env();
2987 let resolved = resolve_mcp_principal(Some("Bearer api-token-xyz"));
2988 assert_eq!(resolved.as_deref(), Some("api-token-xyz"));
2989 }
2990
2991 #[test]
2995 fn http_header_beats_env_var() {
2996 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2997 let _g = set_principal_env("env-token");
2998 let resolved = resolve_mcp_principal(Some("Bearer header-token"));
2999 assert_eq!(
3000 resolved.as_deref(),
3001 Some("header-token"),
3002 "header MUST win over env var per documented precedence"
3003 );
3004 }
3005
3006 #[test]
3009 fn http_malformed_header_falls_through_to_env() {
3010 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
3011 let _g = set_principal_env("env-fallback");
3012 let resolved = resolve_mcp_principal(Some("Basic dXNlcjpwYXNz"));
3013 assert_eq!(resolved.as_deref(), Some("env-fallback"));
3014 }
3015
3016 #[test]
3021 fn http_empty_bearer_header_falls_through_to_env() {
3022 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
3023 let _g = set_principal_env("env-fallback");
3024 let resolved = resolve_mcp_principal(Some("Bearer "));
3025 assert_eq!(resolved.as_deref(), Some("env-fallback"));
3026 }
3027
3028 #[test]
3034 fn stable_across_multiple_resolutions() {
3035 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
3036 let _g = set_principal_env("stable-token");
3037 for _ in 0..5 {
3038 assert_eq!(
3039 resolve_mcp_principal(None).as_deref(),
3040 Some("stable-token")
3041 );
3042 }
3043 }
3044}
3045
3046#[cfg(test)]
3057mod initialize_decision_tests {
3058 use super::*;
3059 use solo_storage::LlmSettings;
3060
3061 #[test]
3063 fn no_llm_block_allows_initialize_regardless_of_sampling_capability() {
3064 assert_eq!(initialize_decision(&None, false), InitializeDecision::Allow);
3065 assert_eq!(initialize_decision(&None, true), InitializeDecision::Allow);
3066 }
3067
3068 #[test]
3070 fn llm_none_allows_initialize_regardless_of_sampling_capability() {
3071 let s = Some(LlmSettings::None);
3072 assert_eq!(initialize_decision(&s, false), InitializeDecision::Allow);
3073 assert_eq!(initialize_decision(&s, true), InitializeDecision::Allow);
3074 }
3075
3076 #[test]
3078 fn llm_anthropic_allows_initialize_regardless_of_sampling_capability() {
3079 let s = Some(LlmSettings::Anthropic {
3080 api_key_env: "ANTHROPIC_API_KEY".into(),
3081 model: "claude-sonnet-4-6".into(),
3082 });
3083 assert_eq!(initialize_decision(&s, false), InitializeDecision::Allow);
3084 assert_eq!(initialize_decision(&s, true), InitializeDecision::Allow);
3085 }
3086
3087 #[test]
3089 fn llm_ollama_allows_initialize_regardless_of_sampling_capability() {
3090 let s = Some(LlmSettings::Ollama {
3091 base_url: "http://localhost:11434".into(),
3092 model: "qwen3-coder:30b".into(),
3093 });
3094 assert_eq!(initialize_decision(&s, false), InitializeDecision::Allow);
3095 assert_eq!(initialize_decision(&s, true), InitializeDecision::Allow);
3096 }
3097
3098 #[test]
3101 fn llm_mcp_sampling_with_sampling_capability_populates_slot() {
3102 let s = Some(LlmSettings::McpSampling);
3103 assert_eq!(
3104 initialize_decision(&s, true),
3105 InitializeDecision::PopulateSamplingSteward
3106 );
3107 }
3108
3109 #[test]
3112 fn llm_mcp_sampling_without_sampling_capability_rejects() {
3113 let s = Some(LlmSettings::McpSampling);
3114 assert_eq!(
3115 initialize_decision(&s, false),
3116 InitializeDecision::RejectMissingSamplingCapability
3117 );
3118 }
3119
3120 #[test]
3124 fn sampling_capability_missing_error_message_contains_all_alternatives() {
3125 let msg = sampling_capability_missing_error_message();
3126 assert!(msg.contains("LLM backend `mcp_sampling`"));
3128 assert!(msg.contains("mode = \"anthropic\""));
3129 assert!(msg.contains("api_key_env = \"ANTHROPIC_API_KEY\""));
3130 assert!(msg.contains("mode = \"openai\""));
3131 assert!(msg.contains("api_key_env = \"OPENAI_API_KEY\""));
3132 assert!(msg.contains("mode = \"ollama\""));
3133 assert!(msg.contains("base_url = \"http://localhost:11434\""));
3134 assert!(msg.contains("mode = \"none\""));
3135 assert!(msg.contains("docs/releases/v0.9.0.md"));
3137 }
3138}
3139
3140