1use std::sync::Arc;
32
33use rmcp::handler::server::ServerHandler;
34use rmcp::model::{
35 CallToolRequestParam, CallToolResult, Content, Implementation, ListToolsResult,
36 PaginatedRequestParam, ProtocolVersion, ServerCapabilities, ServerInfo, Tool,
37 ToolsCapability,
38};
39use rmcp::service::{RequestContext, RoleServer};
40use rmcp::{Error as McpError, ServiceExt};
41use serde::{Deserialize, Serialize};
42use solo_core::{
43 Confidence, Embedder, EncodingContext, Episode, MemoryId, Tier,
44 VectorIndex,
45};
46use solo_storage::{ReaderPool, WriteHandle};
47use std::str::FromStr;
48
49#[derive(Clone)]
51pub struct SoloMcpServer {
52 inner: Arc<Inner>,
53}
54
55struct Inner {
56 write: WriteHandle,
57 pool: ReaderPool,
58 embedder: Arc<dyn Embedder>,
59 hnsw: Arc<dyn VectorIndex + Send + Sync>,
60}
61
62impl SoloMcpServer {
63 pub fn new(
64 write: WriteHandle,
65 pool: ReaderPool,
66 embedder: Arc<dyn Embedder>,
67 hnsw: Arc<dyn VectorIndex + Send + Sync>,
68 ) -> Self {
69 Self {
70 inner: Arc::new(Inner {
71 write,
72 pool,
73 embedder,
74 hnsw,
75 }),
76 }
77 }
78}
79
80pub async fn serve_stdio(server: SoloMcpServer) -> anyhow::Result<()> {
83 use rmcp::transport::io::stdio;
84 let (stdin, stdout) = stdio();
85 let running = server.serve((stdin, stdout)).await?;
86 running.waiting().await?;
87 Ok(())
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct RememberArgs {
96 pub content: String,
97 #[serde(default)]
98 pub source_type: Option<String>,
99 #[serde(default)]
100 pub source_id: Option<String>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct RecallArgs {
105 pub query: String,
106 #[serde(default = "default_limit")]
107 pub limit: usize,
108}
109
110fn default_limit() -> usize {
111 5
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ForgetArgs {
116 pub memory_id: String,
117 #[serde(default = "default_forget_reason")]
118 pub reason: String,
119}
120
121fn default_forget_reason() -> String {
122 "user-initiated via MCP".into()
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct InspectArgs {
127 pub memory_id: String,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ThemesArgs {
137 #[serde(default)]
141 pub window_days: Option<i64>,
142 #[serde(default = "default_limit")]
143 pub limit: usize,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct FactsAboutArgs {
148 pub subject: String,
151 #[serde(default)]
152 pub predicate: Option<String>,
153 #[serde(default)]
154 pub since_ms: Option<i64>,
155 #[serde(default)]
156 pub until_ms: Option<i64>,
157 #[serde(default = "default_limit")]
158 pub limit: usize,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct ContradictionsArgs {
163 #[serde(default = "default_limit")]
164 pub limit: usize,
165}
166
167impl ServerHandler for SoloMcpServer {
172 fn get_info(&self) -> ServerInfo {
173 ServerInfo {
174 protocol_version: ProtocolVersion::default(),
175 capabilities: ServerCapabilities {
176 tools: Some(ToolsCapability {
177 list_changed: Some(false),
178 }),
179 ..Default::default()
180 },
181 server_info: Implementation {
182 name: "solo".into(),
183 version: env!("CARGO_PKG_VERSION").into(),
184 },
185 instructions: Some(
186 "Solo: local-first personal memory for LLMs. \
187 Episode tools: memory.remember (store), \
188 memory.recall (vector search), memory.forget \
189 (soft-delete), memory.inspect (full record by id). \
190 Derived-layer tools (queries against the Steward's \
191 outputs from `solo consolidate`): memory.themes \
192 (cluster abstractions), memory.facts_about \
193 (subject-predicate-object knowledge graph), \
194 memory.contradictions (flagged disagreements between \
195 facts)."
196 .into(),
197 ),
198 }
199 }
200
201 async fn list_tools(
202 &self,
203 _request: PaginatedRequestParam,
204 _context: RequestContext<RoleServer>,
205 ) -> std::result::Result<ListToolsResult, McpError> {
206 Ok(ListToolsResult {
207 tools: build_tools(),
208 next_cursor: None,
209 })
210 }
211
212 async fn call_tool(
213 &self,
214 request: CallToolRequestParam,
215 _context: RequestContext<RoleServer>,
216 ) -> std::result::Result<CallToolResult, McpError> {
217 let CallToolRequestParam { name, arguments } = request;
218 let args_value = serde_json::Value::Object(arguments.unwrap_or_default());
219 self.dispatch_tool(&name, args_value).await
220 }
221}
222
223impl SoloMcpServer {
224 pub async fn dispatch_tool(
230 &self,
231 name: &str,
232 args_value: serde_json::Value,
233 ) -> std::result::Result<CallToolResult, McpError> {
234 match name {
235 "memory.remember" => {
236 let args: RememberArgs = parse_args(&args_value)?;
237 self.handle_remember(args).await
238 }
239 "memory.recall" => {
240 let args: RecallArgs = parse_args(&args_value)?;
241 self.handle_recall(args).await
242 }
243 "memory.forget" => {
244 let args: ForgetArgs = parse_args(&args_value)?;
245 self.handle_forget(args).await
246 }
247 "memory.inspect" => {
248 let args: InspectArgs = parse_args(&args_value)?;
249 self.handle_inspect(args).await
250 }
251 "memory.themes" => {
252 let args: ThemesArgs = parse_args(&args_value)?;
253 self.handle_themes(args).await
254 }
255 "memory.facts_about" => {
256 let args: FactsAboutArgs = parse_args(&args_value)?;
257 self.handle_facts_about(args).await
258 }
259 "memory.contradictions" => {
260 let args: ContradictionsArgs = parse_args(&args_value)?;
261 self.handle_contradictions(args).await
262 }
263 other => Err(McpError::invalid_params(
264 format!("unknown tool `{other}`"),
265 None,
266 )),
267 }
268 }
269
270 pub fn dispatch_list_tools(&self) -> Vec<Tool> {
273 build_tools()
274 }
275}
276
277fn parse_args<T: serde::de::DeserializeOwned>(
278 v: &serde_json::Value,
279) -> std::result::Result<T, McpError> {
280 serde_json::from_value(v.clone()).map_err(|e| {
281 McpError::invalid_params(format!("invalid tool arguments: {e}"), None)
282 })
283}
284
285fn solo_to_mcp(e: solo_core::Error) -> McpError {
286 use solo_core::Error;
287 match e {
288 Error::NotFound(msg) => McpError::invalid_params(msg, None),
289 Error::InvalidInput(msg) => McpError::invalid_params(msg, None),
290 Error::Conflict(msg) => McpError::invalid_params(msg, None),
291 other => McpError::internal_error(other.to_string(), None),
292 }
293}
294
295fn build_tools() -> Vec<Tool> {
300 vec![
301 Tool::new(
302 "memory.remember",
303 "Store a new episodic memory. Returns the new MemoryId (UUID v7).",
304 json_schema_object(serde_json::json!({
305 "type": "object",
306 "properties": {
307 "content": {
308 "type": "string",
309 "description": "The text to remember.",
310 },
311 "source_type": {
312 "type": "string",
313 "description": "Optional source-type tag (default: \"user_message\").",
314 },
315 "source_id": {
316 "type": "string",
317 "description": "Optional upstream id for traceability.",
318 },
319 },
320 "required": ["content"],
321 })),
322 ),
323 Tool::new(
324 "memory.recall",
325 "Vector-search the memory store. Returns up to `limit` results \
326 ordered by cosine distance (smaller = more similar). Excludes \
327 forgotten memories.",
328 json_schema_object(serde_json::json!({
329 "type": "object",
330 "properties": {
331 "query": {
332 "type": "string",
333 "description": "The query text.",
334 },
335 "limit": {
336 "type": "integer",
337 "description": "Maximum results (default 5).",
338 "minimum": 1,
339 "maximum": 100,
340 },
341 },
342 "required": ["query"],
343 })),
344 ),
345 Tool::new(
346 "memory.forget",
347 "Soft-delete a memory by id. The HNSW vector stays in the graph \
348 but the SQL row's status flips to 'forgotten' so future recalls \
349 exclude it.",
350 json_schema_object(serde_json::json!({
351 "type": "object",
352 "properties": {
353 "memory_id": {
354 "type": "string",
355 "description": "MemoryId to forget (UUID v7).",
356 },
357 "reason": {
358 "type": "string",
359 "description": "Optional free-form reason (logged, not yet persisted).",
360 },
361 },
362 "required": ["memory_id"],
363 })),
364 ),
365 Tool::new(
366 "memory.inspect",
367 "Return the full record for a memory_id (timestamps, source, \
368 status, scoring values, content).",
369 json_schema_object(serde_json::json!({
370 "type": "object",
371 "properties": {
372 "memory_id": {
373 "type": "string",
374 "description": "MemoryId to inspect (UUID v7).",
375 },
376 },
377 "required": ["memory_id"],
378 })),
379 ),
380 Tool::new(
384 "memory.themes",
385 "List recent cluster themes (the Steward's grouping of \
386 related episodes) with their LLM-generated abstractions. \
387 Use this to ask 'what has the user been thinking about \
388 lately' before deciding whether to drill into specific \
389 episodes via memory.recall. Returns up to `limit` results \
390 ordered by most-recent cluster first; pass `window_days` \
391 to scope to e.g. the last week.",
392 json_schema_object(serde_json::json!({
393 "type": "object",
394 "properties": {
395 "window_days": {
396 "type": "integer",
397 "description": "Optional time window in days. Omit for unfiltered.",
398 "minimum": 1,
399 },
400 "limit": {
401 "type": "integer",
402 "description": "Maximum results (default 5).",
403 "minimum": 1,
404 "maximum": 100,
405 },
406 },
407 })),
408 ),
409 Tool::new(
410 "memory.facts_about",
411 "Query the structured-fact knowledge graph (subject-\
412 predicate-object triples extracted by the Steward) by \
413 subject + optional predicate + optional time window. Use \
414 this to ground answers on distilled facts rather than raw \
415 episodes. Subject is required; predicate-only scans are \
416 not supported.",
417 json_schema_object(serde_json::json!({
418 "type": "object",
419 "properties": {
420 "subject": {
421 "type": "string",
422 "description": "Subject id to query (e.g. 'Sam').",
423 },
424 "predicate": {
425 "type": "string",
426 "description": "Optional predicate filter (e.g. 'works_at').",
427 },
428 "since_ms": {
429 "type": "integer",
430 "description": "Optional valid_from_ms lower bound (epoch ms).",
431 },
432 "until_ms": {
433 "type": "integer",
434 "description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through.",
435 },
436 "limit": {
437 "type": "integer",
438 "description": "Maximum results (default 5).",
439 "minimum": 1,
440 "maximum": 100,
441 },
442 },
443 "required": ["subject"],
444 })),
445 ),
446 Tool::new(
447 "memory.contradictions",
448 "List Steward-flagged contradictions (pairs of triples that \
449 disagree). Each result includes both sides' triple SPO via \
450 LEFT JOIN for context. Use this to surface conflicts and \
451 ask the user to disambiguate before relying on memory \
452 content.",
453 json_schema_object(serde_json::json!({
454 "type": "object",
455 "properties": {
456 "limit": {
457 "type": "integer",
458 "description": "Maximum results (default 5).",
459 "minimum": 1,
460 "maximum": 100,
461 },
462 },
463 })),
464 ),
465 ]
466}
467
468fn json_schema_object(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
469 match value {
470 serde_json::Value::Object(map) => map,
471 _ => panic!("json_schema_object: input must be an object"),
472 }
473}
474
475impl SoloMcpServer {
480 async fn handle_remember(
481 &self,
482 args: RememberArgs,
483 ) -> std::result::Result<CallToolResult, McpError> {
484 let content = args.content.trim_end().to_string();
485 if content.is_empty() {
486 return Err(McpError::invalid_params(
487 "memory.remember: content must not be empty".to_string(),
488 None,
489 ));
490 }
491 let embedding: solo_core::Embedding = self
492 .inner
493 .embedder
494 .embed(&content)
495 .await
496 .map_err(solo_to_mcp)?;
497 let episode = Episode {
498 memory_id: MemoryId::new(),
499 ts_ms: chrono::Utc::now().timestamp_millis(),
500 source_type: args.source_type.unwrap_or_else(|| "user_message".into()),
501 source_id: args.source_id,
502 content,
503 encoding_context: EncodingContext::default(),
504 provenance: None,
505 confidence: Confidence::new(0.9).unwrap(),
506 strength: 0.5,
507 salience: 0.5,
508 tier: Tier::Hot,
509 };
510 let mid = self
511 .inner
512 .write
513 .remember(episode, embedding)
514 .await
515 .map_err(solo_to_mcp)?;
516 Ok(CallToolResult::success(vec![Content::text(format!(
517 "remembered {mid}"
518 ))]))
519 }
520
521 async fn handle_recall(
522 &self,
523 args: RecallArgs,
524 ) -> std::result::Result<CallToolResult, McpError> {
525 let result = solo_query::run_recall(
529 &self.inner.embedder,
530 &self.inner.hnsw,
531 &self.inner.pool,
532 &args.query,
533 args.limit,
534 )
535 .await
536 .map_err(solo_to_mcp)?;
537
538 if result.hits.is_empty() {
539 return Ok(CallToolResult::success(vec![Content::text(format!(
540 "no matches (index has {} vectors)",
541 result.index_len
542 ))]));
543 }
544 let body = serde_json::to_string_pretty(&result.hits).unwrap_or_else(|_| String::new());
545 Ok(CallToolResult::success(vec![Content::text(body)]))
546 }
547
548 async fn handle_forget(
549 &self,
550 args: ForgetArgs,
551 ) -> std::result::Result<CallToolResult, McpError> {
552 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
553 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
554 })?;
555 self.inner
556 .write
557 .forget(mid, args.reason)
558 .await
559 .map_err(solo_to_mcp)?;
560 Ok(CallToolResult::success(vec![Content::text(format!(
561 "forgotten {mid}"
562 ))]))
563 }
564
565 async fn handle_inspect(
566 &self,
567 args: InspectArgs,
568 ) -> std::result::Result<CallToolResult, McpError> {
569 let mid = MemoryId::from_str(&args.memory_id).map_err(|e| {
570 McpError::invalid_params(format!("invalid memory_id: {e}"), None)
571 })?;
572 let row = solo_query::inspect_one(&self.inner.pool, mid)
574 .await
575 .map_err(solo_to_mcp)?;
576 let body = serde_json::to_string_pretty(&row).unwrap_or_else(|_| String::new());
577 Ok(CallToolResult::success(vec![Content::text(body)]))
578 }
579
580 async fn handle_themes(
587 &self,
588 args: ThemesArgs,
589 ) -> std::result::Result<CallToolResult, McpError> {
590 let hits = solo_query::themes(
591 &self.inner.pool,
592 args.window_days,
593 args.limit,
594 )
595 .await
596 .map_err(solo_to_mcp)?;
597 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
598 Ok(CallToolResult::success(vec![Content::text(body)]))
599 }
600
601 async fn handle_facts_about(
602 &self,
603 args: FactsAboutArgs,
604 ) -> std::result::Result<CallToolResult, McpError> {
605 if args.subject.trim().is_empty() {
606 return Err(McpError::invalid_params(
607 "memory.facts_about: subject must not be empty".to_string(),
608 None,
609 ));
610 }
611 let hits = solo_query::facts_about(
612 &self.inner.pool,
613 &args.subject,
614 args.predicate.as_deref(),
615 args.since_ms,
616 args.until_ms,
617 args.limit,
618 )
619 .await
620 .map_err(solo_to_mcp)?;
621 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
622 Ok(CallToolResult::success(vec![Content::text(body)]))
623 }
624
625 async fn handle_contradictions(
626 &self,
627 args: ContradictionsArgs,
628 ) -> std::result::Result<CallToolResult, McpError> {
629 let hits = solo_query::contradictions(&self.inner.pool, args.limit)
630 .await
631 .map_err(solo_to_mcp)?;
632 let body = serde_json::to_string_pretty(&hits).unwrap_or_else(|_| String::new());
633 Ok(CallToolResult::success(vec![Content::text(body)]))
634 }
635}
636
637#[cfg(test)]
638mod dispatch_tests {
639 use super::*;
651 use serde_json::json;
652 use solo_core::VectorIndex;
653 use solo_storage::test_support::StubVectorIndex;
654 use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
655 use std::sync::Arc as StdArc;
656
657 struct Harness {
658 server: SoloMcpServer,
659 _tmp: tempfile::TempDir,
660 write_handle_extra: Option<solo_storage::WriteHandle>,
661 join: Option<std::thread::JoinHandle<()>>,
662 }
663
664 impl Harness {
665 fn new(runtime: &tokio::runtime::Runtime) -> Self {
666 let tmp = tempfile::TempDir::new().unwrap();
667 let dim = 16usize;
668 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
669 let embedder: StdArc<dyn solo_core::Embedder> = StdArc::new(StubEmbedder::new("stub", "v1", dim));
670
671 let conn = solo_storage::test_support::open_test_db_at(&tmp.path().join("test.db"));
672 let WriterSpawn { handle, join } = WriterActor::spawn(conn, hnsw.clone());
673
674 let path = tmp.path().join("test.db");
677 let pool: ReaderPool =
678 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
679
680 let server = SoloMcpServer::new(handle.clone(), pool, embedder, hnsw);
681 Harness {
682 server,
683 _tmp: tmp,
684 write_handle_extra: Some(handle),
685 join: Some(join),
686 }
687 }
688
689 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
690 let join = self.join.take();
696 let extra = self.write_handle_extra.take();
697 runtime.block_on(async move {
698 drop(extra);
699 drop(self.server);
700 drop(self._tmp);
701 if let Some(join) = join {
702 let (tx, rx) = std::sync::mpsc::channel();
703 std::thread::spawn(move || {
704 let _ = tx.send(join.join());
705 });
706 tokio::task::spawn_blocking(move || {
707 rx.recv_timeout(std::time::Duration::from_secs(5))
708 })
709 .await
710 .expect("blocking task")
711 .expect("writer thread did not exit within 5s")
712 .expect("writer thread panicked");
713 }
714 });
715 }
716 }
717
718 fn rt() -> tokio::runtime::Runtime {
719 tokio::runtime::Builder::new_multi_thread()
720 .worker_threads(2)
721 .enable_all()
722 .build()
723 .unwrap()
724 }
725
726 fn first_text(r: &rmcp::model::CallToolResult) -> String {
731 let first = r.content.first().expect("at least one content item");
732 let v = serde_json::to_value(first).expect("content serialises");
733 v.get("text")
734 .and_then(|t| t.as_str())
735 .map(|s| s.to_string())
736 .unwrap_or_else(|| format!("{v}"))
737 }
738
739 #[test]
740 fn tools_list_returns_seven_canonical_tools() {
741 let runtime = rt();
742 let h = Harness::new(&runtime);
743 let tools = h.server.dispatch_list_tools();
744 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
745 assert_eq!(
746 names,
747 vec![
748 "memory.remember",
749 "memory.recall",
750 "memory.forget",
751 "memory.inspect",
752 "memory.themes",
754 "memory.facts_about",
755 "memory.contradictions",
756 ]
757 );
758 for t in &tools {
759 assert!(!t.description.is_empty(), "{} description empty", t.name);
760 let _schema = t.schema_as_json_value();
761 }
768 h.shutdown(&runtime);
769 }
770
771 #[test]
772 fn themes_returns_json_array_on_empty_db() {
773 let runtime = rt();
774 let h = Harness::new(&runtime);
775 runtime.block_on(async {
776 let r = h
777 .server
778 .dispatch_tool("memory.themes", json!({}))
779 .await
780 .expect("themes succeeds");
781 let text = first_text(&r);
782 let v: serde_json::Value =
784 serde_json::from_str(&text).expect("parses as json");
785 assert!(v.is_array(), "expected array, got: {text}");
786 assert_eq!(v.as_array().unwrap().len(), 0);
787 });
788 h.shutdown(&runtime);
789 }
790
791 #[test]
792 fn themes_passes_through_window_and_limit_args() {
793 let runtime = rt();
794 let h = Harness::new(&runtime);
795 runtime.block_on(async {
796 let r = h
798 .server
799 .dispatch_tool(
800 "memory.themes",
801 json!({ "window_days": 7, "limit": 20 }),
802 )
803 .await
804 .expect("themes with args succeeds");
805 let text = first_text(&r);
806 let v: serde_json::Value =
807 serde_json::from_str(&text).expect("parses as json");
808 assert!(v.is_array());
809 });
810 h.shutdown(&runtime);
811 }
812
813 #[test]
814 fn facts_about_rejects_empty_subject() {
815 let runtime = rt();
816 let h = Harness::new(&runtime);
817 runtime.block_on(async {
818 let err = h
819 .server
820 .dispatch_tool(
821 "memory.facts_about",
822 json!({ "subject": " " }),
823 )
824 .await
825 .expect_err("empty subject must error");
826 let s = format!("{err:?}");
829 assert!(
830 s.to_lowercase().contains("subject")
831 || s.to_lowercase().contains("invalid"),
832 "got: {s}"
833 );
834 });
835 h.shutdown(&runtime);
836 }
837
838 #[test]
839 fn facts_about_returns_array_for_unknown_subject() {
840 let runtime = rt();
841 let h = Harness::new(&runtime);
842 runtime.block_on(async {
843 let r = h
844 .server
845 .dispatch_tool(
846 "memory.facts_about",
847 json!({ "subject": "NobodyKnowsThisSubject" }),
848 )
849 .await
850 .expect("facts_about with unknown subject succeeds");
851 let text = first_text(&r);
852 let v: serde_json::Value =
853 serde_json::from_str(&text).expect("parses as json");
854 assert_eq!(v.as_array().unwrap().len(), 0);
855 });
856 h.shutdown(&runtime);
857 }
858
859 #[test]
860 fn contradictions_returns_json_array_on_empty_db() {
861 let runtime = rt();
862 let h = Harness::new(&runtime);
863 runtime.block_on(async {
864 let r = h
865 .server
866 .dispatch_tool("memory.contradictions", json!({}))
867 .await
868 .expect("contradictions succeeds");
869 let text = first_text(&r);
870 let v: serde_json::Value =
871 serde_json::from_str(&text).expect("parses as json");
872 assert!(v.is_array());
873 assert_eq!(v.as_array().unwrap().len(), 0);
874 });
875 h.shutdown(&runtime);
876 }
877
878 #[test]
879 fn remember_then_recall_round_trip() {
880 let runtime = rt();
881 let h = Harness::new(&runtime);
882 runtime.block_on(async {
888 let r = h
889 .server
890 .dispatch_tool("memory.remember", json!({ "content": "the cat sat on the mat" }))
891 .await
892 .expect("remember succeeds");
893 let text = first_text(&r);
894 assert!(text.starts_with("remembered "), "got: {text}");
895
896 let r = h
897 .server
898 .dispatch_tool(
899 "memory.recall",
900 json!({ "query": "the cat sat on the mat", "limit": 5 }),
901 )
902 .await
903 .expect("recall succeeds");
904 let text = first_text(&r);
905 assert!(text.contains("the cat sat on the mat"), "got: {text}");
906 });
907 h.shutdown(&runtime);
908 }
909
910 #[test]
911 fn forget_excludes_row_from_subsequent_recall() {
912 let runtime = rt();
913 let h = Harness::new(&runtime);
914
915 runtime.block_on(async {
916 let r = h
917 .server
918 .dispatch_tool("memory.remember", json!({ "content": "to be forgotten" }))
919 .await
920 .unwrap();
921 let text = first_text(&r);
922 let mid = text.strip_prefix("remembered ").unwrap().to_string();
923
924 h.server
925 .dispatch_tool(
926 "memory.forget",
927 json!({ "memory_id": mid, "reason": "test" }),
928 )
929 .await
930 .expect("forget succeeds");
931
932 let r = h
933 .server
934 .dispatch_tool(
935 "memory.recall",
936 json!({ "query": "to be forgotten", "limit": 5 }),
937 )
938 .await
939 .unwrap();
940 let text = first_text(&r);
941 assert!(
942 !text.contains(r#""content": "to be forgotten""#),
943 "forgotten row should be excluded; got: {text}"
944 );
945 });
946 h.shutdown(&runtime);
947 }
948
949 #[test]
950 fn empty_remember_returns_invalid_params() {
951 let runtime = rt();
952 let h = Harness::new(&runtime);
953 runtime.block_on(async {
954 let err = h
955 .server
956 .dispatch_tool("memory.remember", json!({ "content": "" }))
957 .await
958 .unwrap_err();
959 assert!(format!("{err:?}").contains("must not be empty"));
960 });
961 h.shutdown(&runtime);
962 }
963
964 #[test]
965 fn empty_recall_query_returns_invalid_params() {
966 let runtime = rt();
967 let h = Harness::new(&runtime);
968 runtime.block_on(async {
969 let err = h
970 .server
971 .dispatch_tool("memory.recall", json!({ "query": " " }))
972 .await
973 .unwrap_err();
974 assert!(format!("{err:?}").contains("must not be empty"));
975 });
976 h.shutdown(&runtime);
977 }
978
979 #[test]
980 fn inspect_with_invalid_id_returns_invalid_params() {
981 let runtime = rt();
982 let h = Harness::new(&runtime);
983 runtime.block_on(async {
984 let err = h
985 .server
986 .dispatch_tool("memory.inspect", json!({ "memory_id": "not-a-uuid" }))
987 .await
988 .unwrap_err();
989 assert!(format!("{err:?}").contains("invalid memory_id"));
990 });
991 h.shutdown(&runtime);
992 }
993
994 #[test]
995 fn forget_unknown_id_returns_invalid_params() {
996 let runtime = rt();
997 let h = Harness::new(&runtime);
998 runtime.block_on(async {
999 let err = h
1003 .server
1004 .dispatch_tool(
1005 "memory.forget",
1006 json!({ "memory_id": "00000000-0000-7000-8000-000000000000" }),
1007 )
1008 .await
1009 .unwrap_err();
1010 assert!(format!("{err:?}").contains("not found"));
1011 });
1012 h.shutdown(&runtime);
1013 }
1014
1015 #[test]
1016 fn unknown_tool_name_returns_invalid_params() {
1017 let runtime = rt();
1018 let h = Harness::new(&runtime);
1019 runtime.block_on(async {
1020 let err = h
1021 .server
1022 .dispatch_tool("memory.summon", json!({}))
1023 .await
1024 .unwrap_err();
1025 assert!(format!("{err:?}").contains("unknown tool"));
1026 });
1027 h.shutdown(&runtime);
1028 }
1029}
1030
1031