1use std::sync::Arc;
44
45use rmcp::handler::server::ServerHandler;
46use rmcp::model::{
47 CallToolRequestParam, CallToolResult, Content, Implementation, ListToolsResult,
48 PaginatedRequestParam, ProtocolVersion, ServerCapabilities, ServerInfo, Tool,
49 ToolsCapability,
50};
51use rmcp::service::{RequestContext, RoleServer};
52use rmcp::{Error as McpError, ServiceExt};
53use serde::{Deserialize, Serialize};
54use solo_core::{
55 Confidence, Embedder, EncodingContext, Episode, MemoryId, Tier,
56 VectorIndex,
57};
58use solo_storage::{ReaderPool, WriteHandle};
59use std::str::FromStr;
60
61#[derive(Clone)]
63pub struct SoloMcpServer {
64 inner: Arc<Inner>,
65}
66
67struct Inner {
68 write: WriteHandle,
69 pool: ReaderPool,
70 embedder: Arc<dyn Embedder>,
71 hnsw: Arc<dyn VectorIndex + Send + Sync>,
72 user_aliases: Vec<String>,
78}
79
80impl SoloMcpServer {
81 pub fn new(
85 write: WriteHandle,
86 pool: ReaderPool,
87 embedder: Arc<dyn Embedder>,
88 hnsw: Arc<dyn VectorIndex + Send + Sync>,
89 ) -> Self {
90 Self::new_with_identity(write, pool, embedder, hnsw, Vec::new())
91 }
92
93 pub fn new_with_identity(
97 write: WriteHandle,
98 pool: ReaderPool,
99 embedder: Arc<dyn Embedder>,
100 hnsw: Arc<dyn VectorIndex + Send + Sync>,
101 user_aliases: Vec<String>,
102 ) -> Self {
103 Self {
104 inner: Arc::new(Inner {
105 write,
106 pool,
107 embedder,
108 hnsw,
109 user_aliases,
110 }),
111 }
112 }
113}
114
115pub async fn serve_stdio(server: SoloMcpServer) -> anyhow::Result<()> {
118 use rmcp::transport::io::stdio;
119 let (stdin, stdout) = stdio();
120 let running = server.serve((stdin, stdout)).await?;
121 running.waiting().await?;
122 Ok(())
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct RememberArgs {
131 pub content: String,
132 #[serde(default)]
133 pub source_type: Option<String>,
134 #[serde(default)]
135 pub source_id: Option<String>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct RecallArgs {
140 pub query: String,
141 #[serde(default = "default_limit")]
142 pub limit: usize,
143}
144
145fn default_limit() -> usize {
146 5
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct ForgetArgs {
151 pub memory_id: String,
152 #[serde(default = "default_forget_reason")]
153 pub reason: String,
154}
155
156fn default_forget_reason() -> String {
157 "user-initiated via MCP".into()
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct InspectArgs {
162 pub memory_id: String,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct ThemesArgs {
172 #[serde(default)]
176 pub window_days: Option<i64>,
177 #[serde(default = "default_limit")]
178 pub limit: usize,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct FactsAboutArgs {
183 pub subject: String,
186 #[serde(default)]
187 pub predicate: Option<String>,
188 #[serde(default)]
189 pub since_ms: Option<i64>,
190 #[serde(default)]
191 pub until_ms: Option<i64>,
192 #[serde(default = "default_limit")]
193 pub limit: usize,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct ContradictionsArgs {
198 #[serde(default = "default_limit")]
199 pub limit: usize,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct InspectClusterArgs {
207 pub cluster_id: String,
208 #[serde(default)]
213 pub full_content: bool,
214}
215
216impl ServerHandler for SoloMcpServer {
221 fn get_info(&self) -> ServerInfo {
222 ServerInfo {
223 protocol_version: ProtocolVersion::default(),
224 capabilities: ServerCapabilities {
225 tools: Some(ToolsCapability {
226 list_changed: Some(false),
227 }),
228 ..Default::default()
229 },
230 server_info: Implementation {
231 name: "solo".into(),
232 version: env!("CARGO_PKG_VERSION").into(),
233 },
234 instructions: Some(
235 "Solo gives you persistent memory across conversations \
236 with this user — what they've told you before, the \
237 people and projects in their life, and where their \
238 stated beliefs have shifted. Reach for these tools \
239 whenever the user references something from earlier \
240 (\"like I mentioned\", \"the project I'm working \
241 on\", \"my friend Alex\") or asks a question that \
242 hinges on personal context you don't have in the \
243 current chat. \
244 \n\nTools to write or look up specific moments: \
245 memory_remember (save something worth keeping), \
246 memory_recall (search past conversations by topic), \
247 memory_inspect (show one saved item by id), \
248 memory_forget (delete one saved item). \
249 \n\nTools for the bigger picture (populated as the \
250 user uses Solo over time): memory_themes (recent \
251 topics they've been thinking about), \
252 memory_facts_about (what you know about a person, \
253 project, or place — \"what do you know about \
254 Alex?\"), memory_contradictions (places where the \
255 user has said two things that disagree — surface \
256 these before answering), memory_inspect_cluster \
257 (the raw conversations behind one summary)."
258 .into(),
259 ),
260 }
261 }
262
263 async fn list_tools(
264 &self,
265 _request: PaginatedRequestParam,
266 _context: RequestContext<RoleServer>,
267 ) -> std::result::Result<ListToolsResult, McpError> {
268 Ok(ListToolsResult {
269 tools: build_tools(),
270 next_cursor: None,
271 })
272 }
273
274 async fn call_tool(
275 &self,
276 request: CallToolRequestParam,
277 _context: RequestContext<RoleServer>,
278 ) -> std::result::Result<CallToolResult, McpError> {
279 let CallToolRequestParam { name, arguments } = request;
280 let args_value = serde_json::Value::Object(arguments.unwrap_or_default());
281 self.dispatch_tool(&name, args_value).await
282 }
283}
284
285impl SoloMcpServer {
286 pub async fn dispatch_tool(
292 &self,
293 name: &str,
294 args_value: serde_json::Value,
295 ) -> std::result::Result<CallToolResult, McpError> {
296 match name {
297 "memory_remember" => {
298 let args: RememberArgs = parse_args(&args_value)?;
299 self.handle_remember(args).await
300 }
301 "memory_recall" => {
302 let args: RecallArgs = parse_args(&args_value)?;
303 self.handle_recall(args).await
304 }
305 "memory_forget" => {
306 let args: ForgetArgs = parse_args(&args_value)?;
307 self.handle_forget(args).await
308 }
309 "memory_inspect" => {
310 let args: InspectArgs = parse_args(&args_value)?;
311 self.handle_inspect(args).await
312 }
313 "memory_themes" => {
314 let args: ThemesArgs = parse_args(&args_value)?;
315 self.handle_themes(args).await
316 }
317 "memory_facts_about" => {
318 let args: FactsAboutArgs = parse_args(&args_value)?;
319 self.handle_facts_about(args).await
320 }
321 "memory_contradictions" => {
322 let args: ContradictionsArgs = parse_args(&args_value)?;
323 self.handle_contradictions(args).await
324 }
325 "memory_inspect_cluster" => {
326 let args: InspectClusterArgs = parse_args(&args_value)?;
327 self.handle_inspect_cluster(args).await
328 }
329 other => Err(McpError::invalid_params(
330 format!("unknown tool `{other}`"),
331 None,
332 )),
333 }
334 }
335
336 pub fn dispatch_list_tools(&self) -> Vec<Tool> {
339 build_tools()
340 }
341}
342
343fn parse_args<T: serde::de::DeserializeOwned>(
344 v: &serde_json::Value,
345) -> std::result::Result<T, McpError> {
346 serde_json::from_value(v.clone()).map_err(|e| {
347 McpError::invalid_params(format!("invalid tool arguments: {e}"), None)
348 })
349}
350
351fn solo_to_mcp(e: solo_core::Error) -> McpError {
352 use solo_core::Error;
353 match e {
354 Error::NotFound(msg) => McpError::invalid_params(msg, None),
355 Error::InvalidInput(msg) => McpError::invalid_params(msg, None),
356 Error::Conflict(msg) => McpError::invalid_params(msg, None),
357 other => McpError::internal_error(other.to_string(), None),
358 }
359}
360
361fn build_tools() -> Vec<Tool> {
366 vec![
367 Tool::new(
368 "memory_remember",
369 "Save something the user has told you — a fact, a \
370 preference, a name, a date, a context — so you can pick \
371 it up next conversation. Use whenever the user mentions \
372 something they'd reasonably expect you to recall later \
373 (\"I just started at Quotient\", \"my partner is Maya\"). \
374 Returns the saved item's id.",
375 json_schema_object(serde_json::json!({
376 "type": "object",
377 "properties": {
378 "content": {
379 "type": "string",
380 "description": "The text to remember.",
381 },
382 "source_type": {
383 "type": "string",
384 "description": "Optional source-type tag (default: \"user_message\").",
385 },
386 "source_id": {
387 "type": "string",
388 "description": "Optional upstream id for traceability.",
389 },
390 },
391 "required": ["content"],
392 })),
393 ),
394 Tool::new(
395 "memory_recall",
396 "Search past conversations with this user by topic or \
397 phrase. Returns up to `limit` of the closest matches, \
398 best match first. Use when the user references \
399 something they said before (\"that book I told you \
400 about\", \"the bug we were debugging last week\"). \
401 Skips items the user has deleted.",
402 json_schema_object(serde_json::json!({
403 "type": "object",
404 "properties": {
405 "query": {
406 "type": "string",
407 "description": "The query text.",
408 },
409 "limit": {
410 "type": "integer",
411 "description": "Maximum results (default 5).",
412 "minimum": 1,
413 "maximum": 100,
414 },
415 },
416 "required": ["query"],
417 })),
418 ),
419 Tool::new(
420 "memory_forget",
421 "Delete one saved item by id. Use when the user asks you \
422 to forget something specific (\"forget that I said \
423 X\"). The item stops appearing in future recalls. \
424 Reversible only via backups.",
425 json_schema_object(serde_json::json!({
426 "type": "object",
427 "properties": {
428 "memory_id": {
429 "type": "string",
430 "description": "MemoryId to forget (UUID v7).",
431 },
432 "reason": {
433 "type": "string",
434 "description": "Optional free-form reason (logged, not yet persisted).",
435 },
436 },
437 "required": ["memory_id"],
438 })),
439 ),
440 Tool::new(
441 "memory_inspect",
442 "Show the full record for one saved item — when it was \
443 saved, where it came from, and the full text. Use after \
444 memory_recall when you want the complete content of a \
445 specific hit (recall results may be truncated).",
446 json_schema_object(serde_json::json!({
447 "type": "object",
448 "properties": {
449 "memory_id": {
450 "type": "string",
451 "description": "MemoryId to inspect (UUID v7).",
452 },
453 },
454 "required": ["memory_id"],
455 })),
456 ),
457 Tool::new(
461 "memory_themes",
462 "Recent topics the user has been thinking about. Use to \
463 orient yourself at the start of a conversation, or when \
464 the user asks \"what have I been up to\" / \"what was I \
465 working on last week\". Pass `window_days` to scope \
466 (e.g. 7 for last week); omit for all-time.",
467 json_schema_object(serde_json::json!({
468 "type": "object",
469 "properties": {
470 "window_days": {
471 "type": "integer",
472 "description": "Optional time window in days. Omit for unfiltered.",
473 "minimum": 1,
474 },
475 "limit": {
476 "type": "integer",
477 "description": "Maximum results (default 5).",
478 "minimum": 1,
479 "maximum": 100,
480 },
481 },
482 })),
483 ),
484 Tool::new(
485 "memory_facts_about",
486 "Look up what you remember about a person, project, or \
487 topic — names, dates, preferences, relationships. Use \
488 when the user asks \"what do you know about Alex?\", \
489 \"when did I start at Quotient?\", \"who is Maya?\", or \
490 whenever you need grounded facts about someone or \
491 something before answering. Subject is required (the \
492 person/place/thing you're asking about); narrow further \
493 with `predicate` (\"works_at\", \"lives_in\") or a date \
494 range. (Backed by subject-predicate-object triples \
495 distilled from past conversations.)",
496 json_schema_object(serde_json::json!({
497 "type": "object",
498 "properties": {
499 "subject": {
500 "type": "string",
501 "description": "Subject id to query (e.g. 'Sam').",
502 },
503 "predicate": {
504 "type": "string",
505 "description": "Optional predicate filter (e.g. 'works_at').",
506 },
507 "since_ms": {
508 "type": "integer",
509 "description": "Optional valid_from_ms lower bound (epoch ms).",
510 },
511 "until_ms": {
512 "type": "integer",
513 "description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through.",
514 },
515 "limit": {
516 "type": "integer",
517 "description": "Maximum results (default 5).",
518 "minimum": 1,
519 "maximum": 100,
520 },
521 },
522 "required": ["subject"],
523 })),
524 ),
525 Tool::new(
526 "memory_contradictions",
527 "Find places where the user's stated beliefs or facts \
528 disagree across conversations — flag disagreements \
529 before answering. Use whenever you're about to rely on \
530 a remembered fact that could have changed (jobs, \
531 relationships, preferences, opinions); a disagreement \
532 here means the user has told you both X and not-X over \
533 time and you should ask which is current instead of \
534 guessing. Each result shows both conflicting statements \
535 with the topic.",
536 json_schema_object(serde_json::json!({
537 "type": "object",
538 "properties": {
539 "limit": {
540 "type": "integer",
541 "description": "Maximum results (default 5).",
542 "minimum": 1,
543 "maximum": 100,
544 },
545 },
546 })),
547 ),
548 Tool::new(
549 "memory_inspect_cluster",
550 "Show the raw conversations behind one summary. Returns \
551 the one-line topic (the LLM-generated summary) and the \
552 source conversations the topic was built from. Use \
553 after memory_themes when the user asks \"show me the \
554 raw context behind this\" or \"why does Solo think \
555 that about cluster Y\". Source items are truncated to \
556 200 chars unless `full_content` is set.",
557 json_schema_object(serde_json::json!({
558 "type": "object",
559 "properties": {
560 "cluster_id": {
561 "type": "string",
562 "description": "Cluster id to inspect (from memory_themes hits).",
563 },
564 "full_content": {
565 "type": "boolean",
566 "description": "If true, episode content is returned verbatim. Default false (truncate to 200 chars + ellipsis).",
567 },
568 },
569 "required": ["cluster_id"],
570 })),
571 ),
572 ]
573}
574
575fn json_schema_object(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
576 match value {
577 serde_json::Value::Object(map) => map,
578 _ => panic!("json_schema_object: input must be an object"),
579 }
580}
581
582pub fn tool_names() -> Vec<&'static str> {
591 vec![
592 "memory_remember",
593 "memory_recall",
594 "memory_forget",
595 "memory_inspect",
596 "memory_themes",
597 "memory_facts_about",
598 "memory_contradictions",
599 "memory_inspect_cluster",
600 ]
601}
602
603impl SoloMcpServer {
608 async fn handle_remember(
609 &self,
610 args: RememberArgs,
611 ) -> std::result::Result<CallToolResult, McpError> {
612 let content = args.content.trim_end().to_string();
613 if content.is_empty() {
614 return Err(McpError::invalid_params(
615 "memory_remember: content must not be empty".to_string(),
616 None,
617 ));
618 }
619 let embedding: solo_core::Embedding = self
620 .inner
621 .embedder
622 .embed(&content)
623 .await
624 .map_err(solo_to_mcp)?;
625 let episode = Episode {
626 memory_id: MemoryId::new(),
627 ts_ms: chrono::Utc::now().timestamp_millis(),
628 source_type: args.source_type.unwrap_or_else(|| "user_message".into()),
629 source_id: args.source_id,
630 content,
631 encoding_context: EncodingContext::default(),
632 provenance: None,
633 confidence: Confidence::new(0.9).unwrap(),
634 strength: 0.5,
635 salience: 0.5,
636 tier: Tier::Hot,
637 };
638 let mid = self
639 .inner
640 .write
641 .remember(episode, embedding)
642 .await
643 .map_err(solo_to_mcp)?;
644 Ok(CallToolResult::success(vec![Content::text(format!(
645 "remembered {mid}"
646 ))]))
647 }
648
649 async fn handle_recall(
650 &self,
651 args: RecallArgs,
652 ) -> std::result::Result<CallToolResult, McpError> {
653 let result = solo_query::run_recall(
657 &self.inner.embedder,
658 &self.inner.hnsw,
659 &self.inner.pool,
660 &args.query,
661 args.limit,
662 )
663 .await
664 .map_err(solo_to_mcp)?;
665
666 if result.hits.is_empty() {
667 return Ok(CallToolResult::success(vec![Content::text(format!(
668 "no matches (index has {} vectors)",
669 result.index_len
670 ))]));
671 }
672 let body = serde_json::to_string_pretty(&result.hits).unwrap_or_else(|_| String::new());
673 Ok(CallToolResult::success(vec![Content::text(body)]))
674 }
675
676 async fn handle_forget(
677 &self,
678 args: ForgetArgs,
679 ) -> std::result::Result<CallToolResult, McpError> {
680 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
681 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
682 })?;
683 self.inner
684 .write
685 .forget(mid, args.reason)
686 .await
687 .map_err(solo_to_mcp)?;
688 Ok(CallToolResult::success(vec![Content::text(format!(
689 "forgotten {mid}"
690 ))]))
691 }
692
693 async fn handle_inspect(
694 &self,
695 args: InspectArgs,
696 ) -> std::result::Result<CallToolResult, McpError> {
697 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
698 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
699 })?;
700 let row = solo_query::inspect_one(&self.inner.pool, mid)
702 .await
703 .map_err(solo_to_mcp)?;
704 let body = serde_json::to_string_pretty(&row).unwrap_or_else(|_| String::new());
705 Ok(CallToolResult::success(vec![Content::text(body)]))
706 }
707
708 async fn handle_themes(
715 &self,
716 args: ThemesArgs,
717 ) -> std::result::Result<CallToolResult, McpError> {
718 let hits = solo_query::themes(
719 &self.inner.pool,
720 args.window_days,
721 args.limit,
722 )
723 .await
724 .map_err(solo_to_mcp)?;
725 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
726 Ok(CallToolResult::success(vec![Content::text(body)]))
727 }
728
729 async fn handle_facts_about(
730 &self,
731 args: FactsAboutArgs,
732 ) -> std::result::Result<CallToolResult, McpError> {
733 if args.subject.trim().is_empty() {
734 return Err(McpError::invalid_params(
735 "memory_facts_about: subject must not be empty".to_string(),
736 None,
737 ));
738 }
739 let hits = solo_query::facts_about(
740 &self.inner.pool,
741 &args.subject,
742 &self.inner.user_aliases,
743 args.predicate.as_deref(),
744 args.since_ms,
745 args.until_ms,
746 args.limit,
747 )
748 .await
749 .map_err(solo_to_mcp)?;
750 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
751 Ok(CallToolResult::success(vec![Content::text(body)]))
752 }
753
754 async fn handle_contradictions(
755 &self,
756 args: ContradictionsArgs,
757 ) -> std::result::Result<CallToolResult, McpError> {
758 let hits = solo_query::contradictions(&self.inner.pool, args.limit)
759 .await
760 .map_err(solo_to_mcp)?;
761 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
762 Ok(CallToolResult::success(vec![Content::text(body)]))
763 }
764
765 async fn handle_inspect_cluster(
766 &self,
767 args: InspectClusterArgs,
768 ) -> std::result::Result<CallToolResult, McpError> {
769 if args.cluster_id.trim().is_empty() {
770 return Err(McpError::invalid_params(
771 "memory_inspect_cluster: cluster_id must not be empty".to_string(),
772 None,
773 ));
774 }
775 let record = solo_query::inspect_cluster(
780 &self.inner.pool,
781 &args.cluster_id,
782 args.full_content,
783 )
784 .await
785 .map_err(solo_to_mcp)?;
786 let body = serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
787 Ok(CallToolResult::success(vec![Content::text(body)]))
788 }
789}
790
791#[cfg(test)]
792mod dispatch_tests {
793 use super::*;
805 use serde_json::json;
806 use solo_core::VectorIndex;
807 use solo_storage::test_support::StubVectorIndex;
808 use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
809 use std::sync::Arc as StdArc;
810
811 struct Harness {
812 server: SoloMcpServer,
813 _tmp: tempfile::TempDir,
814 write_handle_extra: Option<solo_storage::WriteHandle>,
815 join: Option<std::thread::JoinHandle<()>>,
816 }
817
818 impl Harness {
819 fn new(runtime: &tokio::runtime::Runtime) -> Self {
820 let tmp = tempfile::TempDir::new().unwrap();
821 let dim = 16usize;
822 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
823 let embedder: StdArc<dyn solo_core::Embedder> = StdArc::new(StubEmbedder::new("stub", "v1", dim));
824
825 let conn = solo_storage::test_support::open_test_db_at(&tmp.path().join("test.db"));
826 let WriterSpawn { handle, join } = WriterActor::spawn(conn, hnsw.clone());
827
828 let path = tmp.path().join("test.db");
831 let pool: ReaderPool =
832 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
833
834 let server = SoloMcpServer::new(handle.clone(), pool, embedder, hnsw);
835 Harness {
836 server,
837 _tmp: tmp,
838 write_handle_extra: Some(handle),
839 join: Some(join),
840 }
841 }
842
843 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
844 let join = self.join.take();
850 let extra = self.write_handle_extra.take();
851 runtime.block_on(async move {
852 drop(extra);
853 drop(self.server);
854 drop(self._tmp);
855 if let Some(join) = join {
856 let (tx, rx) = std::sync::mpsc::channel();
857 std::thread::spawn(move || {
858 let _ = tx.send(join.join());
859 });
860 tokio::task::spawn_blocking(move || {
861 rx.recv_timeout(std::time::Duration::from_secs(5))
862 })
863 .await
864 .expect("blocking task")
865 .expect("writer thread did not exit within 5s")
866 .expect("writer thread panicked");
867 }
868 });
869 }
870 }
871
872 fn rt() -> tokio::runtime::Runtime {
873 tokio::runtime::Builder::new_multi_thread()
874 .worker_threads(2)
875 .enable_all()
876 .build()
877 .unwrap()
878 }
879
880 fn first_text(r: &rmcp::model::CallToolResult) -> String {
885 let first = r.content.first().expect("at least one content item");
886 let v = serde_json::to_value(first).expect("content serialises");
887 v.get("text")
888 .and_then(|t| t.as_str())
889 .map(|s| s.to_string())
890 .unwrap_or_else(|| format!("{v}"))
891 }
892
893 #[test]
894 fn tools_list_returns_eight_canonical_tools() {
895 let runtime = rt();
896 let h = Harness::new(&runtime);
897 let tools = h.server.dispatch_list_tools();
898 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
899 assert_eq!(
900 names,
901 vec![
902 "memory_remember",
903 "memory_recall",
904 "memory_forget",
905 "memory_inspect",
906 "memory_themes",
908 "memory_facts_about",
909 "memory_contradictions",
910 "memory_inspect_cluster",
912 ]
913 );
914 for t in &tools {
915 assert!(!t.description.is_empty(), "{} description empty", t.name);
916 let _schema = t.schema_as_json_value();
917 }
924 h.shutdown(&runtime);
925 }
926
927 #[test]
928 fn themes_returns_json_array_on_empty_db() {
929 let runtime = rt();
930 let h = Harness::new(&runtime);
931 runtime.block_on(async {
932 let r = h
933 .server
934 .dispatch_tool("memory_themes", json!({}))
935 .await
936 .expect("themes succeeds");
937 let text = first_text(&r);
938 let v: serde_json::Value =
940 serde_json::from_str(&text).expect("parses as json");
941 assert!(v.is_array(), "expected array, got: {text}");
942 assert_eq!(v.as_array().unwrap().len(), 0);
943 });
944 h.shutdown(&runtime);
945 }
946
947 #[test]
948 fn themes_passes_through_window_and_limit_args() {
949 let runtime = rt();
950 let h = Harness::new(&runtime);
951 runtime.block_on(async {
952 let r = h
954 .server
955 .dispatch_tool(
956 "memory_themes",
957 json!({ "window_days": 7, "limit": 20 }),
958 )
959 .await
960 .expect("themes with args succeeds");
961 let text = first_text(&r);
962 let v: serde_json::Value =
963 serde_json::from_str(&text).expect("parses as json");
964 assert!(v.is_array());
965 });
966 h.shutdown(&runtime);
967 }
968
969 #[test]
970 fn facts_about_rejects_empty_subject() {
971 let runtime = rt();
972 let h = Harness::new(&runtime);
973 runtime.block_on(async {
974 let err = h
975 .server
976 .dispatch_tool(
977 "memory_facts_about",
978 json!({ "subject": " " }),
979 )
980 .await
981 .expect_err("empty subject must error");
982 let s = format!("{err:?}");
985 assert!(
986 s.to_lowercase().contains("subject")
987 || s.to_lowercase().contains("invalid"),
988 "got: {s}"
989 );
990 });
991 h.shutdown(&runtime);
992 }
993
994 #[test]
995 fn facts_about_returns_array_for_unknown_subject() {
996 let runtime = rt();
997 let h = Harness::new(&runtime);
998 runtime.block_on(async {
999 let r = h
1000 .server
1001 .dispatch_tool(
1002 "memory_facts_about",
1003 json!({ "subject": "NobodyKnowsThisSubject" }),
1004 )
1005 .await
1006 .expect("facts_about with unknown subject succeeds");
1007 let text = first_text(&r);
1008 let v: serde_json::Value =
1009 serde_json::from_str(&text).expect("parses as json");
1010 assert_eq!(v.as_array().unwrap().len(), 0);
1011 });
1012 h.shutdown(&runtime);
1013 }
1014
1015 #[test]
1016 fn contradictions_returns_json_array_on_empty_db() {
1017 let runtime = rt();
1018 let h = Harness::new(&runtime);
1019 runtime.block_on(async {
1020 let r = h
1021 .server
1022 .dispatch_tool("memory_contradictions", json!({}))
1023 .await
1024 .expect("contradictions succeeds");
1025 let text = first_text(&r);
1026 let v: serde_json::Value =
1027 serde_json::from_str(&text).expect("parses as json");
1028 assert!(v.is_array());
1029 assert_eq!(v.as_array().unwrap().len(), 0);
1030 });
1031 h.shutdown(&runtime);
1032 }
1033
1034 #[test]
1035 fn remember_then_recall_round_trip() {
1036 let runtime = rt();
1037 let h = Harness::new(&runtime);
1038 runtime.block_on(async {
1044 let r = h
1045 .server
1046 .dispatch_tool("memory_remember", json!({ "content": "the cat sat on the mat" }))
1047 .await
1048 .expect("remember succeeds");
1049 let text = first_text(&r);
1050 assert!(text.starts_with("remembered "), "got: {text}");
1051
1052 let r = h
1053 .server
1054 .dispatch_tool(
1055 "memory_recall",
1056 json!({ "query": "the cat sat on the mat", "limit": 5 }),
1057 )
1058 .await
1059 .expect("recall succeeds");
1060 let text = first_text(&r);
1061 assert!(text.contains("the cat sat on the mat"), "got: {text}");
1062 });
1063 h.shutdown(&runtime);
1064 }
1065
1066 #[test]
1067 fn forget_excludes_row_from_subsequent_recall() {
1068 let runtime = rt();
1069 let h = Harness::new(&runtime);
1070
1071 runtime.block_on(async {
1072 let r = h
1073 .server
1074 .dispatch_tool("memory_remember", json!({ "content": "to be forgotten" }))
1075 .await
1076 .unwrap();
1077 let text = first_text(&r);
1078 let mid = text.strip_prefix("remembered ").unwrap().to_string();
1079
1080 h.server
1081 .dispatch_tool(
1082 "memory_forget",
1083 json!({ "memory_id": mid, "reason": "test" }),
1084 )
1085 .await
1086 .expect("forget succeeds");
1087
1088 let r = h
1089 .server
1090 .dispatch_tool(
1091 "memory_recall",
1092 json!({ "query": "to be forgotten", "limit": 5 }),
1093 )
1094 .await
1095 .unwrap();
1096 let text = first_text(&r);
1097 assert!(
1098 !text.contains(r#""content": "to be forgotten""#),
1099 "forgotten row should be excluded; got: {text}"
1100 );
1101 });
1102 h.shutdown(&runtime);
1103 }
1104
1105 #[test]
1106 fn empty_remember_returns_invalid_params() {
1107 let runtime = rt();
1108 let h = Harness::new(&runtime);
1109 runtime.block_on(async {
1110 let err = h
1111 .server
1112 .dispatch_tool("memory_remember", json!({ "content": "" }))
1113 .await
1114 .unwrap_err();
1115 assert!(format!("{err:?}").contains("must not be empty"));
1116 });
1117 h.shutdown(&runtime);
1118 }
1119
1120 #[test]
1121 fn empty_recall_query_returns_invalid_params() {
1122 let runtime = rt();
1123 let h = Harness::new(&runtime);
1124 runtime.block_on(async {
1125 let err = h
1126 .server
1127 .dispatch_tool("memory_recall", json!({ "query": " " }))
1128 .await
1129 .unwrap_err();
1130 assert!(format!("{err:?}").contains("must not be empty"));
1131 });
1132 h.shutdown(&runtime);
1133 }
1134
1135 #[test]
1136 fn inspect_with_invalid_id_returns_invalid_params() {
1137 let runtime = rt();
1138 let h = Harness::new(&runtime);
1139 runtime.block_on(async {
1140 let err = h
1141 .server
1142 .dispatch_tool("memory_inspect", json!({ "memory_id": "not-a-uuid" }))
1143 .await
1144 .unwrap_err();
1145 assert!(format!("{err:?}").contains("invalid memory_id"));
1146 });
1147 h.shutdown(&runtime);
1148 }
1149
1150 #[test]
1151 fn forget_unknown_id_returns_invalid_params() {
1152 let runtime = rt();
1153 let h = Harness::new(&runtime);
1154 runtime.block_on(async {
1155 let err = h
1159 .server
1160 .dispatch_tool(
1161 "memory_forget",
1162 json!({ "memory_id": "00000000-0000-7000-8000-000000000000" }),
1163 )
1164 .await
1165 .unwrap_err();
1166 assert!(format!("{err:?}").contains("not found"));
1167 });
1168 h.shutdown(&runtime);
1169 }
1170
1171 #[test]
1172 fn unknown_tool_name_returns_invalid_params() {
1173 let runtime = rt();
1174 let h = Harness::new(&runtime);
1175 runtime.block_on(async {
1176 let err = h
1177 .server
1178 .dispatch_tool("memory.summon", json!({}))
1179 .await
1180 .unwrap_err();
1181 assert!(format!("{err:?}").contains("unknown tool"));
1182 });
1183 h.shutdown(&runtime);
1184 }
1185
1186 #[test]
1221 fn tool_names_match_cross_provider_regex() {
1222 fn passes_anthropic(name: &str) -> bool {
1224 let len = name.len();
1225 if !(1..=64).contains(&len) {
1226 return false;
1227 }
1228 name.chars()
1229 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1230 }
1231
1232 fn passes_openai(name: &str) -> bool {
1235 let len = name.len();
1236 if !(1..=64).contains(&len) {
1237 return false;
1238 }
1239 let mut chars = name.chars();
1240 let first = match chars.next() {
1241 Some(c) => c,
1242 None => return false,
1243 };
1244 if !(first.is_ascii_alphabetic() || first == '_') {
1245 return false;
1246 }
1247 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1248 }
1249
1250 fn passes_gemini(name: &str) -> bool {
1255 let len = name.len();
1256 if !(1..=63).contains(&len) {
1257 return false;
1258 }
1259 let mut chars = name.chars();
1260 let first = match chars.next() {
1261 Some(c) => c,
1262 None => return false,
1263 };
1264 if !(first.is_ascii_alphabetic() || first == '_') {
1265 return false;
1266 }
1267 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1268 }
1269
1270 let tools = build_tools();
1271 assert_eq!(
1272 tools.len(),
1273 8,
1274 "expected 8 tools in v0.5.0 (7 v0.4.x + memory_inspect_cluster)"
1275 );
1276 let tool_name_strings: Vec<String> =
1278 tools.iter().map(|t| t.name.to_string()).collect();
1279 let public_names: Vec<String> =
1280 super::tool_names().iter().map(|s| s.to_string()).collect();
1281 assert_eq!(
1282 tool_name_strings, public_names,
1283 "tool_names() drifted from build_tools() — keep them in sync"
1284 );
1285
1286 for t in tools {
1287 assert!(
1288 passes_anthropic(&t.name),
1289 "tool name {:?} fails Anthropic regex \
1290 ^[a-zA-Z0-9_-]{{1,64}}$ — see v0.3 lesson #8",
1291 t.name
1292 );
1293 assert!(
1294 passes_openai(&t.name),
1295 "tool name {:?} fails OpenAI function-calling regex \
1296 ^[a-zA-Z_][a-zA-Z0-9_-]*$ (len ≤ 64)",
1297 t.name
1298 );
1299 assert!(
1300 passes_gemini(&t.name),
1301 "tool name {:?} fails Gemini function-calling regex \
1302 ^[a-zA-Z_][a-zA-Z0-9_]*$ (len ≤ 63, strict)",
1303 t.name
1304 );
1305 }
1306 }
1307
1308 #[test]
1325 fn tool_descriptions_avoid_internal_jargon() {
1326 const FORBIDDEN: &[&str] = &[
1330 "SPO",
1331 "Steward",
1332 "Steward-flagged",
1333 "LEFT JOIN",
1334 "candidate pair",
1335 "candidate_pair",
1336 "tagged_with",
1337 ];
1338
1339 fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
1340 haystack.to_lowercase().contains(&needle.to_lowercase())
1341 }
1342
1343 for t in build_tools() {
1345 for term in FORBIDDEN {
1346 assert!(
1347 !contains_case_insensitive(&t.description, term),
1348 "tool {:?} description contains forbidden jargon \
1349 {:?} — rewrite in plain English (see v0.5.0 \
1350 Priority 4)",
1351 t.name,
1352 term,
1353 );
1354 }
1355 }
1356
1357 let server_info = harness_server_info();
1360 let instructions = server_info
1361 .instructions
1362 .as_deref()
1363 .expect("get_info() must set instructions");
1364 for term in FORBIDDEN {
1365 assert!(
1366 !contains_case_insensitive(instructions, term),
1367 "get_info().instructions contains forbidden jargon \
1368 {:?} — rewrite in plain English",
1369 term,
1370 );
1371 }
1372 }
1373
1374 fn harness_server_info() -> rmcp::model::ServerInfo {
1381 let runtime = rt();
1382 let h = Harness::new(&runtime);
1383 let info = ServerHandler::get_info(&h.server);
1384 h.shutdown(&runtime);
1385 info
1386 }
1387
1388 #[test]
1391 fn inspect_cluster_unknown_id_returns_invalid_params() {
1392 let runtime = rt();
1396 let h = Harness::new(&runtime);
1397 runtime.block_on(async {
1398 let err = h
1399 .server
1400 .dispatch_tool(
1401 "memory_inspect_cluster",
1402 json!({ "cluster_id": "no-such-cluster" }),
1403 )
1404 .await
1405 .expect_err("unknown cluster must error");
1406 let s = format!("{err:?}");
1407 assert!(
1408 s.contains("no-such-cluster") || s.to_lowercase().contains("not found"),
1409 "expected error to mention the missing cluster id; got: {s}"
1410 );
1411 });
1412 h.shutdown(&runtime);
1413 }
1414
1415 #[test]
1416 fn inspect_cluster_rejects_empty_id() {
1417 let runtime = rt();
1418 let h = Harness::new(&runtime);
1419 runtime.block_on(async {
1420 let err = h
1421 .server
1422 .dispatch_tool(
1423 "memory_inspect_cluster",
1424 json!({ "cluster_id": " " }),
1425 )
1426 .await
1427 .expect_err("blank cluster_id must error");
1428 let s = format!("{err:?}");
1429 assert!(
1430 s.to_lowercase().contains("cluster_id")
1431 || s.to_lowercase().contains("must not be empty"),
1432 "got: {s}"
1433 );
1434 });
1435 h.shutdown(&runtime);
1436 }
1437}
1438
1439