1use crate::client::OriginClient;
2use crate::types::*;
3use rmcp::{
4 handler::server::router::tool::ToolRouter,
5 handler::server::wrapper::Parameters,
6 model::{CallToolResult, Content, Implementation, InitializeResult, ServerCapabilities},
7 service::{NotificationContext, RoleServer},
8 tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler,
9};
10use serde::{Deserialize, Deserializer};
11
12fn deserialize_optional_usize_lenient<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
15where
16 D: Deserializer<'de>,
17{
18 #[derive(Deserialize)]
19 #[serde(untagged)]
20 enum StringOrNumber {
21 Number(usize),
22 Str(String),
23 }
24
25 match Option::<StringOrNumber>::deserialize(deserializer)? {
26 None => Ok(None),
27 Some(StringOrNumber::Number(n)) => Ok(Some(n)),
28 Some(StringOrNumber::Str(s)) => s
29 .parse::<usize>()
30 .map(Some)
31 .map_err(serde::de::Error::custom),
32 }
33}
34
35#[derive(Clone, Debug, PartialEq)]
37pub enum TransportMode {
38 Stdio,
40 Http,
42}
43
44#[derive(Clone)]
45pub struct OriginMcpServer {
46 #[allow(dead_code)]
47 tool_router: ToolRouter<Self>,
48 client: OriginClient,
49 transport: TransportMode,
50 agent_name: String,
51 client_name: std::sync::Arc<std::sync::Mutex<Option<String>>>,
53 user_id: Option<String>,
54}
55
56#[derive(Debug, Deserialize, schemars::JsonSchema)]
61pub struct RememberParams {
62 #[schemars(
63 description = "The memory content. Write as a complete statement with context and reasoning, not shorthand. One idea per memory."
64 )]
65 pub content: String,
66 #[schemars(
67 description = "\"profile\" (about the user) or \"knowledge\" (about the world) — or precise: \"identity\", \"preference\", \"goal\", \"fact\", \"decision\" — auto-classified if omitted"
68 )]
69 pub memory_type: Option<String>,
70 #[schemars(
71 description = "Topic scope (e.g. 'rust', 'work', 'health', 'origin'). Auto-detected if omitted."
72 )]
73 pub domain: Option<String>,
74 #[schemars(
75 description = "Person, project, or tool name to anchor to (e.g. 'Alice', 'Origin', 'PostgreSQL'). Helps build the knowledge graph."
76 )]
77 pub entity: Option<String>,
78 #[schemars(
79 description = "0.0-1.0. Leave unset for auto-calculation based on type and trust level. Set low (0.3-0.5) for uncertain info, high (0.8-1.0) for user-stated facts."
80 )]
81 pub confidence: Option<f32>,
82 #[schemars(
83 description = "source_id of a memory this replaces. Use when correcting or updating an existing memory — get the ID from recall first."
84 )]
85 pub supersedes: Option<String>,
86 #[schemars(
87 description = "Pre-extracted structured fields as a JSON object. Auto-extracted by backend; only supply if you have high-quality structured data already."
88 )]
89 pub structured_fields: Option<serde_json::Map<String, serde_json::Value>>,
90 #[schemars(
91 description = "A question this memory answers, for search matching. Auto-generated by backend; only supply to override."
92 )]
93 pub retrieval_cue: Option<String>,
94}
95
96#[derive(Debug, Deserialize, schemars::JsonSchema)]
97pub struct RecallParams {
98 #[schemars(
99 description = "Natural language search. Be specific: 'Alice database preference' finds more than 'database stuff'."
100 )]
101 pub query: String,
102 #[schemars(
103 description = "Max results, default 10. Use 3-5 for quick lookups, 10-20 for exploration."
104 )]
105 #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
106 pub limit: Option<usize>,
107 #[schemars(
108 description = "Filter by type. Two-level filter: \"profile\" (user-facing) or \"knowledge\" (world-facing), or precise: identity, preference, goal, fact, decision."
109 )]
110 pub memory_type: Option<String>,
111 #[schemars(description = "Filter by topic scope.")]
112 pub domain: Option<String>,
113}
114
115#[derive(Debug, Deserialize, schemars::JsonSchema)]
116pub struct ContextParams {
117 #[schemars(
118 description = "Topic or conversation summary to focus context retrieval. Omit at session start for general orientation; provide when shifting topics."
119 )]
120 pub topic: Option<String>,
121 #[schemars(
122 description = "Max context chunks, default 20. Increase for complex topics, decrease for quick check-ins."
123 )]
124 #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
125 pub limit: Option<usize>,
126 #[schemars(
127 description = "Scope context to a domain/space (e.g. 'work', 'personal'). Auto-detected from conversation if omitted."
128 )]
129 pub domain: Option<String>,
130}
131
132#[derive(Debug, Deserialize, schemars::JsonSchema)]
133pub struct ForgetParams {
134 #[schemars(
135 description = "The source_id of the memory to delete. Get this from recall results first."
136 )]
137 pub memory_id: String,
138}
139
140fn format_remember_success(resp: &StoreMemoryResponse) -> String {
143 let mut msg = format!("Stored {}", resp.source_id);
144 if !resp.warnings.is_empty() {
145 msg.push_str("\nWarnings:");
146 for warning in &resp.warnings {
147 msg.push_str(&format!("\n - {}", warning));
148 }
149 }
150 msg
151}
152
153impl OriginMcpServer {
154 fn resolve_source_agent(&self, param_agent: Option<String>) -> Option<String> {
157 if let Some(ref agent) = param_agent {
159 if !agent.is_empty() {
160 return param_agent;
161 }
162 }
163 if let Ok(guard) = self.client_name.lock() {
165 if let Some(ref name) = *guard {
166 return Some(name.clone());
167 }
168 }
169 Some(self.agent_name.clone())
171 }
172
173 fn resolve_user_id(&self, param_user_id: Option<String>) -> Option<String> {
176 if self.transport == TransportMode::Http {
177 self.user_id.clone().or(param_user_id)
178 } else {
179 param_user_id
180 }
181 }
182
183 pub async fn remember_impl(&self, params: RememberParams) -> Result<CallToolResult, McpError> {
184 let source_agent = self.resolve_source_agent(None);
185 if let Some(uid) = self.resolve_user_id(None) {
186 tracing::debug!(user_id = %uid, "remember invoked");
187 }
188
189 let req = StoreMemoryRequest {
190 content: params.content,
191 memory_type: params.memory_type,
192 domain: params.domain,
193 source_agent,
194 title: None,
195 confidence: params.confidence,
196 supersedes: params.supersedes,
197 entity: params.entity,
198 entity_id: None,
199 structured_fields: params.structured_fields.map(serde_json::Value::Object),
200 retrieval_cue: params.retrieval_cue,
201 };
202
203 let resp: StoreMemoryResponse = self
204 .client
205 .post("/api/memory/store", &req)
206 .await
207 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
208
209 Ok(CallToolResult::success(vec![Content::text(
210 format_remember_success(&resp),
211 )]))
212 }
213
214 pub async fn recall_impl(&self, params: RecallParams) -> Result<CallToolResult, McpError> {
215 let req = SearchMemoryRequest {
216 query: params.query,
217 limit: params.limit.unwrap_or(10),
218 memory_type: params.memory_type,
219 domain: params.domain,
220 source_agent: self.resolve_source_agent(None),
221 };
222
223 let resp: SearchMemoryResponse = self
224 .client
225 .post("/api/memory/search", &req)
226 .await
227 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
228
229 let json = serde_json::to_string_pretty(&resp.results)
230 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
231
232 Ok(CallToolResult::success(vec![Content::text(format!(
233 "{} results ({:.1}ms)\n{}",
234 resp.results.len(),
235 resp.took_ms,
236 json
237 ))]))
238 }
239
240 pub async fn context_impl(&self, params: ContextParams) -> Result<CallToolResult, McpError> {
241 let req = ChatContextRequest {
242 query: None,
243 conversation_id: params.topic,
244 max_chunks: params.limit.unwrap_or(20),
245 relevance_threshold: None,
246 include_goals: true,
247 domain: params.domain,
248 };
249
250 let raw: serde_json::Value = self
258 .client
259 .post("/api/chat-context", &req)
260 .await
261 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
262
263 let context = raw
264 .get("context")
265 .and_then(|v| v.as_str())
266 .unwrap_or_default()
267 .to_string();
268
269 if context.is_empty() {
270 Ok(CallToolResult::success(vec![Content::text(
271 "No relevant context found".to_string(),
272 )]))
273 } else {
274 Ok(CallToolResult::success(vec![Content::text(context)]))
275 }
276 }
277
278 pub async fn forget_impl(&self, memory_id: &str) -> Result<CallToolResult, McpError> {
279 if self.transport == TransportMode::Http {
280 return Ok(CallToolResult::error(vec![Content::text(
281 "Delete operations are not available over remote connections. \
282 Use the Origin desktop app to delete memories."
283 .to_string(),
284 )]));
285 }
286
287 let resp: DeleteResponse = self
288 .client
289 .delete(&format!("/api/memory/delete/{}", memory_id))
290 .await
291 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
292
293 Ok(CallToolResult::success(vec![Content::text(
294 if resp.deleted {
295 "Memory deleted"
296 } else {
297 "Memory not found"
298 }
299 .to_string(),
300 )]))
301 }
302}
303
304#[tool_router]
307impl OriginMcpServer {
308 pub fn new(
309 client: OriginClient,
310 transport: TransportMode,
311 agent_name: String,
312 user_id: Option<String>,
313 ) -> Self {
314 Self {
315 tool_router: Self::tool_router(),
316 client,
317 transport,
318 agent_name,
319 client_name: std::sync::Arc::new(std::sync::Mutex::new(None)),
320 user_id,
321 }
322 }
323
324 #[tool(
327 description = "Store a memory. Call PROACTIVELY when you learn something durable about the user — preferences, decisions, corrections, or facts about people/projects/tools they care about. Don't wait for the user to say 'remember this' — that's a floor, not a trigger.\n\nWrite content as a complete, self-contained statement — someone reading it months later with no conversation context should understand it. Include the WHY, not just the WHAT. Name people, projects, and tools explicitly.\n\nThe backend auto-classifies type, extracts structured fields, detects entities, and links to the knowledge graph. You don't need to set memory_type or structured_fields unless you're confident — omitting them gets better results than guessing wrong.\n\nDo NOT store: system prompts, boot logs, heartbeat/health checks, transient task state ('currently working on...'), tool output/responses, architecture dumps, single-word acknowledgments, or content you have already stored. Focus on durable facts, preferences, decisions, goals, and identity information. Each call is one atomic idea — \"prefers TDD\" and \"uses pytest\" are two calls, not one.",
328 annotations(
329 title = "Remember",
330 read_only_hint = false,
331 destructive_hint = false,
332 idempotent_hint = false,
333 open_world_hint = false
334 )
335 )]
336 async fn remember(
337 &self,
338 Parameters(params): Parameters<RememberParams>,
339 ) -> Result<CallToolResult, McpError> {
340 self.remember_impl(params).await
341 }
342
343 #[tool(
344 description = "Search memories by query. Use when the user asks 'do you remember', 'what do you know about', 'look up', or when you need a specific fact before acting.\n\nWrite queries as natural language — the search engine handles semantic matching. For precision, use filters (memory_type, domain) to narrow results. If you get too many results, add filters rather than making the query longer.\n\nThis is for targeted lookups. For broad session orientation, use context instead.",
345 annotations(title = "Recall", read_only_hint = true, open_world_hint = false)
346 )]
347 async fn recall(
348 &self,
349 Parameters(params): Parameters<RecallParams>,
350 ) -> Result<CallToolResult, McpError> {
351 self.recall_impl(params).await
352 }
353
354 #[tool(
355 description = "Load session context — identity, preferences, goals, and topic-relevant memories. Call this FIRST at the start of every session before doing anything else. Also call on major topic shifts or when the user says 'catch me up' or 'what's the background on'.\n\nThis returns a curated blend of who the user is and what's relevant. For specific factual lookups, use recall instead. Use the result to model how the user thinks, not just to look things up — their preferences and corrections tell you how they want to be helped.",
356 annotations(title = "Context", read_only_hint = true, open_world_hint = false)
357 )]
358 async fn context(
359 &self,
360 Parameters(params): Parameters<ContextParams>,
361 ) -> Result<CallToolResult, McpError> {
362 self.context_impl(params).await
363 }
364
365 #[tool(
366 description = "Delete a memory by ID. Use when the user says 'forget this', 'delete that', 'that's wrong and should be removed'. Requires the source_id — get it from recall first.\n\nThis is destructive and cannot be undone. For corrections, prefer storing a new memory with the supersedes param pointing to the old one — this preserves history.",
367 annotations(
368 title = "Forget",
369 read_only_hint = false,
370 destructive_hint = true,
371 idempotent_hint = true,
372 open_world_hint = false
373 )
374 )]
375 async fn forget(
376 &self,
377 Parameters(params): Parameters<ForgetParams>,
378 ) -> Result<CallToolResult, McpError> {
379 self.forget_impl(¶ms.memory_id).await
380 }
381}
382
383#[tool_handler]
386impl ServerHandler for OriginMcpServer {
387 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
388 if let Some(client_info) = context.peer.peer_info() {
390 let name = &client_info.client_info.name;
391 if !name.is_empty() {
392 if let Ok(mut guard) = self.client_name.lock() {
393 tracing::info!("MCP client identified: {}", name);
394 *guard = Some(name.clone());
395 }
396 }
397 }
398 }
399
400 fn get_info(&self) -> InitializeResult {
401 InitializeResult::new(
402 ServerCapabilities::builder()
403 .enable_tools()
404 .build(),
405 )
406 .with_server_info(
407 Implementation::new("origin-mcp", env!("CARGO_PKG_VERSION"))
408 )
409 .with_instructions(
410 "Origin is your personal memory layer — a local knowledge base that persists across sessions and tools.\n\
411 Think of yourself as a curator, not a logger. Store insights, not conversation artifacts.\n\n\
412 Origin is self-evolving — each memory you store contributes to a knowledge structure that grows over time. \
413 It's also shared across all the user's tools: what you write, other agents (Claude Desktop, Claude Code, \
414 ChatGPT, Cursor, etc.) will read later. Write for any future reader, not just this conversation.\n\n\
415 FIRST THING EVERY SESSION: Call context to load the user's identity, preferences, goals, and\n\
416 topic-relevant memories. This is how you know who you're talking to. Use the result to model how the \
417 user thinks — their preferences, corrections, and past decisions tell you how they want to be helped, \
418 not just what they already know.\n\n\
419 STORE PROACTIVELY — don't wait for the user to ask.\n\
420 - The user states a preference (\"I use X because...\", \"I prefer Y over Z\")\n\
421 - The user makes a decision (\"going with approach A\", \"switching to B\")\n\
422 - The user corrects you or prior info (\"actually, it's C, not D\") — store the correction so it sticks\n\
423 - The user shares a durable fact about themselves, their work, or people/projects/tools they care about — \
424 anchor it to the entity\n\n\
425 If the user asks explicitly (\"remember this\", \"save this\", \"don't forget\"), that's a floor — you \
426 should have already stored it.\n\n\
427 WHEN NOT TO STORE:\n\
428 - Conversation filler (\"ok\", \"thanks\", \"let's move on\")\n\
429 - Things the user can trivially re-derive (file paths, recent git history)\n\
430 - Anything already stored — recall first if unsure\n\
431 - Tool output or command results (file contents, git history, build logs) — these are derivable\n\
432 - General world facts or documentation that aren't personal to this user (e.g., \"Rust has a borrow \
433 checker\", \"PostgreSQL supports JSONB\") — those are not memory material.\n\
434 - Your own inferences about the user that they didn't express. Store what they said; infer from that \
435 when responding.\n\n\
436 CONTENT QUALITY — this is where you make the biggest difference:\n\
437 - Specific beats vague: \"prefers Rust for CLI tools because of compile-time safety\" > \"likes Rust\"\n\
438 - Include the WHY: the backend can classify \"dark mode\" as a preference, but only you know\n\
439 \"switched to dark mode because of migraines from bright screens\"\n\
440 - Name the entities: mention people, projects, tools by name — this powers the knowledge graph\n\
441 - Atomic: one idea per memory — \"prefers TDD\" and \"uses pytest\" should be two memories, not one\n\
442 - Declarative, not narrative: \"User prefers X because Y\" — not \"User said today they prefer X\". \
443 Memories outlive the conversation that produced them.\n\n\
444 MEMORY TYPES — omit and trust the backend.\n\n\
445 By default, do NOT set memory_type. The backend auto-classifies into identity / preference / goal / \
446 fact / decision with more context than you have. Agents that over-specify types tend to pick wrong.\n\n\
447 Opt-in specification:\n\
448 - \"profile\" — you're sure it's about the user (identity/preference/goal)\n\
449 - \"knowledge\" — you're sure it's about the world (fact/decision)\n\
450 - Precise type — only if you're confident and the distinction matters.\n\n\
451 EXCEPTION — decisions carry structured fields (alternatives considered, reversibility, domain) \
452 that power the Decision Log view. Set memory_type=\"decision\" explicitly ONLY when the user \
453 articulated alternatives weighed AND the reasoning for the choice. A bare \"I'm switching to Cursor\" \
454 is just a preference change — omit the type. \"Switching to Cursor over VSCode because of better \
455 Claude integration, and we can always go back\" — that's a decision.\n\n\
456 RECALL vs CONTEXT:\n\
457 - context: broad orientation, session start, topic shifts, \"catch me up\"\n\
458 - recall: specific lookup (\"what's Alice's role?\", \"database preferences\", \"our auth decision\")\n\n\
459 The backend handles classification, entity extraction, structured fields, quality scoring,\n\
460 and dedup — you don't need to replicate that logic. Focus on what only you know:\n\
461 the conversational context, why something matters, and what the user actually cares about."
462 )
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use crate::client::OriginClient;
470 use crate::types::{
471 ChatContextRequest, ChatContextResponse, SearchMemoryRequest, SearchResult,
472 StoreMemoryRequest, StoreMemoryResponse,
473 };
474
475 fn make_server(
476 transport: TransportMode,
477 agent_name: &str,
478 user_id: Option<&str>,
479 ) -> OriginMcpServer {
480 let client = OriginClient::new("http://127.0.0.1:19999".into());
481 OriginMcpServer::new(
482 client,
483 transport,
484 agent_name.into(),
485 user_id.map(String::from),
486 )
487 }
488
489 #[test]
492 fn test_http_mode_prefers_param_over_agent_name() {
493 let server = make_server(TransportMode::Http, "claude.ai", None);
494 let result = server.resolve_source_agent(Some("user-provided".into()));
496 assert_eq!(result, Some("user-provided".into()));
497 }
498
499 #[test]
500 fn test_http_mode_sets_source_agent_when_none() {
501 let server = make_server(TransportMode::Http, "chatgpt", None);
502 let result = server.resolve_source_agent(None);
503 assert_eq!(result, Some("chatgpt".into()));
504 }
505
506 #[test]
507 fn test_stdio_mode_passes_through_source_agent() {
508 let server = make_server(TransportMode::Stdio, "ignored", None);
509 let result = server.resolve_source_agent(Some("user-provided".into()));
510 assert_eq!(result, Some("user-provided".into()));
511 }
512
513 #[test]
514 fn test_stdio_mode_falls_back_to_agent_name() {
515 let server = make_server(TransportMode::Stdio, "fallback", None);
516 let result = server.resolve_source_agent(None);
518 assert_eq!(result, Some("fallback".into()));
519 }
520
521 #[test]
522 fn test_http_mode_resolves_configured_user_id_for_local_use() {
523 let server = make_server(TransportMode::Http, "agent", Some("lucian"));
524 let result = server.resolve_user_id(None);
525 assert_eq!(result, Some("lucian".into()));
526 }
527
528 #[test]
529 fn test_transport_mode_equality() {
530 assert_eq!(TransportMode::Stdio, TransportMode::Stdio);
531 assert_eq!(TransportMode::Http, TransportMode::Http);
532 assert_ne!(TransportMode::Stdio, TransportMode::Http);
533 }
534
535 #[test]
538 fn test_remember_params_minimal() {
539 let json = r#"{"content": "Lucian prefers dark mode"}"#;
540 let params: RememberParams = serde_json::from_str(json).unwrap();
541 assert_eq!(params.content, "Lucian prefers dark mode");
542 assert!(params.memory_type.is_none());
543 assert!(params.domain.is_none());
544 assert!(params.entity.is_none());
545 assert!(params.confidence.is_none());
546 assert!(params.supersedes.is_none());
547 }
548
549 #[test]
550 fn test_remember_params_full() {
551 let json = r#"{
552 "content": "We chose PostgreSQL over MongoDB",
553 "memory_type": "decision",
554 "domain": "origin",
555 "entity": "PostgreSQL",
556 "confidence": 0.95,
557 "supersedes": "mem_abc123"
558 }"#;
559 let params: RememberParams = serde_json::from_str(json).unwrap();
560 assert_eq!(params.content, "We chose PostgreSQL over MongoDB");
561 assert_eq!(params.memory_type.as_deref(), Some("decision"));
562 assert_eq!(params.domain.as_deref(), Some("origin"));
563 assert_eq!(params.entity.as_deref(), Some("PostgreSQL"));
564 assert_eq!(params.confidence, Some(0.95));
565 assert_eq!(params.supersedes.as_deref(), Some("mem_abc123"));
566 }
567
568 #[test]
569 fn test_remember_params_missing_content_fails() {
570 let json = r#"{"memory_type": "fact"}"#;
571 let result = serde_json::from_str::<RememberParams>(json);
572 assert!(result.is_err());
573 }
574
575 #[test]
578 fn test_recall_params_minimal() {
579 let json = r#"{"query": "what does Alice work on?"}"#;
580 let params: RecallParams = serde_json::from_str(json).unwrap();
581 assert_eq!(params.query, "what does Alice work on?");
582 assert!(params.limit.is_none());
583 }
584
585 #[test]
586 fn test_recall_params_full() {
587 let json = r#"{
588 "query": "database preferences",
589 "limit": 5,
590 "memory_type": "decision",
591 "domain": "origin"
592 }"#;
593 let params: RecallParams = serde_json::from_str(json).unwrap();
594 assert_eq!(params.query, "database preferences");
595 assert_eq!(params.limit, Some(5));
596 assert_eq!(params.memory_type.as_deref(), Some("decision"));
597 assert_eq!(params.domain.as_deref(), Some("origin"));
598 }
599
600 #[test]
601 fn test_recall_params_limit_as_string() {
602 let json = r#"{"query": "test", "limit": "10"}"#;
603 let params: RecallParams = serde_json::from_str(json).unwrap();
604 assert_eq!(params.limit, Some(10));
605 }
606
607 #[test]
608 fn test_recall_params_missing_query_fails() {
609 let json = r#"{"limit": 5}"#;
610 let result = serde_json::from_str::<RecallParams>(json);
611 assert!(result.is_err());
612 }
613
614 #[test]
617 fn test_context_params_empty() {
618 let json = r#"{}"#;
619 let params: ContextParams = serde_json::from_str(json).unwrap();
620 assert!(params.topic.is_none());
621 assert!(params.limit.is_none());
622 assert!(params.domain.is_none());
623 }
624
625 #[test]
626 fn test_context_params_full() {
627 let json = r#"{"topic": "project Origin architecture", "limit": 30, "domain": "work"}"#;
628 let params: ContextParams = serde_json::from_str(json).unwrap();
629 assert_eq!(params.topic.as_deref(), Some("project Origin architecture"));
630 assert_eq!(params.limit, Some(30));
631 assert_eq!(params.domain.as_deref(), Some("work"));
632 }
633
634 #[test]
635 fn test_context_params_limit_as_string() {
636 let json = r#"{"limit": "20"}"#;
637 let params: ContextParams = serde_json::from_str(json).unwrap();
638 assert_eq!(params.limit, Some(20));
639 }
640
641 #[test]
642 fn store_memory_request_serialization_excludes_user_id() {
643 let req = StoreMemoryRequest {
644 content: "test content".into(),
645 memory_type: None,
646 domain: None,
647 source_agent: Some("test-agent".into()),
648 title: None,
649 confidence: None,
650 supersedes: None,
651 entity: None,
652 entity_id: None,
653 structured_fields: None,
654 retrieval_cue: None,
655 };
656 let json = serde_json::to_value(&req).unwrap();
657 let obj = json.as_object().unwrap();
658 assert!(
659 !obj.contains_key("user_id"),
660 "user_id must not be on the wire; got: {:?}",
661 obj.keys().collect::<Vec<_>>()
662 );
663 }
664
665 #[test]
666 fn remember_success_message_is_terse() {
667 let resp = StoreMemoryResponse {
668 source_id: "mem_abc".into(),
669 chunks_created: 3,
670 memory_type: "fact".into(),
671 entity_id: Some("ent_xyz".into()),
672 quality: Some("high".into()),
673 warnings: vec![],
674 extraction_method: "llm".into(),
675 };
676 let msg = format_remember_success(&resp);
677 assert_eq!(msg, "Stored mem_abc");
678 assert!(!msg.contains("chunks"));
679 assert!(!msg.contains("quality"));
680 assert!(!msg.contains("entity"));
681 }
682
683 #[test]
684 fn remember_success_message_surfaces_warnings() {
685 let resp = StoreMemoryResponse {
686 source_id: "mem_abc".into(),
687 chunks_created: 1,
688 memory_type: "decision".into(),
689 entity_id: None,
690 quality: None,
691 warnings: vec!["decision memory missing required 'claim' field".into()],
692 extraction_method: "agent".into(),
693 };
694 let msg = format_remember_success(&resp);
695 assert!(msg.starts_with("Stored mem_abc"));
696 assert!(msg.contains("Warnings:"));
697 assert!(msg.contains("decision memory missing required 'claim' field"));
698 }
699
700 #[test]
701 fn search_memory_request_serialization_excludes_entity() {
702 let req = SearchMemoryRequest {
703 query: "test".into(),
704 limit: 10,
705 memory_type: None,
706 domain: None,
707 source_agent: None,
708 };
709 let json = serde_json::to_value(&req).unwrap();
710 let obj = json.as_object().unwrap();
711 assert!(
712 !obj.contains_key("entity"),
713 "entity must not be on the wire; got keys: {:?}",
714 obj.keys().collect::<Vec<_>>()
715 );
716 }
717
718 #[test]
719 fn chat_context_request_serialization_includes_domain() {
720 let req = ChatContextRequest {
721 query: None,
722 conversation_id: Some("topic".into()),
723 max_chunks: 20,
724 relevance_threshold: None,
725 include_goals: true,
726 domain: Some("work".into()),
727 };
728 let json = serde_json::to_value(&req).unwrap();
729 assert_eq!(json["domain"], serde_json::json!("work"));
730 assert_eq!(json["conversation_id"], serde_json::json!("topic"));
731 }
732
733 #[test]
734 fn chat_context_response_deserializes_with_profile_and_knowledge() {
735 let json = r#"{
736 "context": "user is Lucian, prefers Rust",
737 "profile": {
738 "narrative": "n",
739 "identity": ["rust"],
740 "preferences": [],
741 "goals": []
742 },
743 "knowledge": {
744 "concepts": [],
745 "decisions": [],
746 "relevant_memories": [],
747 "graph_context": []
748 },
749 "took_ms": 42.0,
750 "token_estimates": {
751 "tier1_identity": 10,
752 "tier2_project": 20,
753 "tier3_relevant": 30,
754 "total": 60
755 }
756 }"#;
757 let parsed: ChatContextResponse = serde_json::from_str(json).unwrap();
758 assert_eq!(parsed.context, "user is Lucian, prefers Rust");
759 assert_eq!(parsed.profile.identity, vec!["rust"]);
760 assert_eq!(parsed.token_estimates.total, 60);
761 }
762
763 #[test]
764 fn remember_params_structured_fields_schema_is_object() {
765 use schemars::schema_for;
766
767 let schema = schema_for!(RememberParams);
768 let json = serde_json::to_value(&schema).unwrap();
769 let sf_schema = json
770 .pointer("/properties/structured_fields")
771 .expect("structured_fields property in schema");
772 let type_val = sf_schema
773 .pointer("/type")
774 .unwrap_or(&serde_json::Value::Null);
775 let type_str = match type_val {
776 serde_json::Value::String(s) => s.clone(),
777 serde_json::Value::Array(arr) => arr
778 .iter()
779 .filter_map(|v| v.as_str())
780 .collect::<Vec<_>>()
781 .join(","),
782 other => panic!(
783 "structured_fields schema lacks type constraint; got: {:?}",
784 other
785 ),
786 };
787 assert!(
788 type_str.contains("object"),
789 "expected object type, got: {}",
790 type_str
791 );
792 }
793
794 #[test]
797 fn test_forget_params() {
798 let json = r#"{"memory_id": "mem_abc123"}"#;
799 let params: ForgetParams = serde_json::from_str(json).unwrap();
800 assert_eq!(params.memory_id, "mem_abc123");
801 }
802
803 #[test]
804 fn test_forget_params_missing_id_fails() {
805 let json = r#"{}"#;
806 let result = serde_json::from_str::<ForgetParams>(json);
807 assert!(result.is_err());
808 }
809
810 #[test]
813 fn test_store_request_includes_new_fields() {
814 let req = StoreMemoryRequest {
815 content: "test".into(),
816 memory_type: Some("decision".into()),
817 domain: None,
818 source_agent: Some("claude".into()),
819 title: None,
820 confidence: Some(0.9),
821 supersedes: Some("old_id".into()),
822 entity: Some("PostgreSQL".into()),
823 entity_id: None,
824 structured_fields: None,
825 retrieval_cue: None,
826 };
827 let json = serde_json::to_value(&req).unwrap();
828 assert_eq!(json["entity"], "PostgreSQL");
829 assert_eq!(json["supersedes"], "old_id");
830 assert!(json["confidence"].as_f64().unwrap() > 0.89);
831 assert_eq!(json["source_agent"], "claude");
832 assert!(json.get("user_id").is_none());
833 }
834
835 #[test]
836 fn test_store_request_minimal() {
837 let req = StoreMemoryRequest {
838 content: "hello".into(),
839 memory_type: Some("fact".into()),
840 domain: None,
841 source_agent: None,
842 title: None,
843 confidence: None,
844 supersedes: None,
845 entity: None,
846 entity_id: None,
847 structured_fields: None,
848 retrieval_cue: None,
849 };
850 let json = serde_json::to_value(&req).unwrap();
851 assert_eq!(json["content"], "hello");
852 assert_eq!(json["memory_type"], "fact");
853 assert!(json.get("user_id").is_none());
854 }
855
856 #[test]
859 fn test_store_response_with_new_fields() {
860 let json = r#"{
861 "source_id": "mem_xyz",
862 "chunks_created": 2,
863 "memory_type": "fact",
864 "entity_id": "ent_abc",
865 "quality": "high",
866 "warnings": ["decision memory missing claim"],
867 "extraction_method": "agent"
868 }"#;
869 let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
870 assert_eq!(resp.source_id, "mem_xyz");
871 assert_eq!(resp.chunks_created, 2);
872 assert_eq!(resp.memory_type, "fact");
873 assert_eq!(resp.entity_id.as_deref(), Some("ent_abc"));
874 assert_eq!(resp.quality.as_deref(), Some("high"));
875 assert_eq!(resp.warnings, vec!["decision memory missing claim"]);
876 assert_eq!(resp.extraction_method, "agent");
877 }
878
879 #[test]
880 fn test_store_response_backward_compat_no_new_fields() {
881 let json = r#"{
883 "source_id": "mem_old",
884 "chunks_created": 1,
885 "memory_type": "fact"
886 }"#;
887 let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
888 assert_eq!(resp.source_id, "mem_old");
889 assert_eq!(resp.chunks_created, 1);
890 assert_eq!(resp.memory_type, "fact");
891 assert!(resp.entity_id.is_none());
892 assert!(resp.quality.is_none());
893 assert!(resp.warnings.is_empty());
894 assert_eq!(resp.extraction_method, "unknown");
895 }
896
897 #[test]
898 fn test_store_response_with_warnings_and_extraction_method() {
899 let json = r#"{
900 "source_id": "mem_xyz",
901 "chunks_created": 1,
902 "memory_type": "decision",
903 "warnings": ["decision memory missing required 'claim' field"],
904 "extraction_method": "llm"
905 }"#;
906 let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
907 assert_eq!(resp.memory_type, "decision");
908 assert_eq!(
909 resp.warnings,
910 vec!["decision memory missing required 'claim' field"]
911 );
912 assert_eq!(resp.extraction_method, "llm");
913 }
914
915 #[test]
918 fn test_search_result_with_new_fields() {
919 let json = r#"{
920 "id": "1",
921 "content": "We chose Postgres",
922 "source": "memory",
923 "source_id": "mem_1",
924 "title": "DB decision",
925 "url": null,
926 "chunk_index": 0,
927 "last_modified": 1711000000,
928 "score": 0.95,
929 "chunk_type": "memory",
930 "language": "en",
931 "semantic_unit": "sentence",
932 "memory_type": "decision",
933 "domain": "origin",
934 "source_agent": "claude",
935 "confidence": 0.9,
936 "confirmed": true,
937 "stability": "standard",
938 "supersedes": "mem_0",
939 "summary": "DB choice",
940 "entity_id": "ent_pg",
941 "entity_name": "PostgreSQL",
942 "quality": "high",
943 "is_archived": false,
944 "is_recap": false,
945 "source_text": "We chose Postgres",
946 "raw_score": 0.42
947 }"#;
948 let result: SearchResult = serde_json::from_str(json).unwrap();
949 assert_eq!(result.chunk_type.as_deref(), Some("memory"));
950 assert_eq!(result.language.as_deref(), Some("en"));
951 assert_eq!(result.semantic_unit.as_deref(), Some("sentence"));
952 assert_eq!(result.stability.as_deref(), Some("standard"));
953 assert_eq!(result.supersedes.as_deref(), Some("mem_0"));
954 assert_eq!(result.summary.as_deref(), Some("DB choice"));
955 assert_eq!(result.entity_id.as_deref(), Some("ent_pg"));
956 assert_eq!(result.entity_name.as_deref(), Some("PostgreSQL"));
957 assert_eq!(result.quality.as_deref(), Some("high"));
958 assert!(!result.is_archived);
959 assert!(!result.is_recap);
960 assert_eq!(result.source_text.as_deref(), Some("We chose Postgres"));
961 assert!((result.raw_score - 0.42).abs() < f32::EPSILON);
962 }
963
964 #[test]
965 fn test_search_result_backward_compat_no_new_fields() {
966 let json = r#"{
968 "id": "1",
969 "content": "test",
970 "source": "memory",
971 "source_id": "mem_1",
972 "title": "test",
973 "url": null,
974 "chunk_index": 0,
975 "last_modified": 1711000000,
976 "score": 0.8,
977 "memory_type": "fact",
978 "domain": null,
979 "source_agent": null,
980 "confidence": null,
981 "confirmed": null
982 }"#;
983 let result: SearchResult = serde_json::from_str(json).unwrap();
984 assert!(result.entity_id.is_none());
985 assert!(result.entity_name.is_none());
986 assert!(result.quality.is_none());
987 assert!(!result.is_archived);
988 assert!(!result.is_recap);
989 assert!(result.structured_fields.is_none());
990 assert!(result.retrieval_cue.is_none());
991 assert_eq!(result.raw_score, 0.0);
992 }
993
994 #[test]
995 fn test_search_result_with_structured_fields_and_retrieval_cue() {
996 let json = r#"{
997 "id": "1",
998 "content": "Lucian prefers dark mode",
999 "source": "memory",
1000 "source_id": "mem_1",
1001 "title": "Dark mode preference",
1002 "url": null,
1003 "chunk_index": 0,
1004 "last_modified": 1711000000,
1005 "score": 0.92,
1006 "memory_type": "preference",
1007 "domain": null,
1008 "source_agent": null,
1009 "confidence": null,
1010 "confirmed": null,
1011 "structured_fields": "{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}",
1012 "retrieval_cue": "What UI theme does Lucian prefer?"
1013 }"#;
1014 let result: SearchResult = serde_json::from_str(json).unwrap();
1015 assert_eq!(
1016 result.structured_fields.as_deref(),
1017 Some("{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}")
1018 );
1019 assert_eq!(
1020 result.retrieval_cue.as_deref(),
1021 Some("What UI theme does Lucian prefer?")
1022 );
1023 assert!(!result.is_archived);
1024 assert!(!result.is_recap);
1025 assert_eq!(result.raw_score, 0.0);
1026 }
1027
1028 #[test]
1029 fn test_search_result_knowledge_graph_source() {
1030 let json = r#"{
1032 "id": "obs_1",
1033 "content": "Prefers Rust over Go",
1034 "source": "knowledge_graph",
1035 "source_id": "ent_lucian",
1036 "title": "Lucian",
1037 "url": null,
1038 "chunk_index": 0,
1039 "last_modified": 1711000000,
1040 "score": 1.14,
1041 "memory_type": null,
1042 "domain": null,
1043 "source_agent": null,
1044 "confidence": null,
1045 "confirmed": null,
1046 "entity_id": "ent_lucian",
1047 "entity_name": "Lucian"
1048 }"#;
1049 let result: SearchResult = serde_json::from_str(json).unwrap();
1050 assert_eq!(result.source, "knowledge_graph");
1051 assert_eq!(result.entity_id.as_deref(), Some("ent_lucian"));
1052 assert_eq!(result.entity_name.as_deref(), Some("Lucian"));
1053 assert!(!result.is_archived);
1054 assert!(!result.is_recap);
1055 assert_eq!(result.raw_score, 0.0);
1056 }
1057
1058 #[tokio::test]
1061 async fn test_forget_blocked_on_http_transport() {
1062 let server = make_server(TransportMode::Http, "agent", None);
1063 let result = server.forget_impl("mem_123").await.unwrap();
1064 let content = &result.content[0];
1066 match content.raw {
1067 rmcp::model::RawContent::Text(ref tc) => {
1068 assert!(tc.text.contains("not available over remote connections"));
1069 }
1070 _ => panic!("expected text content"),
1071 }
1072 }
1073
1074 #[tokio::test]
1075 async fn test_forget_allowed_on_stdio_transport() {
1076 let server = make_server(TransportMode::Stdio, "agent", None);
1079 let result = server.forget_impl("mem_123").await;
1080 assert!(
1081 result.is_err(),
1082 "should fail with connection error, not transport block"
1083 );
1084 }
1085
1086 #[test]
1089 fn test_context_request_default_limit() {
1090 let params = ContextParams {
1091 topic: Some("test".into()),
1092 limit: None,
1093 domain: None,
1094 };
1095 let req = ChatContextRequest {
1096 query: None,
1097 conversation_id: params.topic,
1098 max_chunks: params.limit.unwrap_or(20),
1099 relevance_threshold: None,
1100 include_goals: true,
1101 domain: params.domain,
1102 };
1103 assert_eq!(req.max_chunks, 20);
1104 }
1105
1106 #[test]
1107 fn test_context_request_custom_limit() {
1108 let params = ContextParams {
1109 topic: None,
1110 limit: Some(5),
1111 domain: Some("work".into()),
1112 };
1113 let req = ChatContextRequest {
1114 query: None,
1115 conversation_id: params.topic,
1116 max_chunks: params.limit.unwrap_or(20),
1117 relevance_threshold: None,
1118 include_goals: true,
1119 domain: params.domain,
1120 };
1121 assert_eq!(req.max_chunks, 5);
1122 assert_eq!(req.domain.as_deref(), Some("work"));
1123 }
1124
1125 #[test]
1126 fn test_context_maps_topic_to_conversation_id() {
1127 let params = ContextParams {
1128 topic: Some("project Origin".into()),
1129 limit: None,
1130 domain: None,
1131 };
1132 let req = ChatContextRequest {
1133 query: None,
1134 conversation_id: params.topic.clone(),
1135 max_chunks: params.limit.unwrap_or(20),
1136 relevance_threshold: None,
1137 include_goals: true,
1138 domain: params.domain,
1139 };
1140 assert_eq!(req.conversation_id.as_deref(), Some("project Origin"));
1141 }
1142
1143 #[test]
1146 fn test_remember_constructs_store_request_with_entity() {
1147 let server = make_server(TransportMode::Stdio, "claude", None);
1148 let params = RememberParams {
1149 content: "Alice manages the frontend team".into(),
1150 memory_type: Some("fact".into()),
1151 domain: Some("work".into()),
1152 entity: Some("Alice".into()),
1153 confidence: Some(0.9),
1154 supersedes: None,
1155 structured_fields: None,
1156 retrieval_cue: None,
1157 };
1158
1159 let source_agent = server.resolve_source_agent(None);
1161
1162 let req = StoreMemoryRequest {
1163 content: params.content,
1164 memory_type: params.memory_type,
1165 domain: params.domain,
1166 source_agent,
1167 title: None,
1168 confidence: params.confidence,
1169 supersedes: params.supersedes,
1170 entity: params.entity,
1171 entity_id: None,
1172 structured_fields: params.structured_fields.map(serde_json::Value::Object),
1173 retrieval_cue: params.retrieval_cue,
1174 };
1175
1176 let json = serde_json::to_value(&req).unwrap();
1177 assert_eq!(json["content"], "Alice manages the frontend team");
1178 assert_eq!(json["memory_type"], "fact");
1179 assert_eq!(json["domain"], "work");
1180 assert_eq!(json["entity"], "Alice");
1181 assert!(json["confidence"].as_f64().unwrap() > 0.89);
1182 assert_eq!(json["source_agent"], "claude");
1184 }
1185
1186 #[test]
1187 fn test_remember_http_mode_injects_agent() {
1188 let server = make_server(TransportMode::Http, "claude.ai", Some("lucian"));
1189 let source_agent = server.resolve_source_agent(None);
1190
1191 assert_eq!(source_agent, Some("claude.ai".into()));
1192 }
1193
1194 #[test]
1197 fn test_recall_constructs_search_request() {
1198 let params = RecallParams {
1199 query: "database choices".into(),
1200 limit: Some(5),
1201 memory_type: Some("decision".into()),
1202 domain: None,
1203 };
1204
1205 let req = SearchMemoryRequest {
1206 query: params.query,
1207 limit: params.limit.unwrap_or(10),
1208 memory_type: params.memory_type,
1209 domain: params.domain,
1210 source_agent: None,
1211 };
1212
1213 let json = serde_json::to_value(&req).unwrap();
1214 assert_eq!(json["query"], "database choices");
1215 assert_eq!(json["limit"], 5);
1216 assert_eq!(json["memory_type"], "decision");
1217 assert!(json.get("entity").is_none());
1218 assert!(json["domain"].is_null());
1219 assert!(json["source_agent"].is_null());
1220 }
1221
1222 #[test]
1225 fn test_remember_passes_through_all_5_types() {
1226 for t in &["identity", "preference", "fact", "decision", "goal"] {
1227 let params = RememberParams {
1228 content: "test".into(),
1229 memory_type: Some(t.to_string()),
1230 domain: None,
1231 entity: None,
1232 confidence: None,
1233 supersedes: None,
1234 structured_fields: None,
1235 retrieval_cue: None,
1236 };
1237 assert_eq!(params.memory_type.as_deref(), Some(*t));
1238 }
1239 }
1240
1241 #[test]
1244 fn test_remember_params_with_structured_fields_and_cue() {
1245 let json = r#"{
1246 "content": "Lucian prefers dark mode",
1247 "structured_fields": {"theme":"dark"},
1248 "retrieval_cue": "What theme does Lucian prefer?"
1249 }"#;
1250 let params: RememberParams = serde_json::from_str(json).unwrap();
1251 let structured_fields = params.structured_fields.expect("structured_fields");
1252 assert_eq!(
1253 structured_fields.get("theme"),
1254 Some(&serde_json::Value::String("dark".into()))
1255 );
1256 assert_eq!(
1257 params.retrieval_cue.as_deref(),
1258 Some("What theme does Lucian prefer?")
1259 );
1260 }
1261
1262 #[test]
1263 fn test_store_request_with_structured_fields() {
1264 let req = StoreMemoryRequest {
1265 content: "test".into(),
1266 memory_type: Some("fact".into()),
1267 domain: None,
1268 source_agent: None,
1269 title: None,
1270 confidence: None,
1271 supersedes: None,
1272 entity: None,
1273 entity_id: None,
1274 structured_fields: Some(serde_json::json!({"key":"val"})),
1275 retrieval_cue: Some("What is the key?".into()),
1276 };
1277 let json = serde_json::to_value(&req).unwrap();
1278 assert_eq!(json["structured_fields"], serde_json::json!({"key":"val"}));
1279 assert_eq!(json["retrieval_cue"], "What is the key?");
1280 }
1281
1282 #[test]
1285 fn test_chat_context_response() {
1286 let json = r#"{
1287 "context": "User prefers dark mode. Works on Origin project.",
1288 "profile": {
1289 "narrative": "narrative",
1290 "identity": [],
1291 "preferences": [],
1292 "goals": []
1293 },
1294 "knowledge": {
1295 "concepts": [],
1296 "decisions": [],
1297 "relevant_memories": [],
1298 "graph_context": []
1299 },
1300 "took_ms": 12.5,
1301 "token_estimates": {
1302 "tier1_identity": 1,
1303 "tier2_project": 2,
1304 "tier3_relevant": 3,
1305 "total": 6
1306 }
1307 }"#;
1308 let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
1309 assert!(!resp.context.is_empty());
1310 assert!(resp.profile.identity.is_empty());
1311 assert_eq!(resp.took_ms, 12.5);
1312 assert_eq!(resp.token_estimates.total, 6);
1313 }
1314
1315 #[test]
1316 fn test_chat_context_response_empty() {
1317 let json = r#"{
1318 "context": "",
1319 "profile": {
1320 "narrative": "",
1321 "identity": [],
1322 "preferences": [],
1323 "goals": []
1324 },
1325 "knowledge": {
1326 "concepts": [],
1327 "decisions": [],
1328 "relevant_memories": [],
1329 "graph_context": []
1330 },
1331 "took_ms": 1.0,
1332 "token_estimates": {
1333 "tier1_identity": 0,
1334 "tier2_project": 0,
1335 "tier3_relevant": 0,
1336 "total": 0
1337 }
1338 }"#;
1339 let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
1340 assert!(resp.context.is_empty());
1341 }
1342
1343 fn server_instructions() -> String {
1350 let s = make_server(TransportMode::Stdio, "test", None);
1351 s.get_info()
1352 .instructions
1353 .expect("server must ship with_instructions")
1354 }
1355
1356 #[test]
1357 fn instructions_mention_self_evolving_knowledge() {
1358 assert!(
1359 server_instructions().contains("self-evolving"),
1360 "with_instructions must describe Origin as self-evolving"
1361 );
1362 }
1363
1364 #[test]
1365 fn instructions_mention_shared_across_tools() {
1366 assert!(
1367 server_instructions().contains("shared across all"),
1368 "with_instructions must tell agents the store is shared across tools"
1369 );
1370 }
1371
1372 #[test]
1373 fn instructions_mention_how_user_thinks() {
1374 assert!(
1375 server_instructions().contains("how the user thinks"),
1376 "with_instructions must frame context as modeling how the user thinks"
1377 );
1378 }
1379
1380 #[test]
1381 fn instructions_use_proactive_framing() {
1382 assert!(
1383 server_instructions().contains("STORE PROACTIVELY"),
1384 "with_instructions must use STORE PROACTIVELY framing (not passive WHEN TO STORE)"
1385 );
1386 }
1387
1388 #[test]
1389 fn instructions_ban_tool_output_storage() {
1390 assert!(
1391 server_instructions().contains("Tool output or command results"),
1392 "with_instructions must explicitly rule out tool output as storage material"
1393 );
1394 }
1395
1396 #[test]
1397 fn instructions_ban_ghost_inferences() {
1398 assert!(
1399 server_instructions().contains("Your own inferences"),
1400 "with_instructions must rule out storing agent's own inferences user didn't express"
1401 );
1402 }
1403
1404 #[test]
1405 fn instructions_call_out_atomic_memory() {
1406 assert!(
1407 server_instructions().contains("Atomic: one idea per memory"),
1408 "with_instructions must call out the atomic-memory rule explicitly by name"
1409 );
1410 }
1411
1412 #[test]
1413 fn instructions_specify_declarative_writing() {
1414 assert!(
1415 server_instructions().contains("Declarative, not narrative"),
1416 "with_instructions must require declarative (not narrative) writing style"
1417 );
1418 }
1419
1420 #[test]
1421 fn instructions_default_to_omit_memory_type() {
1422 let i = server_instructions();
1423 assert!(
1424 i.contains("omit and trust the backend"),
1425 "with_instructions must default agents to omitting memory_type"
1426 );
1427 assert!(
1428 i.contains("do NOT set memory_type"),
1429 "with_instructions must explicitly say do NOT set memory_type by default"
1430 );
1431 }
1432
1433 #[test]
1434 fn instructions_carve_out_decisions_for_decision_log() {
1435 let i = server_instructions();
1436 assert!(
1437 i.contains("Decision Log"),
1438 "with_instructions must name the Decision Log as the reason for explicit decision typing"
1439 );
1440 assert!(
1441 i.contains("memory_type=\"decision\""),
1442 "with_instructions must tell agents to set memory_type=\"decision\" explicitly for decisions"
1443 );
1444 }
1445
1446 fn tool_descriptions() -> std::collections::HashMap<String, String> {
1449 let server = make_server(TransportMode::Stdio, "test", None);
1450 server
1451 .tool_router
1452 .list_all()
1453 .into_iter()
1454 .filter_map(|t| {
1455 let desc = t.description.as_ref()?.to_string();
1456 Some((t.name.to_string(), desc))
1457 })
1458 .collect()
1459 }
1460
1461 #[test]
1462 fn remember_description_calls_out_atomic() {
1463 let descriptions = tool_descriptions();
1464 let remember = descriptions.get("remember").expect("remember tool exists");
1465 assert!(
1466 remember.contains("Each call is one atomic idea"),
1467 "remember description must call out atomic-per-call explicitly, got: {remember}"
1468 );
1469 }
1470
1471 #[test]
1472 fn context_description_frames_modeling_user() {
1473 let descriptions = tool_descriptions();
1474 let ctx = descriptions.get("context").expect("context tool exists");
1475 assert!(
1476 ctx.contains("how the user thinks"),
1477 "context description must frame the result as modeling how the user thinks, got: {ctx}"
1478 );
1479 }
1480
1481 #[test]
1482 fn recall_memory_type_param_lists_two_level_filter() {
1483 let params_schema = serde_json::to_string(&schemars::schema_for!(RecallParams))
1484 .expect("RecallParams schema serializes");
1485 assert!(
1486 params_schema.contains("Two-level filter"),
1487 "RecallParams.memory_type must advertise the two-level filter, got schema: {params_schema}"
1488 );
1489 assert!(
1490 params_schema.contains("profile"),
1491 "RecallParams.memory_type must mention profile alias"
1492 );
1493 assert!(
1494 params_schema.contains("knowledge"),
1495 "RecallParams.memory_type must mention knowledge alias"
1496 );
1497 }
1498}