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)]
197 pub include_as_object: bool,
198 #[serde(default = "default_limit")]
199 pub limit: usize,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct ContradictionsArgs {
204 #[serde(default = "default_limit")]
205 pub limit: usize,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct InspectClusterArgs {
213 pub cluster_id: String,
214 #[serde(default)]
219 pub full_content: bool,
220}
221
222impl ServerHandler for SoloMcpServer {
227 fn get_info(&self) -> ServerInfo {
228 ServerInfo {
229 protocol_version: ProtocolVersion::default(),
230 capabilities: ServerCapabilities {
231 tools: Some(ToolsCapability {
232 list_changed: Some(false),
233 }),
234 ..Default::default()
235 },
236 server_info: Implementation {
237 name: "solo".into(),
238 version: env!("CARGO_PKG_VERSION").into(),
239 },
240 instructions: Some(
241 "Solo gives you persistent memory across conversations \
242 with this user — what they've told you before, the \
243 people and projects in their life, and where their \
244 stated beliefs have shifted. Reach for these tools \
245 whenever the user references something from earlier \
246 (\"like I mentioned\", \"the project I'm working \
247 on\", \"my friend Alex\") or asks a question that \
248 hinges on personal context you don't have in the \
249 current chat. \
250 \n\nTools to write or look up specific moments: \
251 memory_remember (save something worth keeping), \
252 memory_recall (search past conversations by topic), \
253 memory_inspect (show one saved item by id), \
254 memory_forget (delete one saved item). \
255 \n\nTools for the bigger picture (populated as the \
256 user uses Solo over time): memory_themes (recent \
257 topics they've been thinking about), \
258 memory_facts_about (what you know about a person, \
259 project, or place — \"what do you know about \
260 Alex?\"), memory_contradictions (places where the \
261 user has said two things that disagree — surface \
262 these before answering), memory_inspect_cluster \
263 (the raw conversations behind one summary)."
264 .into(),
265 ),
266 }
267 }
268
269 async fn list_tools(
270 &self,
271 _request: PaginatedRequestParam,
272 _context: RequestContext<RoleServer>,
273 ) -> std::result::Result<ListToolsResult, McpError> {
274 Ok(ListToolsResult {
275 tools: build_tools(),
276 next_cursor: None,
277 })
278 }
279
280 async fn call_tool(
281 &self,
282 request: CallToolRequestParam,
283 _context: RequestContext<RoleServer>,
284 ) -> std::result::Result<CallToolResult, McpError> {
285 let CallToolRequestParam { name, arguments } = request;
286 let args_value = serde_json::Value::Object(arguments.unwrap_or_default());
287 self.dispatch_tool(&name, args_value).await
288 }
289}
290
291impl SoloMcpServer {
292 pub async fn dispatch_tool(
298 &self,
299 name: &str,
300 args_value: serde_json::Value,
301 ) -> std::result::Result<CallToolResult, McpError> {
302 match name {
303 "memory_remember" => {
304 let args: RememberArgs = parse_args(&args_value)?;
305 self.handle_remember(args).await
306 }
307 "memory_recall" => {
308 let args: RecallArgs = parse_args(&args_value)?;
309 self.handle_recall(args).await
310 }
311 "memory_forget" => {
312 let args: ForgetArgs = parse_args(&args_value)?;
313 self.handle_forget(args).await
314 }
315 "memory_inspect" => {
316 let args: InspectArgs = parse_args(&args_value)?;
317 self.handle_inspect(args).await
318 }
319 "memory_themes" => {
320 let args: ThemesArgs = parse_args(&args_value)?;
321 self.handle_themes(args).await
322 }
323 "memory_facts_about" => {
324 let args: FactsAboutArgs = parse_args(&args_value)?;
325 self.handle_facts_about(args).await
326 }
327 "memory_contradictions" => {
328 let args: ContradictionsArgs = parse_args(&args_value)?;
329 self.handle_contradictions(args).await
330 }
331 "memory_inspect_cluster" => {
332 let args: InspectClusterArgs = parse_args(&args_value)?;
333 self.handle_inspect_cluster(args).await
334 }
335 other => Err(McpError::invalid_params(
336 format!("unknown tool `{other}`"),
337 None,
338 )),
339 }
340 }
341
342 pub fn dispatch_list_tools(&self) -> Vec<Tool> {
345 build_tools()
346 }
347}
348
349fn parse_args<T: serde::de::DeserializeOwned>(
350 v: &serde_json::Value,
351) -> std::result::Result<T, McpError> {
352 serde_json::from_value(v.clone()).map_err(|e| {
353 McpError::invalid_params(format!("invalid tool arguments: {e}"), None)
354 })
355}
356
357fn solo_to_mcp(e: solo_core::Error) -> McpError {
358 use solo_core::Error;
359 match e {
360 Error::NotFound(msg) => McpError::invalid_params(msg, None),
361 Error::InvalidInput(msg) => McpError::invalid_params(msg, None),
362 Error::Conflict(msg) => McpError::invalid_params(msg, None),
363 other => McpError::internal_error(other.to_string(), None),
364 }
365}
366
367fn build_tools() -> Vec<Tool> {
372 vec![
373 Tool::new(
374 "memory_remember",
375 "Save something the user has told you — a fact, a \
376 preference, a name, a date, a context — so you can pick \
377 it up next conversation. Use whenever the user mentions \
378 something they'd reasonably expect you to recall later \
379 (\"I just started at Quotient\", \"my partner is Maya\"). \
380 Returns the saved item's id.",
381 json_schema_object(serde_json::json!({
382 "type": "object",
383 "properties": {
384 "content": {
385 "type": "string",
386 "description": "The text to remember.",
387 },
388 "source_type": {
389 "type": "string",
390 "description": "Optional source-type tag (default: \"user_message\").",
391 },
392 "source_id": {
393 "type": "string",
394 "description": "Optional upstream id for traceability.",
395 },
396 },
397 "required": ["content"],
398 })),
399 ),
400 Tool::new(
401 "memory_recall",
402 "Search past conversations with this user by topic or \
403 phrase. Returns up to `limit` of the closest matches, \
404 best match first. Use when the user references \
405 something they said before (\"that book I told you \
406 about\", \"the bug we were debugging last week\"). \
407 Skips items the user has deleted.",
408 json_schema_object(serde_json::json!({
409 "type": "object",
410 "properties": {
411 "query": {
412 "type": "string",
413 "description": "The query text.",
414 },
415 "limit": {
416 "type": "integer",
417 "description": "Maximum results (default 5).",
418 "minimum": 1,
419 "maximum": 100,
420 },
421 },
422 "required": ["query"],
423 })),
424 ),
425 Tool::new(
426 "memory_forget",
427 "Delete one saved item by id. Use when the user asks you \
428 to forget something specific (\"forget that I said \
429 X\"). The item stops appearing in future recalls. \
430 Reversible only via backups.",
431 json_schema_object(serde_json::json!({
432 "type": "object",
433 "properties": {
434 "memory_id": {
435 "type": "string",
436 "description": "MemoryId to forget (UUID v7).",
437 },
438 "reason": {
439 "type": "string",
440 "description": "Optional free-form reason (logged, not yet persisted).",
441 },
442 },
443 "required": ["memory_id"],
444 })),
445 ),
446 Tool::new(
447 "memory_inspect",
448 "Show the full record for one saved item — when it was \
449 saved, where it came from, and the full text. Use after \
450 memory_recall when you want the complete content of a \
451 specific hit (recall results may be truncated).",
452 json_schema_object(serde_json::json!({
453 "type": "object",
454 "properties": {
455 "memory_id": {
456 "type": "string",
457 "description": "MemoryId to inspect (UUID v7).",
458 },
459 },
460 "required": ["memory_id"],
461 })),
462 ),
463 Tool::new(
467 "memory_themes",
468 "Recent topics the user has been thinking about. Use to \
469 orient yourself at the start of a conversation, or when \
470 the user asks \"what have I been up to\" / \"what was I \
471 working on last week\". Pass `window_days` to scope \
472 (e.g. 7 for last week); omit for all-time.",
473 json_schema_object(serde_json::json!({
474 "type": "object",
475 "properties": {
476 "window_days": {
477 "type": "integer",
478 "description": "Optional time window in days. Omit for unfiltered.",
479 "minimum": 1,
480 },
481 "limit": {
482 "type": "integer",
483 "description": "Maximum results (default 5).",
484 "minimum": 1,
485 "maximum": 100,
486 },
487 },
488 })),
489 ),
490 Tool::new(
491 "memory_facts_about",
492 "Look up what you remember about a person, project, or \
493 topic — names, dates, preferences, relationships. Use \
494 when the user asks \"what do you know about Alex?\", \
495 \"when did I start at Quotient?\", \"who is Maya?\", or \
496 whenever you need grounded facts about someone or \
497 something before answering. Subject is required (the \
498 person/place/thing you're asking about); narrow further \
499 with `predicate` (\"works_at\", \"lives_in\") or a date \
500 range. Set `include_as_object=true` to also surface \
501 facts where the subject appears on the receiving side of \
502 a relationship (e.g. \"Sam pushes back on PRs about \
503 Maya\" surfaces under facts_about(subject=\"Maya\", \
504 include_as_object=true)). (Backed by \
505 subject-predicate-object triples distilled from past \
506 conversations.) Clients should set a 30s timeout on this \
507 call; if exceeded, retry once or fall back to \
508 `memory_recall`.",
509 json_schema_object(serde_json::json!({
510 "type": "object",
511 "properties": {
512 "subject": {
513 "type": "string",
514 "description": "Subject id to query (e.g. 'Sam').",
515 },
516 "predicate": {
517 "type": "string",
518 "description": "Optional predicate filter (e.g. 'works_at').",
519 },
520 "since_ms": {
521 "type": "integer",
522 "description": "Optional valid_from_ms lower bound (epoch ms).",
523 },
524 "until_ms": {
525 "type": "integer",
526 "description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through.",
527 },
528 "include_as_object": {
529 "type": "boolean",
530 "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.",
531 "default": false,
532 },
533 "limit": {
534 "type": "integer",
535 "description": "Maximum results (default 5).",
536 "minimum": 1,
537 "maximum": 100,
538 },
539 },
540 "required": ["subject"],
541 })),
542 ),
543 Tool::new(
544 "memory_contradictions",
545 "Find places where the user's stated beliefs or facts \
546 disagree across conversations — flag disagreements \
547 before answering. Use whenever you're about to rely on \
548 a remembered fact that could have changed (jobs, \
549 relationships, preferences, opinions); a disagreement \
550 here means the user has told you both X and not-X over \
551 time and you should ask which is current instead of \
552 guessing. Each result shows both conflicting statements \
553 with the topic.",
554 json_schema_object(serde_json::json!({
555 "type": "object",
556 "properties": {
557 "limit": {
558 "type": "integer",
559 "description": "Maximum results (default 5).",
560 "minimum": 1,
561 "maximum": 100,
562 },
563 },
564 })),
565 ),
566 Tool::new(
567 "memory_inspect_cluster",
568 "Show the raw conversations behind one summary. Returns \
569 the one-line topic (the LLM-generated summary) and the \
570 source conversations the topic was built from. Use \
571 after memory_themes when the user asks \"show me the \
572 raw context behind this\" or \"why does Solo think \
573 that about cluster Y\". Source items are truncated to \
574 200 chars unless `full_content` is set.",
575 json_schema_object(serde_json::json!({
576 "type": "object",
577 "properties": {
578 "cluster_id": {
579 "type": "string",
580 "description": "Cluster id to inspect (from memory_themes hits).",
581 },
582 "full_content": {
583 "type": "boolean",
584 "description": "If true, episode content is returned verbatim. Default false (truncate to 200 chars + ellipsis).",
585 },
586 },
587 "required": ["cluster_id"],
588 })),
589 ),
590 ]
591}
592
593fn json_schema_object(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
594 match value {
595 serde_json::Value::Object(map) => map,
596 _ => panic!("json_schema_object: input must be an object"),
597 }
598}
599
600pub fn tool_names() -> Vec<&'static str> {
609 vec![
610 "memory_remember",
611 "memory_recall",
612 "memory_forget",
613 "memory_inspect",
614 "memory_themes",
615 "memory_facts_about",
616 "memory_contradictions",
617 "memory_inspect_cluster",
618 ]
619}
620
621impl SoloMcpServer {
626 async fn handle_remember(
627 &self,
628 args: RememberArgs,
629 ) -> std::result::Result<CallToolResult, McpError> {
630 let content = args.content.trim_end().to_string();
631 if content.is_empty() {
632 return Err(McpError::invalid_params(
633 "memory_remember: content must not be empty".to_string(),
634 None,
635 ));
636 }
637 let embedding: solo_core::Embedding = self
638 .inner
639 .embedder
640 .embed(&content)
641 .await
642 .map_err(solo_to_mcp)?;
643 let episode = Episode {
644 memory_id: MemoryId::new(),
645 ts_ms: chrono::Utc::now().timestamp_millis(),
646 source_type: args.source_type.unwrap_or_else(|| "user_message".into()),
647 source_id: args.source_id,
648 content,
649 encoding_context: EncodingContext::default(),
650 provenance: None,
651 confidence: Confidence::new(0.9).unwrap(),
652 strength: 0.5,
653 salience: 0.5,
654 tier: Tier::Hot,
655 };
656 let mid = self
657 .inner
658 .write
659 .remember(episode, embedding)
660 .await
661 .map_err(solo_to_mcp)?;
662 Ok(CallToolResult::success(vec![Content::text(format!(
663 "remembered {mid}"
664 ))]))
665 }
666
667 async fn handle_recall(
668 &self,
669 args: RecallArgs,
670 ) -> std::result::Result<CallToolResult, McpError> {
671 let result = solo_query::run_recall(
675 &self.inner.embedder,
676 &self.inner.hnsw,
677 &self.inner.pool,
678 &args.query,
679 args.limit,
680 )
681 .await
682 .map_err(solo_to_mcp)?;
683
684 if result.hits.is_empty() {
685 return Ok(CallToolResult::success(vec![Content::text(format!(
686 "no matches (index has {} vectors)",
687 result.index_len
688 ))]));
689 }
690 let body = serde_json::to_string_pretty(&result.hits).unwrap_or_else(|_| String::new());
691 Ok(CallToolResult::success(vec![Content::text(body)]))
692 }
693
694 async fn handle_forget(
695 &self,
696 args: ForgetArgs,
697 ) -> std::result::Result<CallToolResult, McpError> {
698 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
699 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
700 })?;
701 self.inner
702 .write
703 .forget(mid, args.reason)
704 .await
705 .map_err(solo_to_mcp)?;
706 Ok(CallToolResult::success(vec![Content::text(format!(
707 "forgotten {mid}"
708 ))]))
709 }
710
711 async fn handle_inspect(
712 &self,
713 args: InspectArgs,
714 ) -> std::result::Result<CallToolResult, McpError> {
715 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
716 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
717 })?;
718 let row = solo_query::inspect_one(&self.inner.pool, mid)
720 .await
721 .map_err(solo_to_mcp)?;
722 let body = serde_json::to_string_pretty(&row).unwrap_or_else(|_| String::new());
723 Ok(CallToolResult::success(vec![Content::text(body)]))
724 }
725
726 async fn handle_themes(
733 &self,
734 args: ThemesArgs,
735 ) -> std::result::Result<CallToolResult, McpError> {
736 let hits = solo_query::themes(
737 &self.inner.pool,
738 args.window_days,
739 args.limit,
740 )
741 .await
742 .map_err(solo_to_mcp)?;
743 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
744 Ok(CallToolResult::success(vec![Content::text(body)]))
745 }
746
747 async fn handle_facts_about(
748 &self,
749 args: FactsAboutArgs,
750 ) -> std::result::Result<CallToolResult, McpError> {
751 if args.subject.trim().is_empty() {
752 return Err(McpError::invalid_params(
753 "memory_facts_about: subject must not be empty".to_string(),
754 None,
755 ));
756 }
757 let hits = solo_query::facts_about(
758 &self.inner.pool,
759 &args.subject,
760 &self.inner.user_aliases,
761 args.include_as_object,
762 args.predicate.as_deref(),
763 args.since_ms,
764 args.until_ms,
765 args.limit,
766 )
767 .await
768 .map_err(solo_to_mcp)?;
769 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
770 Ok(CallToolResult::success(vec![Content::text(body)]))
771 }
772
773 async fn handle_contradictions(
774 &self,
775 args: ContradictionsArgs,
776 ) -> std::result::Result<CallToolResult, McpError> {
777 let hits = solo_query::contradictions(&self.inner.pool, args.limit)
778 .await
779 .map_err(solo_to_mcp)?;
780 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
781 Ok(CallToolResult::success(vec![Content::text(body)]))
782 }
783
784 async fn handle_inspect_cluster(
785 &self,
786 args: InspectClusterArgs,
787 ) -> std::result::Result<CallToolResult, McpError> {
788 if args.cluster_id.trim().is_empty() {
789 return Err(McpError::invalid_params(
790 "memory_inspect_cluster: cluster_id must not be empty".to_string(),
791 None,
792 ));
793 }
794 let record = solo_query::inspect_cluster(
799 &self.inner.pool,
800 &args.cluster_id,
801 args.full_content,
802 )
803 .await
804 .map_err(solo_to_mcp)?;
805 let body = serde_json::to_string_pretty(&record).unwrap_or_else(|_| String::new());
806 Ok(CallToolResult::success(vec![Content::text(body)]))
807 }
808}
809
810#[cfg(test)]
811mod dispatch_tests {
812 use super::*;
824 use serde_json::json;
825 use solo_core::VectorIndex;
826 use solo_storage::test_support::StubVectorIndex;
827 use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
828 use std::sync::Arc as StdArc;
829
830 struct Harness {
831 server: SoloMcpServer,
832 _tmp: tempfile::TempDir,
833 write_handle_extra: Option<solo_storage::WriteHandle>,
834 join: Option<std::thread::JoinHandle<()>>,
835 }
836
837 impl Harness {
838 fn new(runtime: &tokio::runtime::Runtime) -> Self {
839 let tmp = tempfile::TempDir::new().unwrap();
840 let dim = 16usize;
841 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
842 let embedder: StdArc<dyn solo_core::Embedder> = StdArc::new(StubEmbedder::new("stub", "v1", dim));
843
844 let conn = solo_storage::test_support::open_test_db_at(&tmp.path().join("test.db"));
845 let WriterSpawn { handle, join } = WriterActor::spawn(conn, hnsw.clone());
846
847 let path = tmp.path().join("test.db");
850 let pool: ReaderPool =
851 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
852
853 let server = SoloMcpServer::new(handle.clone(), pool, embedder, hnsw);
854 Harness {
855 server,
856 _tmp: tmp,
857 write_handle_extra: Some(handle),
858 join: Some(join),
859 }
860 }
861
862 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
863 let join = self.join.take();
869 let extra = self.write_handle_extra.take();
870 runtime.block_on(async move {
871 drop(extra);
872 drop(self.server);
873 drop(self._tmp);
874 if let Some(join) = join {
875 let (tx, rx) = std::sync::mpsc::channel();
876 std::thread::spawn(move || {
877 let _ = tx.send(join.join());
878 });
879 tokio::task::spawn_blocking(move || {
880 rx.recv_timeout(std::time::Duration::from_secs(5))
881 })
882 .await
883 .expect("blocking task")
884 .expect("writer thread did not exit within 5s")
885 .expect("writer thread panicked");
886 }
887 });
888 }
889 }
890
891 fn rt() -> tokio::runtime::Runtime {
892 tokio::runtime::Builder::new_multi_thread()
893 .worker_threads(2)
894 .enable_all()
895 .build()
896 .unwrap()
897 }
898
899 fn first_text(r: &rmcp::model::CallToolResult) -> String {
904 let first = r.content.first().expect("at least one content item");
905 let v = serde_json::to_value(first).expect("content serialises");
906 v.get("text")
907 .and_then(|t| t.as_str())
908 .map(|s| s.to_string())
909 .unwrap_or_else(|| format!("{v}"))
910 }
911
912 #[test]
913 fn tools_list_returns_eight_canonical_tools() {
914 let runtime = rt();
915 let h = Harness::new(&runtime);
916 let tools = h.server.dispatch_list_tools();
917 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
918 assert_eq!(
919 names,
920 vec![
921 "memory_remember",
922 "memory_recall",
923 "memory_forget",
924 "memory_inspect",
925 "memory_themes",
927 "memory_facts_about",
928 "memory_contradictions",
929 "memory_inspect_cluster",
931 ]
932 );
933 for t in &tools {
934 assert!(!t.description.is_empty(), "{} description empty", t.name);
935 let _schema = t.schema_as_json_value();
936 }
943 h.shutdown(&runtime);
944 }
945
946 #[test]
947 fn themes_returns_json_array_on_empty_db() {
948 let runtime = rt();
949 let h = Harness::new(&runtime);
950 runtime.block_on(async {
951 let r = h
952 .server
953 .dispatch_tool("memory_themes", json!({}))
954 .await
955 .expect("themes succeeds");
956 let text = first_text(&r);
957 let v: serde_json::Value =
959 serde_json::from_str(&text).expect("parses as json");
960 assert!(v.is_array(), "expected array, got: {text}");
961 assert_eq!(v.as_array().unwrap().len(), 0);
962 });
963 h.shutdown(&runtime);
964 }
965
966 #[test]
967 fn themes_passes_through_window_and_limit_args() {
968 let runtime = rt();
969 let h = Harness::new(&runtime);
970 runtime.block_on(async {
971 let r = h
973 .server
974 .dispatch_tool(
975 "memory_themes",
976 json!({ "window_days": 7, "limit": 20 }),
977 )
978 .await
979 .expect("themes with args succeeds");
980 let text = first_text(&r);
981 let v: serde_json::Value =
982 serde_json::from_str(&text).expect("parses as json");
983 assert!(v.is_array());
984 });
985 h.shutdown(&runtime);
986 }
987
988 #[test]
989 fn facts_about_rejects_empty_subject() {
990 let runtime = rt();
991 let h = Harness::new(&runtime);
992 runtime.block_on(async {
993 let err = h
994 .server
995 .dispatch_tool(
996 "memory_facts_about",
997 json!({ "subject": " " }),
998 )
999 .await
1000 .expect_err("empty subject must error");
1001 let s = format!("{err:?}");
1004 assert!(
1005 s.to_lowercase().contains("subject")
1006 || s.to_lowercase().contains("invalid"),
1007 "got: {s}"
1008 );
1009 });
1010 h.shutdown(&runtime);
1011 }
1012
1013 #[test]
1014 fn facts_about_returns_array_for_unknown_subject() {
1015 let runtime = rt();
1016 let h = Harness::new(&runtime);
1017 runtime.block_on(async {
1018 let r = h
1019 .server
1020 .dispatch_tool(
1021 "memory_facts_about",
1022 json!({ "subject": "NobodyKnowsThisSubject" }),
1023 )
1024 .await
1025 .expect("facts_about with unknown subject succeeds");
1026 let text = first_text(&r);
1027 let v: serde_json::Value =
1028 serde_json::from_str(&text).expect("parses as json");
1029 assert_eq!(v.as_array().unwrap().len(), 0);
1030 });
1031 h.shutdown(&runtime);
1032 }
1033
1034 #[test]
1035 fn facts_about_accepts_include_as_object_arg() {
1036 let runtime = rt();
1044 let h = Harness::new(&runtime);
1045 runtime.block_on(async {
1046 let r = h
1048 .server
1049 .dispatch_tool(
1050 "memory_facts_about",
1051 json!({ "subject": "Maya", "include_as_object": true }),
1052 )
1053 .await
1054 .expect("dispatch with include_as_object=true succeeds");
1055 let v: serde_json::Value = serde_json::from_str(&first_text(&r))
1056 .expect("parses as json");
1057 assert_eq!(v.as_array().unwrap().len(), 0);
1058
1059 let r = h
1061 .server
1062 .dispatch_tool(
1063 "memory_facts_about",
1064 json!({ "subject": "Maya" }),
1065 )
1066 .await
1067 .expect("dispatch without include_as_object succeeds (default false)");
1068 let v: serde_json::Value = serde_json::from_str(&first_text(&r))
1069 .expect("parses as json");
1070 assert_eq!(v.as_array().unwrap().len(), 0);
1071 });
1072 h.shutdown(&runtime);
1073 }
1074
1075 #[test]
1076 fn contradictions_returns_json_array_on_empty_db() {
1077 let runtime = rt();
1078 let h = Harness::new(&runtime);
1079 runtime.block_on(async {
1080 let r = h
1081 .server
1082 .dispatch_tool("memory_contradictions", json!({}))
1083 .await
1084 .expect("contradictions succeeds");
1085 let text = first_text(&r);
1086 let v: serde_json::Value =
1087 serde_json::from_str(&text).expect("parses as json");
1088 assert!(v.is_array());
1089 assert_eq!(v.as_array().unwrap().len(), 0);
1090 });
1091 h.shutdown(&runtime);
1092 }
1093
1094 #[test]
1095 fn remember_then_recall_round_trip() {
1096 let runtime = rt();
1097 let h = Harness::new(&runtime);
1098 runtime.block_on(async {
1104 let r = h
1105 .server
1106 .dispatch_tool("memory_remember", json!({ "content": "the cat sat on the mat" }))
1107 .await
1108 .expect("remember succeeds");
1109 let text = first_text(&r);
1110 assert!(text.starts_with("remembered "), "got: {text}");
1111
1112 let r = h
1113 .server
1114 .dispatch_tool(
1115 "memory_recall",
1116 json!({ "query": "the cat sat on the mat", "limit": 5 }),
1117 )
1118 .await
1119 .expect("recall succeeds");
1120 let text = first_text(&r);
1121 assert!(text.contains("the cat sat on the mat"), "got: {text}");
1122 });
1123 h.shutdown(&runtime);
1124 }
1125
1126 #[test]
1127 fn forget_excludes_row_from_subsequent_recall() {
1128 let runtime = rt();
1129 let h = Harness::new(&runtime);
1130
1131 runtime.block_on(async {
1132 let r = h
1133 .server
1134 .dispatch_tool("memory_remember", json!({ "content": "to be forgotten" }))
1135 .await
1136 .unwrap();
1137 let text = first_text(&r);
1138 let mid = text.strip_prefix("remembered ").unwrap().to_string();
1139
1140 h.server
1141 .dispatch_tool(
1142 "memory_forget",
1143 json!({ "memory_id": mid, "reason": "test" }),
1144 )
1145 .await
1146 .expect("forget succeeds");
1147
1148 let r = h
1149 .server
1150 .dispatch_tool(
1151 "memory_recall",
1152 json!({ "query": "to be forgotten", "limit": 5 }),
1153 )
1154 .await
1155 .unwrap();
1156 let text = first_text(&r);
1157 assert!(
1158 !text.contains(r#""content": "to be forgotten""#),
1159 "forgotten row should be excluded; got: {text}"
1160 );
1161 });
1162 h.shutdown(&runtime);
1163 }
1164
1165 #[test]
1166 fn empty_remember_returns_invalid_params() {
1167 let runtime = rt();
1168 let h = Harness::new(&runtime);
1169 runtime.block_on(async {
1170 let err = h
1171 .server
1172 .dispatch_tool("memory_remember", json!({ "content": "" }))
1173 .await
1174 .unwrap_err();
1175 assert!(format!("{err:?}").contains("must not be empty"));
1176 });
1177 h.shutdown(&runtime);
1178 }
1179
1180 #[test]
1181 fn empty_recall_query_returns_invalid_params() {
1182 let runtime = rt();
1183 let h = Harness::new(&runtime);
1184 runtime.block_on(async {
1185 let err = h
1186 .server
1187 .dispatch_tool("memory_recall", json!({ "query": " " }))
1188 .await
1189 .unwrap_err();
1190 assert!(format!("{err:?}").contains("must not be empty"));
1191 });
1192 h.shutdown(&runtime);
1193 }
1194
1195 #[test]
1196 fn inspect_with_invalid_id_returns_invalid_params() {
1197 let runtime = rt();
1198 let h = Harness::new(&runtime);
1199 runtime.block_on(async {
1200 let err = h
1201 .server
1202 .dispatch_tool("memory_inspect", json!({ "memory_id": "not-a-uuid" }))
1203 .await
1204 .unwrap_err();
1205 assert!(format!("{err:?}").contains("invalid memory_id"));
1206 });
1207 h.shutdown(&runtime);
1208 }
1209
1210 #[test]
1211 fn forget_unknown_id_returns_invalid_params() {
1212 let runtime = rt();
1213 let h = Harness::new(&runtime);
1214 runtime.block_on(async {
1215 let err = h
1219 .server
1220 .dispatch_tool(
1221 "memory_forget",
1222 json!({ "memory_id": "00000000-0000-7000-8000-000000000000" }),
1223 )
1224 .await
1225 .unwrap_err();
1226 assert!(format!("{err:?}").contains("not found"));
1227 });
1228 h.shutdown(&runtime);
1229 }
1230
1231 #[test]
1232 fn unknown_tool_name_returns_invalid_params() {
1233 let runtime = rt();
1234 let h = Harness::new(&runtime);
1235 runtime.block_on(async {
1236 let err = h
1237 .server
1238 .dispatch_tool("memory.summon", json!({}))
1239 .await
1240 .unwrap_err();
1241 assert!(format!("{err:?}").contains("unknown tool"));
1242 });
1243 h.shutdown(&runtime);
1244 }
1245
1246 #[test]
1281 fn tool_names_match_cross_provider_regex() {
1282 fn passes_anthropic(name: &str) -> bool {
1284 let len = name.len();
1285 if !(1..=64).contains(&len) {
1286 return false;
1287 }
1288 name.chars()
1289 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1290 }
1291
1292 fn passes_openai(name: &str) -> bool {
1295 let len = name.len();
1296 if !(1..=64).contains(&len) {
1297 return false;
1298 }
1299 let mut chars = name.chars();
1300 let first = match chars.next() {
1301 Some(c) => c,
1302 None => return false,
1303 };
1304 if !(first.is_ascii_alphabetic() || first == '_') {
1305 return false;
1306 }
1307 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1308 }
1309
1310 fn passes_gemini(name: &str) -> bool {
1315 let len = name.len();
1316 if !(1..=63).contains(&len) {
1317 return false;
1318 }
1319 let mut chars = name.chars();
1320 let first = match chars.next() {
1321 Some(c) => c,
1322 None => return false,
1323 };
1324 if !(first.is_ascii_alphabetic() || first == '_') {
1325 return false;
1326 }
1327 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1328 }
1329
1330 let tools = build_tools();
1331 assert_eq!(
1332 tools.len(),
1333 8,
1334 "expected 8 tools in v0.5.0 (7 v0.4.x + memory_inspect_cluster)"
1335 );
1336 let tool_name_strings: Vec<String> =
1338 tools.iter().map(|t| t.name.to_string()).collect();
1339 let public_names: Vec<String> =
1340 super::tool_names().iter().map(|s| s.to_string()).collect();
1341 assert_eq!(
1342 tool_name_strings, public_names,
1343 "tool_names() drifted from build_tools() — keep them in sync"
1344 );
1345
1346 for t in tools {
1347 assert!(
1348 passes_anthropic(&t.name),
1349 "tool name {:?} fails Anthropic regex \
1350 ^[a-zA-Z0-9_-]{{1,64}}$ — see v0.3 lesson #8",
1351 t.name
1352 );
1353 assert!(
1354 passes_openai(&t.name),
1355 "tool name {:?} fails OpenAI function-calling regex \
1356 ^[a-zA-Z_][a-zA-Z0-9_-]*$ (len ≤ 64)",
1357 t.name
1358 );
1359 assert!(
1360 passes_gemini(&t.name),
1361 "tool name {:?} fails Gemini function-calling regex \
1362 ^[a-zA-Z_][a-zA-Z0-9_]*$ (len ≤ 63, strict)",
1363 t.name
1364 );
1365 }
1366 }
1367
1368 #[test]
1385 fn tool_descriptions_avoid_internal_jargon() {
1386 const FORBIDDEN: &[&str] = &[
1390 "SPO",
1391 "Steward",
1392 "Steward-flagged",
1393 "LEFT JOIN",
1394 "candidate pair",
1395 "candidate_pair",
1396 "tagged_with",
1397 ];
1398
1399 fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
1400 haystack.to_lowercase().contains(&needle.to_lowercase())
1401 }
1402
1403 for t in build_tools() {
1405 for term in FORBIDDEN {
1406 assert!(
1407 !contains_case_insensitive(&t.description, term),
1408 "tool {:?} description contains forbidden jargon \
1409 {:?} — rewrite in plain English (see v0.5.0 \
1410 Priority 4)",
1411 t.name,
1412 term,
1413 );
1414 }
1415 }
1416
1417 let server_info = harness_server_info();
1420 let instructions = server_info
1421 .instructions
1422 .as_deref()
1423 .expect("get_info() must set instructions");
1424 for term in FORBIDDEN {
1425 assert!(
1426 !contains_case_insensitive(instructions, term),
1427 "get_info().instructions contains forbidden jargon \
1428 {:?} — rewrite in plain English",
1429 term,
1430 );
1431 }
1432 }
1433
1434 fn harness_server_info() -> rmcp::model::ServerInfo {
1441 let runtime = rt();
1442 let h = Harness::new(&runtime);
1443 let info = ServerHandler::get_info(&h.server);
1444 h.shutdown(&runtime);
1445 info
1446 }
1447
1448 #[test]
1451 fn inspect_cluster_unknown_id_returns_invalid_params() {
1452 let runtime = rt();
1456 let h = Harness::new(&runtime);
1457 runtime.block_on(async {
1458 let err = h
1459 .server
1460 .dispatch_tool(
1461 "memory_inspect_cluster",
1462 json!({ "cluster_id": "no-such-cluster" }),
1463 )
1464 .await
1465 .expect_err("unknown cluster must error");
1466 let s = format!("{err:?}");
1467 assert!(
1468 s.contains("no-such-cluster") || s.to_lowercase().contains("not found"),
1469 "expected error to mention the missing cluster id; got: {s}"
1470 );
1471 });
1472 h.shutdown(&runtime);
1473 }
1474
1475 #[test]
1476 fn inspect_cluster_rejects_empty_id() {
1477 let runtime = rt();
1478 let h = Harness::new(&runtime);
1479 runtime.block_on(async {
1480 let err = h
1481 .server
1482 .dispatch_tool(
1483 "memory_inspect_cluster",
1484 json!({ "cluster_id": " " }),
1485 )
1486 .await
1487 .expect_err("blank cluster_id must error");
1488 let s = format!("{err:?}");
1489 assert!(
1490 s.to_lowercase().contains("cluster_id")
1491 || s.to_lowercase().contains("must not be empty"),
1492 "got: {s}"
1493 );
1494 });
1495 h.shutdown(&runtime);
1496 }
1497}
1498
1499