1use std::sync::Arc;
56
57use rmcp::handler::server::ServerHandler;
58use rmcp::model::{
59 CallToolRequestParam, CallToolResult, Content, Implementation, ListToolsResult,
60 PaginatedRequestParam, ProtocolVersion, ServerCapabilities, ServerInfo, Tool,
61 ToolsCapability,
62};
63use rmcp::service::{RequestContext, RoleServer};
64use rmcp::{Error as McpError, ServiceExt};
65use serde::{Deserialize, Serialize};
66use solo_core::{
67 Confidence, DocumentId, EncodingContext, Episode, MemoryId, Tier,
68};
69use solo_storage::{TenantHandle, TenantRegistry};
70use std::str::FromStr;
71
72#[derive(Clone)]
82pub struct SoloMcpServer {
83 inner: Arc<Inner>,
84}
85
86struct Inner {
87 #[allow(dead_code)]
92 registry: Arc<TenantRegistry>,
93 tenant: Arc<TenantHandle>,
96 user_aliases: Vec<String>,
102 audit_principal: Option<String>,
109}
110
111pub const ENV_MCP_PRINCIPAL_TOKEN: &str = "SOLO_MCP_PRINCIPAL_TOKEN";
126
127pub fn resolve_mcp_principal(header_value: Option<&str>) -> Option<String> {
144 if let Some(h) = header_value {
146 if let Some(token) = h.strip_prefix("Bearer ") {
147 let trimmed = token.trim();
148 if !trimmed.is_empty() {
149 return Some(trimmed.to_string());
155 }
156 }
157 }
158 match std::env::var(ENV_MCP_PRINCIPAL_TOKEN) {
160 Ok(v) => {
161 let trimmed = v.trim();
162 if trimmed.is_empty() {
163 None
164 } else {
165 Some(trimmed.to_string())
166 }
167 }
168 Err(_) => None,
169 }
170}
171
172impl SoloMcpServer {
173 pub fn new_for_tenant(
183 registry: Arc<TenantRegistry>,
184 tenant: Arc<TenantHandle>,
185 user_aliases: Vec<String>,
186 ) -> Self {
187 let principal = resolve_mcp_principal(None);
188 Self::new_for_tenant_with_principal(registry, tenant, user_aliases, principal)
189 }
190
191 pub fn new_for_tenant_with_principal(
204 registry: Arc<TenantRegistry>,
205 tenant: Arc<TenantHandle>,
206 user_aliases: Vec<String>,
207 audit_principal: Option<String>,
208 ) -> Self {
209 Self {
210 inner: Arc::new(Inner {
211 registry,
212 tenant,
213 user_aliases,
214 audit_principal,
215 }),
216 }
217 }
218}
219
220pub async fn serve_stdio(server: SoloMcpServer) -> anyhow::Result<()> {
223 use rmcp::transport::io::stdio;
224 let (stdin, stdout) = stdio();
225 let running = server.serve((stdin, stdout)).await?;
226 running.waiting().await?;
227 Ok(())
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct RememberArgs {
236 pub content: String,
237 #[serde(default)]
238 pub source_type: Option<String>,
239 #[serde(default)]
240 pub source_id: Option<String>,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct RecallArgs {
245 pub query: String,
246 #[serde(default = "default_limit")]
247 pub limit: usize,
248}
249
250fn default_limit() -> usize {
251 5
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct ForgetArgs {
256 pub memory_id: String,
257 #[serde(default = "default_forget_reason")]
258 pub reason: String,
259}
260
261fn default_forget_reason() -> String {
262 "user-initiated via MCP".into()
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct InspectArgs {
267 pub memory_id: String,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct ThemesArgs {
277 #[serde(default)]
281 pub window_days: Option<i64>,
282 #[serde(default = "default_limit")]
283 pub limit: usize,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct FactsAboutArgs {
288 pub subject: String,
291 #[serde(default)]
292 pub predicate: Option<String>,
293 #[serde(default)]
294 pub since_ms: Option<i64>,
295 #[serde(default)]
296 pub until_ms: Option<i64>,
297 #[serde(default)]
302 pub include_as_object: bool,
303 #[serde(default = "default_limit")]
304 pub limit: usize,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct ContradictionsArgs {
309 #[serde(default = "default_limit")]
310 pub limit: usize,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct InspectClusterArgs {
318 pub cluster_id: String,
319 #[serde(default)]
324 pub full_content: bool,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct IngestDocumentArgs {
332 pub path: String,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct SearchDocsArgs {
341 pub query: String,
342 #[serde(default = "default_search_docs_limit")]
343 pub limit: usize,
344}
345
346fn default_search_docs_limit() -> usize {
347 5
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct InspectDocumentArgs {
352 pub doc_id: String,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct ListDocumentsArgs {
357 #[serde(default = "default_list_documents_limit")]
358 pub limit: usize,
359 #[serde(default)]
360 pub offset: usize,
361 #[serde(default)]
365 pub include_forgotten: bool,
366}
367
368fn default_list_documents_limit() -> usize {
369 20
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
373pub struct ForgetDocumentArgs {
374 pub doc_id: String,
375}
376
377impl ServerHandler for SoloMcpServer {
382 fn get_info(&self) -> ServerInfo {
383 ServerInfo {
384 protocol_version: ProtocolVersion::default(),
385 capabilities: ServerCapabilities {
386 tools: Some(ToolsCapability {
387 list_changed: Some(false),
388 }),
389 ..Default::default()
390 },
391 server_info: Implementation {
392 name: "solo".into(),
393 version: env!("CARGO_PKG_VERSION").into(),
394 },
395 instructions: Some(
396 "Solo gives you persistent memory across conversations \
397 with this user — what they've told you before, the \
398 people and projects in their life, and where their \
399 stated beliefs have shifted, plus a library of \
400 documents the user has ingested (notes, runbooks, \
401 PDFs). Reach for these tools whenever the user \
402 references something from earlier (\"like I \
403 mentioned\", \"the project I'm working on\", \"my \
404 friend Alex\", \"the notes I uploaded last week\") \
405 or asks a question that hinges on personal context \
406 or document content you don't have in the current \
407 chat. \
408 \n\nTools to write or look up specific moments: \
409 memory_remember (save something worth keeping), \
410 memory_recall (search past conversations by topic), \
411 memory_inspect (show one saved item by id), \
412 memory_forget (delete one saved item). \
413 \n\nTools for the bigger picture (populated as the \
414 user uses Solo over time): memory_themes (recent \
415 topics they've been thinking about), \
416 memory_facts_about (what you know about a person, \
417 project, or place — \"what do you know about \
418 Alex?\"), memory_contradictions (places where the \
419 user has said two things that disagree — surface \
420 these before answering), memory_inspect_cluster \
421 (the raw conversations behind one summary). \
422 \n\nTools for the user's documents: \
423 memory_ingest_document (read a file from disk and \
424 add it to Solo's library), memory_search_docs \
425 (search across ingested documents by topic — use \
426 when the user asks about something they wrote down \
427 or saved as a file), memory_inspect_document (show \
428 one document's metadata plus a preview of its \
429 chunks), memory_list_documents (browse documents \
430 by recency), memory_forget_document (drop a \
431 document from the library)."
432 .into(),
433 ),
434 }
435 }
436
437 async fn list_tools(
438 &self,
439 _request: PaginatedRequestParam,
440 _context: RequestContext<RoleServer>,
441 ) -> std::result::Result<ListToolsResult, McpError> {
442 Ok(ListToolsResult {
443 tools: build_tools(),
444 next_cursor: None,
445 })
446 }
447
448 async fn call_tool(
449 &self,
450 request: CallToolRequestParam,
451 _context: RequestContext<RoleServer>,
452 ) -> std::result::Result<CallToolResult, McpError> {
453 let CallToolRequestParam { name, arguments } = request;
454 let args_value = serde_json::Value::Object(arguments.unwrap_or_default());
455 self.dispatch_tool(&name, args_value).await
456 }
457}
458
459impl SoloMcpServer {
460 pub async fn dispatch_tool(
466 &self,
467 name: &str,
468 args_value: serde_json::Value,
469 ) -> std::result::Result<CallToolResult, McpError> {
470 match name {
471 "memory_remember" => {
472 let args: RememberArgs = parse_args(&args_value)?;
473 self.handle_remember(args).await
474 }
475 "memory_recall" => {
476 let args: RecallArgs = parse_args(&args_value)?;
477 self.handle_recall(args).await
478 }
479 "memory_forget" => {
480 let args: ForgetArgs = parse_args(&args_value)?;
481 self.handle_forget(args).await
482 }
483 "memory_inspect" => {
484 let args: InspectArgs = parse_args(&args_value)?;
485 self.handle_inspect(args).await
486 }
487 "memory_themes" => {
488 let args: ThemesArgs = parse_args(&args_value)?;
489 self.handle_themes(args).await
490 }
491 "memory_facts_about" => {
492 let args: FactsAboutArgs = parse_args(&args_value)?;
493 self.handle_facts_about(args).await
494 }
495 "memory_contradictions" => {
496 let args: ContradictionsArgs = parse_args(&args_value)?;
497 self.handle_contradictions(args).await
498 }
499 "memory_inspect_cluster" => {
500 let args: InspectClusterArgs = parse_args(&args_value)?;
501 self.handle_inspect_cluster(args).await
502 }
503 "memory_ingest_document" => {
504 let args: IngestDocumentArgs = parse_args(&args_value)?;
505 self.handle_ingest_document(args).await
506 }
507 "memory_search_docs" => {
508 let args: SearchDocsArgs = parse_args(&args_value)?;
509 self.handle_search_docs(args).await
510 }
511 "memory_inspect_document" => {
512 let args: InspectDocumentArgs = parse_args(&args_value)?;
513 self.handle_inspect_document(args).await
514 }
515 "memory_list_documents" => {
516 let args: ListDocumentsArgs = parse_args(&args_value)?;
517 self.handle_list_documents(args).await
518 }
519 "memory_forget_document" => {
520 let args: ForgetDocumentArgs = parse_args(&args_value)?;
521 self.handle_forget_document(args).await
522 }
523 other => Err(McpError::invalid_params(
524 format!("unknown tool `{other}`"),
525 None,
526 )),
527 }
528 }
529
530 pub fn dispatch_list_tools(&self) -> Vec<Tool> {
533 build_tools()
534 }
535}
536
537fn parse_args<T: serde::de::DeserializeOwned>(
538 v: &serde_json::Value,
539) -> std::result::Result<T, McpError> {
540 serde_json::from_value(v.clone()).map_err(|e| {
541 McpError::invalid_params(format!("invalid tool arguments: {e}"), None)
542 })
543}
544
545fn solo_to_mcp(e: solo_core::Error) -> McpError {
546 use solo_core::Error;
547 match e {
548 Error::NotFound(msg) => McpError::invalid_params(msg, None),
549 Error::InvalidInput(msg) => McpError::invalid_params(msg, None),
550 Error::Conflict(msg) => McpError::invalid_params(msg, None),
551 other => McpError::internal_error(other.to_string(), None),
552 }
553}
554
555fn build_tools() -> Vec<Tool> {
560 vec![
561 Tool::new(
562 "memory_remember",
563 "Save something the user has told you — a fact, a \
564 preference, a name, a date, a context — so you can pick \
565 it up next conversation. Use whenever the user mentions \
566 something they'd reasonably expect you to recall later \
567 (\"I just started at Quotient\", \"my partner is Maya\"). \
568 Returns the saved item's id.",
569 json_schema_object(serde_json::json!({
570 "type": "object",
571 "properties": {
572 "content": {
573 "type": "string",
574 "description": "The text to remember.",
575 },
576 "source_type": {
577 "type": "string",
578 "description": "Optional source-type tag (default: \"user_message\").",
579 },
580 "source_id": {
581 "type": "string",
582 "description": "Optional upstream id for traceability.",
583 },
584 },
585 "required": ["content"],
586 })),
587 ),
588 Tool::new(
589 "memory_recall",
590 "Search past conversations with this user by topic or \
591 phrase. Returns up to `limit` of the closest matches, \
592 best match first. Use when the user references \
593 something they said before (\"that book I told you \
594 about\", \"the bug we were debugging last week\"). \
595 Skips items the user has deleted.",
596 json_schema_object(serde_json::json!({
597 "type": "object",
598 "properties": {
599 "query": {
600 "type": "string",
601 "description": "The query text.",
602 },
603 "limit": {
604 "type": "integer",
605 "description": "Maximum results (default 5).",
606 "minimum": 1,
607 "maximum": 100,
608 },
609 },
610 "required": ["query"],
611 })),
612 ),
613 Tool::new(
614 "memory_forget",
615 "Delete one saved item by id. Use when the user asks you \
616 to forget something specific (\"forget that I said \
617 X\"). The item stops appearing in future recalls. \
618 Reversible only via backups.",
619 json_schema_object(serde_json::json!({
620 "type": "object",
621 "properties": {
622 "memory_id": {
623 "type": "string",
624 "description": "MemoryId to forget (UUID v7).",
625 },
626 "reason": {
627 "type": "string",
628 "description": "Optional free-form reason (logged, not yet persisted).",
629 },
630 },
631 "required": ["memory_id"],
632 })),
633 ),
634 Tool::new(
635 "memory_inspect",
636 "Show the full record for one saved item — when it was \
637 saved, where it came from, and the full text. Use after \
638 memory_recall when you want the complete content of a \
639 specific hit (recall results may be truncated).",
640 json_schema_object(serde_json::json!({
641 "type": "object",
642 "properties": {
643 "memory_id": {
644 "type": "string",
645 "description": "MemoryId to inspect (UUID v7).",
646 },
647 },
648 "required": ["memory_id"],
649 })),
650 ),
651 Tool::new(
655 "memory_themes",
656 "Recent topics the user has been thinking about. Use to \
657 orient yourself at the start of a conversation, or when \
658 the user asks \"what have I been up to\" / \"what was I \
659 working on last week\". Pass `window_days` to scope \
660 (e.g. 7 for last week); omit for all-time.",
661 json_schema_object(serde_json::json!({
662 "type": "object",
663 "properties": {
664 "window_days": {
665 "type": "integer",
666 "description": "Optional time window in days. Omit for unfiltered.",
667 "minimum": 1,
668 },
669 "limit": {
670 "type": "integer",
671 "description": "Maximum results (default 5).",
672 "minimum": 1,
673 "maximum": 100,
674 },
675 },
676 })),
677 ),
678 Tool::new(
679 "memory_facts_about",
680 "Look up what you remember about a person, project, or \
681 topic — names, dates, preferences, relationships. Use \
682 when the user asks \"what do you know about Alex?\", \
683 \"when did I start at Quotient?\", \"who is Maya?\", or \
684 whenever you need grounded facts about someone or \
685 something before answering. Subject is required (the \
686 person/place/thing you're asking about); narrow further \
687 with `predicate` (\"works_at\", \"lives_in\") or a date \
688 range. Set `include_as_object=true` to also surface \
689 facts where the subject appears on the receiving side of \
690 a relationship (e.g. \"Sam pushes back on PRs about \
691 Maya\" surfaces under facts_about(subject=\"Maya\", \
692 include_as_object=true)). (Backed by \
693 subject-predicate-object triples distilled from past \
694 conversations.) Clients should set a 30s timeout on this \
695 call; if exceeded, retry once or fall back to \
696 `memory_recall`.",
697 json_schema_object(serde_json::json!({
698 "type": "object",
699 "properties": {
700 "subject": {
701 "type": "string",
702 "description": "Subject id to query (e.g. 'Sam').",
703 },
704 "predicate": {
705 "type": "string",
706 "description": "Optional predicate filter (e.g. 'works_at').",
707 },
708 "since_ms": {
709 "type": "integer",
710 "description": "Optional valid_from_ms lower bound (epoch ms).",
711 },
712 "until_ms": {
713 "type": "integer",
714 "description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through.",
715 },
716 "include_as_object": {
717 "type": "boolean",
718 "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.",
719 "default": false,
720 },
721 "limit": {
722 "type": "integer",
723 "description": "Maximum results (default 5).",
724 "minimum": 1,
725 "maximum": 100,
726 },
727 },
728 "required": ["subject"],
729 })),
730 ),
731 Tool::new(
732 "memory_contradictions",
733 "Find places where the user's stated beliefs or facts \
734 disagree across conversations — flag disagreements \
735 before answering. Use whenever you're about to rely on \
736 a remembered fact that could have changed (jobs, \
737 relationships, preferences, opinions); a disagreement \
738 here means the user has told you both X and not-X over \
739 time and you should ask which is current instead of \
740 guessing. Each result shows both conflicting statements \
741 with the topic.",
742 json_schema_object(serde_json::json!({
743 "type": "object",
744 "properties": {
745 "limit": {
746 "type": "integer",
747 "description": "Maximum results (default 5).",
748 "minimum": 1,
749 "maximum": 100,
750 },
751 },
752 })),
753 ),
754 Tool::new(
755 "memory_inspect_cluster",
756 "Show the raw conversations behind one summary. Returns \
757 the one-line topic (the LLM-generated summary) and the \
758 source conversations the topic was built from. Use \
759 after memory_themes when the user asks \"show me the \
760 raw context behind this\" or \"why does Solo think \
761 that about cluster Y\". Source items are truncated to \
762 200 chars unless `full_content` is set.",
763 json_schema_object(serde_json::json!({
764 "type": "object",
765 "properties": {
766 "cluster_id": {
767 "type": "string",
768 "description": "Cluster id to inspect (from memory_themes hits).",
769 },
770 "full_content": {
771 "type": "boolean",
772 "description": "If true, episode content is returned verbatim. Default false (truncate to 200 chars + ellipsis).",
773 },
774 },
775 "required": ["cluster_id"],
776 })),
777 ),
778 Tool::new(
782 "memory_ingest_document",
783 "Read a file from disk and add it to the user's document \
784 library so it becomes searchable alongside past \
785 conversations. Use when the user asks you to remember a \
786 whole file (\"add my notes/runbook.md\", \"ingest this \
787 PDF\"). The file is split into ~500-token chunks and \
788 each chunk is embedded; chunks then surface through \
789 memory_search_docs. Returns the new document id, chunk \
790 count, and a `deduped` flag (true if the same content \
791 was already ingested under another id).",
792 json_schema_object(serde_json::json!({
793 "type": "object",
794 "properties": {
795 "path": {
796 "type": "string",
797 "description": "Server-side absolute path to the file to ingest. The file must be readable by the Solo process.",
798 },
799 },
800 "required": ["path"],
801 })),
802 ),
803 Tool::new(
804 "memory_search_docs",
805 "Search across the user's ingested documents by topic or \
806 phrase. Returns up to `limit` matching chunks, best \
807 match first, each with the parent document's title + \
808 source path so you can cite where the answer came from. \
809 Use when the user asks a question that hinges on \
810 material they've added as a file (\"what does my \
811 runbook say about backups?\", \"find the section in the \
812 notes about the new policy\"). Forgotten documents are \
813 skipped.",
814 json_schema_object(serde_json::json!({
815 "type": "object",
816 "properties": {
817 "query": {
818 "type": "string",
819 "description": "The query text.",
820 },
821 "limit": {
822 "type": "integer",
823 "description": "Maximum results (default 5).",
824 "minimum": 1,
825 "maximum": 100,
826 },
827 },
828 "required": ["query"],
829 })),
830 ),
831 Tool::new(
832 "memory_inspect_document",
833 "Show one document's metadata plus a preview of every \
834 chunk it was split into. Use after memory_search_docs \
835 when the user wants the bigger picture for one hit \
836 (\"show me the whole document this came from\"), or \
837 after memory_list_documents to drill into one entry. \
838 Each chunk preview is truncated to 200 chars.",
839 json_schema_object(serde_json::json!({
840 "type": "object",
841 "properties": {
842 "doc_id": {
843 "type": "string",
844 "description": "Document id to inspect (UUID v7).",
845 },
846 },
847 "required": ["doc_id"],
848 })),
849 ),
850 Tool::new(
851 "memory_list_documents",
852 "List the user's ingested documents, newest first. Use \
853 when the user asks \"what documents have I added?\" or \
854 \"show me my files\". Returns a paginated index — pass \
855 `offset` to page further back. Forgotten documents are \
856 hidden by default; set `include_forgotten=true` to see \
857 them too.",
858 json_schema_object(serde_json::json!({
859 "type": "object",
860 "properties": {
861 "limit": {
862 "type": "integer",
863 "description": "Maximum results per page (default 20).",
864 "minimum": 1,
865 "maximum": 100,
866 },
867 "offset": {
868 "type": "integer",
869 "description": "Number of rows to skip (for paging). Default 0.",
870 "minimum": 0,
871 },
872 "include_forgotten": {
873 "type": "boolean",
874 "description": "If true, also include documents the user has forgotten. Default false.",
875 },
876 },
877 })),
878 ),
879 Tool::new(
880 "memory_forget_document",
881 "Drop one document from the user's library by id. Use \
882 when the user asks you to forget a specific file \
883 (\"forget my old runbook\"). The document's chunks stop \
884 appearing in memory_search_docs and the vectors are \
885 tombstoned in the index. The chunk rows themselves are \
886 kept for forensic value (a future restore command can \
887 undo this).",
888 json_schema_object(serde_json::json!({
889 "type": "object",
890 "properties": {
891 "doc_id": {
892 "type": "string",
893 "description": "Document id to forget (UUID v7).",
894 },
895 },
896 "required": ["doc_id"],
897 })),
898 ),
899 ]
900}
901
902fn json_schema_object(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
903 match value {
904 serde_json::Value::Object(map) => map,
905 _ => panic!("json_schema_object: input must be an object"),
906 }
907}
908
909pub fn tool_names() -> Vec<&'static str> {
918 vec![
919 "memory_remember",
920 "memory_recall",
921 "memory_forget",
922 "memory_inspect",
923 "memory_themes",
924 "memory_facts_about",
925 "memory_contradictions",
926 "memory_inspect_cluster",
927 "memory_ingest_document",
929 "memory_search_docs",
930 "memory_inspect_document",
931 "memory_list_documents",
932 "memory_forget_document",
933 ]
934}
935
936impl SoloMcpServer {
941 async fn handle_remember(
942 &self,
943 args: RememberArgs,
944 ) -> std::result::Result<CallToolResult, McpError> {
945 let content = args.content.trim_end().to_string();
946 if content.is_empty() {
947 return Err(McpError::invalid_params(
948 "memory_remember: content must not be empty".to_string(),
949 None,
950 ));
951 }
952 let embedding: solo_core::Embedding = self
953 .inner
954 .tenant
955 .embedder()
956 .embed(&content)
957 .await
958 .map_err(solo_to_mcp)?;
959 let episode = Episode {
960 memory_id: MemoryId::new(),
961 ts_ms: chrono::Utc::now().timestamp_millis(),
962 source_type: args.source_type.unwrap_or_else(|| "user_message".into()),
963 source_id: args.source_id,
964 content,
965 encoding_context: EncodingContext::default(),
966 provenance: None,
967 confidence: Confidence::new(0.9).unwrap(),
968 strength: 0.5,
969 salience: 0.5,
970 tier: Tier::Hot,
971 };
972 let mid = self
973 .inner
974 .tenant
975 .write()
976 .remember_as(self.inner.audit_principal.clone(), episode, embedding)
977 .await
978 .map_err(solo_to_mcp)?;
979 Ok(CallToolResult::success(vec![Content::text(format!(
980 "remembered {mid}"
981 ))]))
982 }
983
984 async fn handle_recall(
985 &self,
986 args: RecallArgs,
987 ) -> std::result::Result<CallToolResult, McpError> {
988 let result = solo_query::run_recall(
992 self.inner.tenant.as_ref(),
993 self.inner.audit_principal.clone(),
994 &args.query,
995 args.limit,
996 )
997 .await
998 .map_err(solo_to_mcp)?;
999
1000 if result.hits.is_empty() {
1001 return Ok(CallToolResult::success(vec![Content::text(format!(
1002 "no matches (index has {} vectors)",
1003 result.index_len
1004 ))]));
1005 }
1006 let body = serde_json::to_string_pretty(&result.hits).unwrap_or_else(|_| String::new());
1007 Ok(CallToolResult::success(vec![Content::text(body)]))
1008 }
1009
1010 async fn handle_forget(
1011 &self,
1012 args: ForgetArgs,
1013 ) -> std::result::Result<CallToolResult, McpError> {
1014 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
1015 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
1016 })?;
1017 self.inner
1018 .tenant
1019 .write()
1020 .forget_as(self.inner.audit_principal.clone(), mid, args.reason)
1021 .await
1022 .map_err(solo_to_mcp)?;
1023 Ok(CallToolResult::success(vec![Content::text(format!(
1024 "forgotten {mid}"
1025 ))]))
1026 }
1027
1028 async fn handle_inspect(
1029 &self,
1030 args: InspectArgs,
1031 ) -> std::result::Result<CallToolResult, McpError> {
1032 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
1033 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
1034 })?;
1035 let row = solo_query::inspect_one(
1037 self.inner.tenant.read(),
1038 self.inner.tenant.audit(),
1039 self.inner.audit_principal.clone(),
1040 mid,
1041 )
1042 .await
1043 .map_err(solo_to_mcp)?;
1044 let body = serde_json::to_string_pretty(&row).unwrap_or_else(|_| String::new());
1045 Ok(CallToolResult::success(vec![Content::text(body)]))
1046 }
1047
1048 async fn handle_themes(
1055 &self,
1056 args: ThemesArgs,
1057 ) -> std::result::Result<CallToolResult, McpError> {
1058 let hits = solo_query::themes(
1059 self.inner.tenant.read(),
1060 self.inner.tenant.audit(),
1061 self.inner.audit_principal.clone(),
1062 args.window_days,
1063 args.limit,
1064 )
1065 .await
1066 .map_err(solo_to_mcp)?;
1067 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1068 Ok(CallToolResult::success(vec![Content::text(body)]))
1069 }
1070
1071 async fn handle_facts_about(
1072 &self,
1073 args: FactsAboutArgs,
1074 ) -> std::result::Result<CallToolResult, McpError> {
1075 if args.subject.trim().is_empty() {
1076 return Err(McpError::invalid_params(
1077 "memory_facts_about: subject must not be empty".to_string(),
1078 None,
1079 ));
1080 }
1081 let hits = solo_query::facts_about(
1082 self.inner.tenant.read(),
1083 self.inner.tenant.audit(),
1084 self.inner.audit_principal.clone(),
1085 &args.subject,
1086 &self.inner.user_aliases,
1087 args.include_as_object,
1088 args.predicate.as_deref(),
1089 args.since_ms,
1090 args.until_ms,
1091 args.limit,
1092 )
1093 .await
1094 .map_err(solo_to_mcp)?;
1095 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1096 Ok(CallToolResult::success(vec![Content::text(body)]))
1097 }
1098
1099 async fn handle_contradictions(
1100 &self,
1101 args: ContradictionsArgs,
1102 ) -> std::result::Result<CallToolResult, McpError> {
1103 let hits = solo_query::contradictions(
1104 self.inner.tenant.read(),
1105 self.inner.tenant.audit(),
1106 self.inner.audit_principal.clone(),
1107 args.limit,
1108 )
1109 .await
1110 .map_err(solo_to_mcp)?;
1111 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1112 Ok(CallToolResult::success(vec![Content::text(body)]))
1113 }
1114
1115 async fn handle_inspect_cluster(
1116 &self,
1117 args: InspectClusterArgs,
1118 ) -> std::result::Result<CallToolResult, McpError> {
1119 if args.cluster_id.trim().is_empty() {
1120 return Err(McpError::invalid_params(
1121 "memory_inspect_cluster: cluster_id must not be empty".to_string(),
1122 None,
1123 ));
1124 }
1125 let record = solo_query::inspect_cluster(
1130 self.inner.tenant.read(),
1131 self.inner.tenant.audit(),
1132 self.inner.audit_principal.clone(),
1133 &args.cluster_id,
1134 args.full_content,
1135 )
1136 .await
1137 .map_err(solo_to_mcp)?;
1138 let body = serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
1139 Ok(CallToolResult::success(vec![Content::text(body)]))
1140 }
1141
1142 async fn handle_ingest_document(
1147 &self,
1148 args: IngestDocumentArgs,
1149 ) -> std::result::Result<CallToolResult, McpError> {
1150 if args.path.trim().is_empty() {
1151 return Err(McpError::invalid_params(
1152 "memory_ingest_document: path must not be empty".to_string(),
1153 None,
1154 ));
1155 }
1156 let path = std::path::PathBuf::from(args.path);
1157 let chunk_config = solo_storage::document::ChunkConfig::default();
1161 let report = self
1162 .inner
1163 .tenant
1164 .write()
1165 .ingest_document_as(self.inner.audit_principal.clone(), path, chunk_config)
1166 .await
1167 .map_err(solo_to_mcp)?;
1168 let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| String::new());
1169 Ok(CallToolResult::success(vec![Content::text(body)]))
1170 }
1171
1172 async fn handle_search_docs(
1173 &self,
1174 args: SearchDocsArgs,
1175 ) -> std::result::Result<CallToolResult, McpError> {
1176 let hits = solo_query::run_doc_search(
1180 self.inner.tenant.as_ref(),
1181 self.inner.audit_principal.clone(),
1182 &args.query,
1183 args.limit,
1184 )
1185 .await
1186 .map_err(solo_to_mcp)?;
1187 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
1188 Ok(CallToolResult::success(vec![Content::text(body)]))
1189 }
1190
1191 async fn handle_inspect_document(
1192 &self,
1193 args: InspectDocumentArgs,
1194 ) -> std::result::Result<CallToolResult, McpError> {
1195 let doc_id = DocumentId::from_str(&args.doc_id).map_err(|e| {
1196 McpError::invalid_params(format!("invalid doc_id: {e}"), None)
1197 })?;
1198 let result_opt = solo_query::inspect_document(
1199 self.inner.tenant.read(),
1200 self.inner.tenant.audit(),
1201 self.inner.audit_principal.clone(),
1202 &doc_id,
1203 )
1204 .await
1205 .map_err(solo_to_mcp)?;
1206 match result_opt {
1207 Some(record) => {
1208 let body =
1209 serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
1210 Ok(CallToolResult::success(vec![Content::text(body)]))
1211 }
1212 None => Err(McpError::invalid_params(
1213 format!("document {doc_id} not found"),
1214 None,
1215 )),
1216 }
1217 }
1218
1219 async fn handle_list_documents(
1220 &self,
1221 args: ListDocumentsArgs,
1222 ) -> std::result::Result<CallToolResult, McpError> {
1223 let rows = solo_query::list_documents(
1224 self.inner.tenant.read(),
1225 self.inner.tenant.audit(),
1226 self.inner.audit_principal.clone(),
1227 args.limit,
1228 args.offset,
1229 args.include_forgotten,
1230 )
1231 .await
1232 .map_err(solo_to_mcp)?;
1233 let body = serde_json::to_string_pretty(&rows).unwrap_or_else(|_| String::new());
1234 Ok(CallToolResult::success(vec![Content::text(body)]))
1235 }
1236
1237 async fn handle_forget_document(
1238 &self,
1239 args: ForgetDocumentArgs,
1240 ) -> std::result::Result<CallToolResult, McpError> {
1241 let doc_id = DocumentId::from_str(&args.doc_id).map_err(|e| {
1242 McpError::invalid_params(format!("invalid doc_id: {e}"), None)
1243 })?;
1244 let report = self
1245 .inner
1246 .tenant
1247 .write()
1248 .forget_document_as(self.inner.audit_principal.clone(), doc_id)
1249 .await
1250 .map_err(solo_to_mcp)?;
1251 let body = serde_json::to_string_pretty(&report).unwrap_or_else(|_| String::new());
1252 Ok(CallToolResult::success(vec![Content::text(body)]))
1253 }
1254}
1255
1256#[cfg(test)]
1257mod dispatch_tests {
1258 use super::*;
1270 use serde_json::json;
1271 use solo_core::VectorIndex;
1272 use solo_storage::test_support::StubVectorIndex;
1273 use solo_storage::{
1274 EmbedderConfig, IdentityConfig, KeyMaterial, ReaderPool, SoloConfig,
1275 StubEmbedder, TenantHandle, TenantRegistry, WriterActor, WriterSpawn,
1276 };
1277 use std::sync::Arc as StdArc;
1278
1279 fn fake_config(dim: u32) -> SoloConfig {
1280 SoloConfig {
1281 schema_version: 1,
1282 salt_hex: "00000000000000000000000000000000".to_string(),
1283 embedder: EmbedderConfig {
1284 name: "stub".to_string(),
1285 version: "v1".to_string(),
1286 dim,
1287 dtype: "f32".to_string(),
1288 },
1289 identity: IdentityConfig::default(),
1290 documents: solo_storage::DocumentConfig::default(),
1291 auth: None,
1292 audit: solo_storage::AuditSettings::default(),
1293 redaction: solo_storage::RedactionConfig::default(),
1294 }
1295 }
1296
1297 struct Harness {
1298 server: SoloMcpServer,
1299 _tmp: tempfile::TempDir,
1300 write_handle_extra: Option<solo_storage::WriteHandle>,
1301 join: Option<std::thread::JoinHandle<()>>,
1302 }
1303
1304 impl Harness {
1305 fn new(runtime: &tokio::runtime::Runtime) -> Self {
1306 let tmp = tempfile::TempDir::new().unwrap();
1307 let dim = 16usize;
1308 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1309 let embedder: StdArc<dyn solo_core::Embedder> = StdArc::new(StubEmbedder::new("stub", "v1", dim));
1310
1311 let conn = solo_storage::test_support::open_test_db_at(&tmp.path().join("test.db"));
1312 let WriterSpawn { handle, join } = WriterActor::spawn(conn, hnsw.clone());
1313
1314 let path = tmp.path().join("test.db");
1317 let pool: ReaderPool =
1318 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1319
1320 let tenant_id = solo_core::TenantId::default_tenant();
1321 let tenant_handle = StdArc::new(
1322 TenantHandle::from_parts_for_tests(
1323 tenant_id.clone(),
1324 fake_config(dim as u32),
1325 path.clone(),
1326 tmp.path().to_path_buf(),
1327 0, hnsw,
1329 embedder.clone(),
1330 handle.clone(),
1331 std::thread::spawn(|| {}),
1332 pool,
1333 ),
1334 );
1335 let key = KeyMaterial::from_bytes_for_tests([0u8; 32]);
1336 let registry = StdArc::new(TenantRegistry::for_tests_with_single_tenant(
1337 tmp.path().to_path_buf(),
1338 key,
1339 embedder,
1340 tenant_handle.clone(),
1341 ));
1342 let server = SoloMcpServer::new_for_tenant(registry, tenant_handle, Vec::new());
1343 Harness {
1344 server,
1345 _tmp: tmp,
1346 write_handle_extra: Some(handle),
1347 join: Some(join),
1348 }
1349 }
1350
1351 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1352 let join = self.join.take();
1358 let extra = self.write_handle_extra.take();
1359 runtime.block_on(async move {
1360 drop(extra);
1361 drop(self.server);
1362 drop(self._tmp);
1363 if let Some(join) = join {
1364 let (tx, rx) = std::sync::mpsc::channel();
1365 std::thread::spawn(move || {
1366 let _ = tx.send(join.join());
1367 });
1368 tokio::task::spawn_blocking(move || {
1369 rx.recv_timeout(std::time::Duration::from_secs(5))
1370 })
1371 .await
1372 .expect("blocking task")
1373 .expect("writer thread did not exit within 5s")
1374 .expect("writer thread panicked");
1375 }
1376 });
1377 }
1378 }
1379
1380 fn rt() -> tokio::runtime::Runtime {
1381 tokio::runtime::Builder::new_multi_thread()
1382 .worker_threads(2)
1383 .enable_all()
1384 .build()
1385 .unwrap()
1386 }
1387
1388 fn first_text(r: &rmcp::model::CallToolResult) -> String {
1393 let first = r.content.first().expect("at least one content item");
1394 let v = serde_json::to_value(first).expect("content serialises");
1395 v.get("text")
1396 .and_then(|t| t.as_str())
1397 .map(|s| s.to_string())
1398 .unwrap_or_else(|| format!("{v}"))
1399 }
1400
1401 #[test]
1402 fn tools_list_returns_thirteen_canonical_tools() {
1403 let runtime = rt();
1404 let h = Harness::new(&runtime);
1405 let tools = h.server.dispatch_list_tools();
1406 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1407 assert_eq!(
1408 names,
1409 vec![
1410 "memory_remember",
1411 "memory_recall",
1412 "memory_forget",
1413 "memory_inspect",
1414 "memory_themes",
1416 "memory_facts_about",
1417 "memory_contradictions",
1418 "memory_inspect_cluster",
1420 "memory_ingest_document",
1422 "memory_search_docs",
1423 "memory_inspect_document",
1424 "memory_list_documents",
1425 "memory_forget_document",
1426 ]
1427 );
1428 for t in &tools {
1429 assert!(!t.description.is_empty(), "{} description empty", t.name);
1430 let _schema = t.schema_as_json_value();
1431 }
1438 h.shutdown(&runtime);
1439 }
1440
1441 #[test]
1442 fn themes_returns_json_array_on_empty_db() {
1443 let runtime = rt();
1444 let h = Harness::new(&runtime);
1445 runtime.block_on(async {
1446 let r = h
1447 .server
1448 .dispatch_tool("memory_themes", json!({}))
1449 .await
1450 .expect("themes succeeds");
1451 let text = first_text(&r);
1452 let v: serde_json::Value =
1454 serde_json::from_str(&text).expect("parses as json");
1455 assert!(v.is_array(), "expected array, got: {text}");
1456 assert_eq!(v.as_array().unwrap().len(), 0);
1457 });
1458 h.shutdown(&runtime);
1459 }
1460
1461 #[test]
1462 fn themes_passes_through_window_and_limit_args() {
1463 let runtime = rt();
1464 let h = Harness::new(&runtime);
1465 runtime.block_on(async {
1466 let r = h
1468 .server
1469 .dispatch_tool(
1470 "memory_themes",
1471 json!({ "window_days": 7, "limit": 20 }),
1472 )
1473 .await
1474 .expect("themes with args succeeds");
1475 let text = first_text(&r);
1476 let v: serde_json::Value =
1477 serde_json::from_str(&text).expect("parses as json");
1478 assert!(v.is_array());
1479 });
1480 h.shutdown(&runtime);
1481 }
1482
1483 #[test]
1484 fn facts_about_rejects_empty_subject() {
1485 let runtime = rt();
1486 let h = Harness::new(&runtime);
1487 runtime.block_on(async {
1488 let err = h
1489 .server
1490 .dispatch_tool(
1491 "memory_facts_about",
1492 json!({ "subject": " " }),
1493 )
1494 .await
1495 .expect_err("empty subject must error");
1496 let s = format!("{err:?}");
1499 assert!(
1500 s.to_lowercase().contains("subject")
1501 || s.to_lowercase().contains("invalid"),
1502 "got: {s}"
1503 );
1504 });
1505 h.shutdown(&runtime);
1506 }
1507
1508 #[test]
1509 fn facts_about_returns_array_for_unknown_subject() {
1510 let runtime = rt();
1511 let h = Harness::new(&runtime);
1512 runtime.block_on(async {
1513 let r = h
1514 .server
1515 .dispatch_tool(
1516 "memory_facts_about",
1517 json!({ "subject": "NobodyKnowsThisSubject" }),
1518 )
1519 .await
1520 .expect("facts_about with unknown subject succeeds");
1521 let text = first_text(&r);
1522 let v: serde_json::Value =
1523 serde_json::from_str(&text).expect("parses as json");
1524 assert_eq!(v.as_array().unwrap().len(), 0);
1525 });
1526 h.shutdown(&runtime);
1527 }
1528
1529 #[test]
1530 fn facts_about_accepts_include_as_object_arg() {
1531 let runtime = rt();
1539 let h = Harness::new(&runtime);
1540 runtime.block_on(async {
1541 let r = h
1543 .server
1544 .dispatch_tool(
1545 "memory_facts_about",
1546 json!({ "subject": "Maya", "include_as_object": true }),
1547 )
1548 .await
1549 .expect("dispatch with include_as_object=true succeeds");
1550 let v: serde_json::Value = serde_json::from_str(&first_text(&r))
1551 .expect("parses as json");
1552 assert_eq!(v.as_array().unwrap().len(), 0);
1553
1554 let r = h
1556 .server
1557 .dispatch_tool(
1558 "memory_facts_about",
1559 json!({ "subject": "Maya" }),
1560 )
1561 .await
1562 .expect("dispatch without include_as_object succeeds (default false)");
1563 let v: serde_json::Value = serde_json::from_str(&first_text(&r))
1564 .expect("parses as json");
1565 assert_eq!(v.as_array().unwrap().len(), 0);
1566 });
1567 h.shutdown(&runtime);
1568 }
1569
1570 #[test]
1571 fn contradictions_returns_json_array_on_empty_db() {
1572 let runtime = rt();
1573 let h = Harness::new(&runtime);
1574 runtime.block_on(async {
1575 let r = h
1576 .server
1577 .dispatch_tool("memory_contradictions", json!({}))
1578 .await
1579 .expect("contradictions succeeds");
1580 let text = first_text(&r);
1581 let v: serde_json::Value =
1582 serde_json::from_str(&text).expect("parses as json");
1583 assert!(v.is_array());
1584 assert_eq!(v.as_array().unwrap().len(), 0);
1585 });
1586 h.shutdown(&runtime);
1587 }
1588
1589 #[test]
1590 fn remember_then_recall_round_trip() {
1591 let runtime = rt();
1592 let h = Harness::new(&runtime);
1593 runtime.block_on(async {
1599 let r = h
1600 .server
1601 .dispatch_tool("memory_remember", json!({ "content": "the cat sat on the mat" }))
1602 .await
1603 .expect("remember succeeds");
1604 let text = first_text(&r);
1605 assert!(text.starts_with("remembered "), "got: {text}");
1606
1607 let r = h
1608 .server
1609 .dispatch_tool(
1610 "memory_recall",
1611 json!({ "query": "the cat sat on the mat", "limit": 5 }),
1612 )
1613 .await
1614 .expect("recall succeeds");
1615 let text = first_text(&r);
1616 assert!(text.contains("the cat sat on the mat"), "got: {text}");
1617 });
1618 h.shutdown(&runtime);
1619 }
1620
1621 #[test]
1622 fn forget_excludes_row_from_subsequent_recall() {
1623 let runtime = rt();
1624 let h = Harness::new(&runtime);
1625
1626 runtime.block_on(async {
1627 let r = h
1628 .server
1629 .dispatch_tool("memory_remember", json!({ "content": "to be forgotten" }))
1630 .await
1631 .unwrap();
1632 let text = first_text(&r);
1633 let mid = text.strip_prefix("remembered ").unwrap().to_string();
1634
1635 h.server
1636 .dispatch_tool(
1637 "memory_forget",
1638 json!({ "memory_id": mid, "reason": "test" }),
1639 )
1640 .await
1641 .expect("forget succeeds");
1642
1643 let r = h
1644 .server
1645 .dispatch_tool(
1646 "memory_recall",
1647 json!({ "query": "to be forgotten", "limit": 5 }),
1648 )
1649 .await
1650 .unwrap();
1651 let text = first_text(&r);
1652 assert!(
1653 !text.contains(r#""content": "to be forgotten""#),
1654 "forgotten row should be excluded; got: {text}"
1655 );
1656 });
1657 h.shutdown(&runtime);
1658 }
1659
1660 #[test]
1661 fn empty_remember_returns_invalid_params() {
1662 let runtime = rt();
1663 let h = Harness::new(&runtime);
1664 runtime.block_on(async {
1665 let err = h
1666 .server
1667 .dispatch_tool("memory_remember", json!({ "content": "" }))
1668 .await
1669 .unwrap_err();
1670 assert!(format!("{err:?}").contains("must not be empty"));
1671 });
1672 h.shutdown(&runtime);
1673 }
1674
1675 #[test]
1676 fn empty_recall_query_returns_invalid_params() {
1677 let runtime = rt();
1678 let h = Harness::new(&runtime);
1679 runtime.block_on(async {
1680 let err = h
1681 .server
1682 .dispatch_tool("memory_recall", json!({ "query": " " }))
1683 .await
1684 .unwrap_err();
1685 assert!(format!("{err:?}").contains("must not be empty"));
1686 });
1687 h.shutdown(&runtime);
1688 }
1689
1690 #[test]
1691 fn inspect_with_invalid_id_returns_invalid_params() {
1692 let runtime = rt();
1693 let h = Harness::new(&runtime);
1694 runtime.block_on(async {
1695 let err = h
1696 .server
1697 .dispatch_tool("memory_inspect", json!({ "memory_id": "not-a-uuid" }))
1698 .await
1699 .unwrap_err();
1700 assert!(format!("{err:?}").contains("invalid memory_id"));
1701 });
1702 h.shutdown(&runtime);
1703 }
1704
1705 #[test]
1706 fn forget_unknown_id_returns_invalid_params() {
1707 let runtime = rt();
1708 let h = Harness::new(&runtime);
1709 runtime.block_on(async {
1710 let err = h
1714 .server
1715 .dispatch_tool(
1716 "memory_forget",
1717 json!({ "memory_id": "00000000-0000-7000-8000-000000000000" }),
1718 )
1719 .await
1720 .unwrap_err();
1721 assert!(format!("{err:?}").contains("not found"));
1722 });
1723 h.shutdown(&runtime);
1724 }
1725
1726 #[test]
1727 fn unknown_tool_name_returns_invalid_params() {
1728 let runtime = rt();
1729 let h = Harness::new(&runtime);
1730 runtime.block_on(async {
1731 let err = h
1732 .server
1733 .dispatch_tool("memory.summon", json!({}))
1734 .await
1735 .unwrap_err();
1736 assert!(format!("{err:?}").contains("unknown tool"));
1737 });
1738 h.shutdown(&runtime);
1739 }
1740
1741 #[test]
1776 fn tool_names_match_cross_provider_regex() {
1777 fn passes_anthropic(name: &str) -> bool {
1779 let len = name.len();
1780 if !(1..=64).contains(&len) {
1781 return false;
1782 }
1783 name.chars()
1784 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1785 }
1786
1787 fn passes_openai(name: &str) -> bool {
1790 let len = name.len();
1791 if !(1..=64).contains(&len) {
1792 return false;
1793 }
1794 let mut chars = name.chars();
1795 let first = match chars.next() {
1796 Some(c) => c,
1797 None => return false,
1798 };
1799 if !(first.is_ascii_alphabetic() || first == '_') {
1800 return false;
1801 }
1802 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1803 }
1804
1805 fn passes_gemini(name: &str) -> bool {
1810 let len = name.len();
1811 if !(1..=63).contains(&len) {
1812 return false;
1813 }
1814 let mut chars = name.chars();
1815 let first = match chars.next() {
1816 Some(c) => c,
1817 None => return false,
1818 };
1819 if !(first.is_ascii_alphabetic() || first == '_') {
1820 return false;
1821 }
1822 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1823 }
1824
1825 let tools = build_tools();
1826 assert_eq!(
1827 tools.len(),
1828 13,
1829 "expected 13 tools in v0.7.0 (8 v0.5.x + 5 document tools)"
1830 );
1831 let tool_name_strings: Vec<String> =
1833 tools.iter().map(|t| t.name.to_string()).collect();
1834 let public_names: Vec<String> =
1835 super::tool_names().iter().map(|s| s.to_string()).collect();
1836 assert_eq!(
1837 tool_name_strings, public_names,
1838 "tool_names() drifted from build_tools() — keep them in sync"
1839 );
1840
1841 for t in tools {
1842 assert!(
1843 passes_anthropic(&t.name),
1844 "tool name {:?} fails Anthropic regex \
1845 ^[a-zA-Z0-9_-]{{1,64}}$ — see v0.3 lesson #8",
1846 t.name
1847 );
1848 assert!(
1849 passes_openai(&t.name),
1850 "tool name {:?} fails OpenAI function-calling regex \
1851 ^[a-zA-Z_][a-zA-Z0-9_-]*$ (len ≤ 64)",
1852 t.name
1853 );
1854 assert!(
1855 passes_gemini(&t.name),
1856 "tool name {:?} fails Gemini function-calling regex \
1857 ^[a-zA-Z_][a-zA-Z0-9_]*$ (len ≤ 63, strict)",
1858 t.name
1859 );
1860 }
1861 }
1862
1863 #[test]
1880 fn tool_descriptions_avoid_internal_jargon() {
1881 const FORBIDDEN: &[&str] = &[
1885 "SPO",
1886 "Steward",
1887 "Steward-flagged",
1888 "LEFT JOIN",
1889 "candidate pair",
1890 "candidate_pair",
1891 "tagged_with",
1892 ];
1893
1894 fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
1895 haystack.to_lowercase().contains(&needle.to_lowercase())
1896 }
1897
1898 for t in build_tools() {
1900 for term in FORBIDDEN {
1901 assert!(
1902 !contains_case_insensitive(&t.description, term),
1903 "tool {:?} description contains forbidden jargon \
1904 {:?} — rewrite in plain English (see v0.5.0 \
1905 Priority 4)",
1906 t.name,
1907 term,
1908 );
1909 }
1910 }
1911
1912 let server_info = harness_server_info();
1915 let instructions = server_info
1916 .instructions
1917 .as_deref()
1918 .expect("get_info() must set instructions");
1919 for term in FORBIDDEN {
1920 assert!(
1921 !contains_case_insensitive(instructions, term),
1922 "get_info().instructions contains forbidden jargon \
1923 {:?} — rewrite in plain English",
1924 term,
1925 );
1926 }
1927 }
1928
1929 fn harness_server_info() -> rmcp::model::ServerInfo {
1936 let runtime = rt();
1937 let h = Harness::new(&runtime);
1938 let info = ServerHandler::get_info(&h.server);
1939 h.shutdown(&runtime);
1940 info
1941 }
1942
1943 #[test]
1946 fn inspect_cluster_unknown_id_returns_invalid_params() {
1947 let runtime = rt();
1951 let h = Harness::new(&runtime);
1952 runtime.block_on(async {
1953 let err = h
1954 .server
1955 .dispatch_tool(
1956 "memory_inspect_cluster",
1957 json!({ "cluster_id": "no-such-cluster" }),
1958 )
1959 .await
1960 .expect_err("unknown cluster must error");
1961 let s = format!("{err:?}");
1962 assert!(
1963 s.contains("no-such-cluster") || s.to_lowercase().contains("not found"),
1964 "expected error to mention the missing cluster id; got: {s}"
1965 );
1966 });
1967 h.shutdown(&runtime);
1968 }
1969
1970 #[test]
1971 fn inspect_cluster_rejects_empty_id() {
1972 let runtime = rt();
1973 let h = Harness::new(&runtime);
1974 runtime.block_on(async {
1975 let err = h
1976 .server
1977 .dispatch_tool(
1978 "memory_inspect_cluster",
1979 json!({ "cluster_id": " " }),
1980 )
1981 .await
1982 .expect_err("blank cluster_id must error");
1983 let s = format!("{err:?}");
1984 assert!(
1985 s.to_lowercase().contains("cluster_id")
1986 || s.to_lowercase().contains("must not be empty"),
1987 "got: {s}"
1988 );
1989 });
1990 h.shutdown(&runtime);
1991 }
1992
1993 #[test]
2009 fn ingest_document_args_parse_with_required_path() {
2010 let v: IngestDocumentArgs =
2011 serde_json::from_value(json!({ "path": "/tmp/notes.md" })).expect("parses");
2012 assert_eq!(v.path, "/tmp/notes.md");
2013 let err = serde_json::from_value::<IngestDocumentArgs>(json!({})).unwrap_err();
2015 assert!(format!("{err}").contains("path"));
2016 }
2017
2018 #[test]
2019 fn search_docs_args_parse_with_default_limit() {
2020 let v: SearchDocsArgs =
2021 serde_json::from_value(json!({ "query": "backups" })).expect("parses");
2022 assert_eq!(v.query, "backups");
2023 assert_eq!(v.limit, 5, "default limit must be 5");
2024 let v: SearchDocsArgs =
2025 serde_json::from_value(json!({ "query": "backups", "limit": 20 })).expect("parses");
2026 assert_eq!(v.limit, 20);
2027 }
2028
2029 #[test]
2030 fn inspect_document_args_parse_with_required_doc_id() {
2031 let v: InspectDocumentArgs =
2032 serde_json::from_value(json!({ "doc_id": "abc" })).expect("parses");
2033 assert_eq!(v.doc_id, "abc");
2034 let err = serde_json::from_value::<InspectDocumentArgs>(json!({})).unwrap_err();
2035 assert!(format!("{err}").contains("doc_id"));
2036 }
2037
2038 #[test]
2039 fn list_documents_args_parse_with_all_defaults() {
2040 let v: ListDocumentsArgs = serde_json::from_value(json!({})).expect("parses");
2041 assert_eq!(v.limit, 20, "default limit must be 20");
2042 assert_eq!(v.offset, 0, "default offset must be 0");
2043 assert!(!v.include_forgotten, "default include_forgotten must be false");
2044 let v: ListDocumentsArgs = serde_json::from_value(
2045 json!({ "limit": 5, "offset": 10, "include_forgotten": true }),
2046 )
2047 .expect("parses");
2048 assert_eq!(v.limit, 5);
2049 assert_eq!(v.offset, 10);
2050 assert!(v.include_forgotten);
2051 }
2052
2053 #[test]
2054 fn forget_document_args_parse_with_required_doc_id() {
2055 let v: ForgetDocumentArgs =
2056 serde_json::from_value(json!({ "doc_id": "abc" })).expect("parses");
2057 assert_eq!(v.doc_id, "abc");
2058 let err = serde_json::from_value::<ForgetDocumentArgs>(json!({})).unwrap_err();
2059 assert!(format!("{err}").contains("doc_id"));
2060 }
2061
2062 #[test]
2063 fn ingest_document_rejects_empty_path() {
2064 let runtime = rt();
2067 let h = Harness::new(&runtime);
2068 runtime.block_on(async {
2069 let err = h
2070 .server
2071 .dispatch_tool("memory_ingest_document", json!({ "path": "" }))
2072 .await
2073 .expect_err("empty path must error");
2074 let s = format!("{err:?}");
2075 assert!(
2076 s.to_lowercase().contains("path")
2077 || s.to_lowercase().contains("must not be empty"),
2078 "got: {s}"
2079 );
2080 });
2081 h.shutdown(&runtime);
2082 }
2083
2084 #[test]
2085 fn search_docs_rejects_empty_query() {
2086 let runtime = rt();
2089 let h = Harness::new(&runtime);
2090 runtime.block_on(async {
2091 let err = h
2092 .server
2093 .dispatch_tool("memory_search_docs", json!({ "query": " " }))
2094 .await
2095 .expect_err("empty query must error");
2096 let s = format!("{err:?}");
2097 assert!(
2098 s.to_lowercase().contains("must not be empty")
2099 || s.to_lowercase().contains("invalid"),
2100 "got: {s}"
2101 );
2102 });
2103 h.shutdown(&runtime);
2104 }
2105
2106 #[test]
2107 fn inspect_document_unknown_id_returns_invalid_params() {
2108 let runtime = rt();
2111 let h = Harness::new(&runtime);
2112 runtime.block_on(async {
2113 let err = h
2114 .server
2115 .dispatch_tool(
2116 "memory_inspect_document",
2117 json!({ "doc_id": "00000000-0000-7000-8000-000000000000" }),
2118 )
2119 .await
2120 .expect_err("unknown doc must error");
2121 let s = format!("{err:?}");
2122 assert!(
2123 s.to_lowercase().contains("not found"),
2124 "expected 'not found' message; got: {s}"
2125 );
2126 });
2127 h.shutdown(&runtime);
2128 }
2129
2130 #[test]
2131 fn inspect_document_rejects_malformed_id() {
2132 let runtime = rt();
2133 let h = Harness::new(&runtime);
2134 runtime.block_on(async {
2135 let err = h
2136 .server
2137 .dispatch_tool(
2138 "memory_inspect_document",
2139 json!({ "doc_id": "not-a-uuid" }),
2140 )
2141 .await
2142 .expect_err("malformed doc_id must error");
2143 let s = format!("{err:?}");
2144 assert!(s.contains("invalid doc_id"), "got: {s}");
2145 });
2146 h.shutdown(&runtime);
2147 }
2148
2149 #[test]
2150 fn list_documents_returns_empty_array_on_empty_db() {
2151 let runtime = rt();
2152 let h = Harness::new(&runtime);
2153 runtime.block_on(async {
2154 let r = h
2155 .server
2156 .dispatch_tool("memory_list_documents", json!({}))
2157 .await
2158 .expect("list succeeds");
2159 let text = first_text(&r);
2160 let v: serde_json::Value =
2161 serde_json::from_str(&text).expect("parses as json");
2162 assert!(v.is_array(), "expected array, got: {text}");
2163 assert_eq!(v.as_array().unwrap().len(), 0);
2164 });
2165 h.shutdown(&runtime);
2166 }
2167
2168 #[test]
2169 fn list_documents_passes_through_limit_offset_include_args() {
2170 let runtime = rt();
2171 let h = Harness::new(&runtime);
2172 runtime.block_on(async {
2173 let r = h
2174 .server
2175 .dispatch_tool(
2176 "memory_list_documents",
2177 json!({ "limit": 5, "offset": 10, "include_forgotten": true }),
2178 )
2179 .await
2180 .expect("list with args succeeds");
2181 let text = first_text(&r);
2182 let v: serde_json::Value =
2183 serde_json::from_str(&text).expect("parses as json");
2184 assert!(v.is_array());
2185 });
2186 h.shutdown(&runtime);
2187 }
2188
2189 #[test]
2190 fn forget_document_rejects_malformed_id() {
2191 let runtime = rt();
2192 let h = Harness::new(&runtime);
2193 runtime.block_on(async {
2194 let err = h
2195 .server
2196 .dispatch_tool(
2197 "memory_forget_document",
2198 json!({ "doc_id": "not-a-uuid" }),
2199 )
2200 .await
2201 .expect_err("malformed doc_id must error");
2202 let s = format!("{err:?}");
2203 assert!(s.contains("invalid doc_id"), "got: {s}");
2204 });
2205 h.shutdown(&runtime);
2206 }
2207}
2208
2209#[cfg(test)]
2220mod principal_extraction_tests {
2221 use super::*;
2222 use std::sync::Mutex;
2223
2224 static ENV_LOCK: Mutex<()> = Mutex::new(());
2228
2229 struct EnvGuard;
2232 impl Drop for EnvGuard {
2233 fn drop(&mut self) {
2234 unsafe { std::env::remove_var(ENV_MCP_PRINCIPAL_TOKEN) };
2236 }
2237 }
2238
2239 fn set_principal_env(val: &str) -> EnvGuard {
2240 unsafe { std::env::set_var(ENV_MCP_PRINCIPAL_TOKEN, val) };
2242 EnvGuard
2243 }
2244
2245 fn clear_principal_env() -> EnvGuard {
2246 unsafe { std::env::remove_var(ENV_MCP_PRINCIPAL_TOKEN) };
2248 EnvGuard
2249 }
2250
2251 #[test]
2254 fn stdio_env_var_resolves_to_principal() {
2255 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2256 let _g = set_principal_env("alice-token");
2257 let resolved = resolve_mcp_principal(None);
2258 assert_eq!(resolved.as_deref(), Some("alice-token"));
2259 }
2260
2261 #[test]
2264 fn stdio_no_env_var_resolves_to_none() {
2265 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2266 let _g = clear_principal_env();
2267 assert_eq!(resolve_mcp_principal(None), None);
2268 }
2269
2270 #[test]
2274 fn stdio_whitespace_env_var_resolves_to_none() {
2275 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2276 let _g = set_principal_env(" \t ");
2277 assert_eq!(resolve_mcp_principal(None), None);
2278 }
2279
2280 #[test]
2283 fn http_header_resolves_to_bearer_token_principal() {
2284 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2285 let _g = clear_principal_env();
2286 let resolved = resolve_mcp_principal(Some("Bearer api-token-xyz"));
2287 assert_eq!(resolved.as_deref(), Some("api-token-xyz"));
2288 }
2289
2290 #[test]
2294 fn http_header_beats_env_var() {
2295 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2296 let _g = set_principal_env("env-token");
2297 let resolved = resolve_mcp_principal(Some("Bearer header-token"));
2298 assert_eq!(
2299 resolved.as_deref(),
2300 Some("header-token"),
2301 "header MUST win over env var per documented precedence"
2302 );
2303 }
2304
2305 #[test]
2308 fn http_malformed_header_falls_through_to_env() {
2309 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2310 let _g = set_principal_env("env-fallback");
2311 let resolved = resolve_mcp_principal(Some("Basic dXNlcjpwYXNz"));
2312 assert_eq!(resolved.as_deref(), Some("env-fallback"));
2313 }
2314
2315 #[test]
2320 fn http_empty_bearer_header_falls_through_to_env() {
2321 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2322 let _g = set_principal_env("env-fallback");
2323 let resolved = resolve_mcp_principal(Some("Bearer "));
2324 assert_eq!(resolved.as_deref(), Some("env-fallback"));
2325 }
2326
2327 #[test]
2333 fn stable_across_multiple_resolutions() {
2334 let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
2335 let _g = set_principal_env("stable-token");
2336 for _ in 0..5 {
2337 assert_eq!(
2338 resolve_mcp_principal(None).as_deref(),
2339 Some("stable-token")
2340 );
2341 }
2342 }
2343}
2344
2345