Skip to main content

origin_mcp/
tools.rs

1use crate::client::{OriginClient, OriginError};
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
12/// Deserialize an `Option<usize>` that also accepts stringified numbers (e.g. `"10"`).
13/// MCP clients like Claude Desktop sometimes send numeric params as strings.
14fn 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/// Controls which operations are allowed based on transport.
36#[derive(Clone, Debug, PartialEq)]
37pub enum TransportMode {
38    /// Local stdio — full access, all tools
39    Stdio,
40    /// Remote HTTP — block deletes, inject source_agent
41    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 from MCP initialize handshake (e.g., "Claude Code", "Claude Desktop")
52    client_name: std::sync::Arc<std::sync::Mutex<Option<String>>>,
53    user_id: Option<String>,
54}
55
56// ===== Parameter Structs =====
57
58// --- Primary tool params ---
59
60#[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
140// ===== Internal Implementations =====
141
142fn 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
153/// Convert a backend error into a tool-level error result (isError: true)
154/// with an actionable message. This keeps the MCP transport healthy
155/// (no protocol-level McpError) while telling the caller what happened.
156fn tool_error(e: OriginError, verb: &str) -> CallToolResult {
157    let msg = match &e {
158        OriginError::Unreachable(_) => format!(
159            "Origin daemon is not reachable (retried 3x over ~6s). \
160             The {verb} was NOT completed. Try again after the daemon is running."
161        ),
162        OriginError::Api { status, body } => format!(
163            "Origin daemon returned HTTP {status}: {body}. The {verb} may not have completed."
164        ),
165        OriginError::Deserialize(detail) => format!(
166            "Failed to parse daemon response: {detail}. \
167             This may indicate a version mismatch between origin-mcp and the daemon."
168        ),
169    };
170    CallToolResult::error(vec![Content::text(msg)])
171}
172
173impl OriginMcpServer {
174    /// Resolve the source_agent for a write operation.
175    /// Priority: explicit param > MCP client name (from initialize) > configured agent_name.
176    fn resolve_source_agent(&self, param_agent: Option<String>) -> Option<String> {
177        // 1. Explicit param from tool call
178        if let Some(ref agent) = param_agent {
179            if !agent.is_empty() {
180                return param_agent;
181            }
182        }
183        // 2. Client name captured from MCP initialize handshake
184        if let Ok(guard) = self.client_name.lock() {
185            if let Some(ref name) = *guard {
186                return Some(name.clone());
187            }
188        }
189        // 3. Configured --agent-name flag
190        Some(self.agent_name.clone())
191    }
192
193    /// Resolve a local user_id for logging or future use.
194    /// This value is intentionally not sent on the wire (D4).
195    fn resolve_user_id(&self, param_user_id: Option<String>) -> Option<String> {
196        if self.transport == TransportMode::Http {
197            self.user_id.clone().or(param_user_id)
198        } else {
199            param_user_id
200        }
201    }
202
203    pub async fn remember_impl(&self, params: RememberParams) -> Result<CallToolResult, McpError> {
204        let source_agent = self.resolve_source_agent(None);
205        if let Some(uid) = self.resolve_user_id(None) {
206            tracing::debug!(user_id = %uid, "remember invoked");
207        }
208
209        let req = StoreMemoryRequest {
210            content: params.content,
211            memory_type: params.memory_type,
212            domain: params.domain,
213            source_agent,
214            title: None,
215            confidence: params.confidence,
216            supersedes: params.supersedes,
217            entity: params.entity,
218            entity_id: None,
219            structured_fields: params.structured_fields.map(serde_json::Value::Object),
220            retrieval_cue: params.retrieval_cue,
221        };
222
223        let resp: StoreMemoryResponse = match self.client.post("/api/memory/store", &req).await {
224            Ok(r) => r,
225            Err(e) => return Ok(tool_error(e, "memory store")),
226        };
227
228        Ok(CallToolResult::success(vec![Content::text(
229            format_remember_success(&resp),
230        )]))
231    }
232
233    pub async fn recall_impl(&self, params: RecallParams) -> Result<CallToolResult, McpError> {
234        let req = SearchMemoryRequest {
235            query: params.query,
236            limit: params.limit.unwrap_or(10),
237            memory_type: params.memory_type,
238            domain: params.domain,
239            source_agent: self.resolve_source_agent(None),
240        };
241
242        let resp: SearchMemoryResponse = match self.client.post("/api/memory/search", &req).await {
243            Ok(r) => r,
244            Err(e) => return Ok(tool_error(e, "search")),
245        };
246
247        let json = serde_json::to_string_pretty(&resp.results)
248            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
249
250        Ok(CallToolResult::success(vec![Content::text(format!(
251            "{} results ({:.1}ms)\n{}",
252            resp.results.len(),
253            resp.took_ms,
254            json
255        ))]))
256    }
257
258    pub async fn context_impl(&self, params: ContextParams) -> Result<CallToolResult, McpError> {
259        let req = ChatContextRequest {
260            query: None,
261            conversation_id: params.topic,
262            max_chunks: params.limit.unwrap_or(20),
263            relevance_threshold: None,
264            include_goals: true,
265            domain: params.domain,
266        };
267
268        // Extract only the `context` string field from the response.
269        //
270        // The full ChatContextResponse embeds Vec<SearchResult> which may
271        // contain fields added after the published origin-types version.
272        // Since context_impl only uses `resp.context`, we parse the raw
273        // JSON and pull that field directly — this makes the tool forward-
274        // compatible with any new fields the daemon might add.
275        let raw: serde_json::Value = match self.client.post("/api/chat-context", &req).await {
276            Ok(r) => r,
277            Err(e) => return Ok(tool_error(e, "context load")),
278        };
279
280        let context = raw
281            .get("context")
282            .and_then(|v| v.as_str())
283            .unwrap_or_default()
284            .to_string();
285
286        if context.is_empty() {
287            Ok(CallToolResult::success(vec![Content::text(
288                "No relevant context found".to_string(),
289            )]))
290        } else {
291            Ok(CallToolResult::success(vec![Content::text(context)]))
292        }
293    }
294
295    pub async fn forget_impl(&self, memory_id: &str) -> Result<CallToolResult, McpError> {
296        if self.transport == TransportMode::Http {
297            return Ok(CallToolResult::error(vec![Content::text(
298                "Delete operations are not available over remote connections. \
299                 Use the Origin desktop app to delete memories."
300                    .to_string(),
301            )]));
302        }
303
304        let resp: DeleteResponse = match self
305            .client
306            .delete(&format!("/api/memory/delete/{}", memory_id))
307            .await
308        {
309            Ok(r) => r,
310            Err(e) => return Ok(tool_error(e, "delete")),
311        };
312
313        Ok(CallToolResult::success(vec![Content::text(
314            if resp.deleted {
315                "Memory deleted"
316            } else {
317                "Memory not found"
318            }
319            .to_string(),
320        )]))
321    }
322}
323
324// ===== Tool Registrations =====
325
326#[tool_router]
327impl OriginMcpServer {
328    pub fn new(
329        client: OriginClient,
330        transport: TransportMode,
331        agent_name: String,
332        user_id: Option<String>,
333    ) -> Self {
334        Self {
335            tool_router: Self::tool_router(),
336            client,
337            transport,
338            agent_name,
339            client_name: std::sync::Arc::new(std::sync::Mutex::new(None)),
340            user_id,
341        }
342    }
343
344    // --- Primary Tools ---
345
346    #[tool(
347        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.",
348        annotations(
349            title = "Remember",
350            read_only_hint = false,
351            destructive_hint = false,
352            idempotent_hint = false,
353            open_world_hint = false
354        )
355    )]
356    async fn remember(
357        &self,
358        Parameters(params): Parameters<RememberParams>,
359    ) -> Result<CallToolResult, McpError> {
360        self.remember_impl(params).await
361    }
362
363    #[tool(
364        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.",
365        annotations(title = "Recall", read_only_hint = true, open_world_hint = false)
366    )]
367    async fn recall(
368        &self,
369        Parameters(params): Parameters<RecallParams>,
370    ) -> Result<CallToolResult, McpError> {
371        self.recall_impl(params).await
372    }
373
374    #[tool(
375        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.",
376        annotations(title = "Context", read_only_hint = true, open_world_hint = false)
377    )]
378    async fn context(
379        &self,
380        Parameters(params): Parameters<ContextParams>,
381    ) -> Result<CallToolResult, McpError> {
382        self.context_impl(params).await
383    }
384
385    #[tool(
386        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.",
387        annotations(
388            title = "Forget",
389            read_only_hint = false,
390            destructive_hint = true,
391            idempotent_hint = true,
392            open_world_hint = false
393        )
394    )]
395    async fn forget(
396        &self,
397        Parameters(params): Parameters<ForgetParams>,
398    ) -> Result<CallToolResult, McpError> {
399        self.forget_impl(&params.memory_id).await
400    }
401}
402
403// ===== ServerHandler =====
404
405#[tool_handler]
406impl ServerHandler for OriginMcpServer {
407    async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
408        // Capture client name from MCP initialize handshake
409        if let Some(client_info) = context.peer.peer_info() {
410            let name = &client_info.client_info.name;
411            if !name.is_empty() {
412                if let Ok(mut guard) = self.client_name.lock() {
413                    tracing::info!("MCP client identified: {}", name);
414                    *guard = Some(name.clone());
415                }
416            }
417        }
418    }
419
420    fn get_info(&self) -> InitializeResult {
421        InitializeResult::new(
422            ServerCapabilities::builder()
423                .enable_tools()
424                .build(),
425        )
426        .with_server_info(
427            Implementation::new("origin-mcp", env!("CARGO_PKG_VERSION"))
428        )
429        .with_instructions(
430            "Origin is your personal memory layer — a local knowledge base that persists across sessions and tools.\n\
431             Think of yourself as a curator, not a logger. Store insights, not conversation artifacts.\n\n\
432             Origin is self-evolving — each memory you store contributes to a knowledge structure that grows over time. \
433             It's also shared across all the user's tools: what you write, other agents (Claude Desktop, Claude Code, \
434             ChatGPT, Cursor, etc.) will read later. Write for any future reader, not just this conversation.\n\n\
435             FIRST THING EVERY SESSION: Call context to load the user's identity, preferences, goals, and\n\
436             topic-relevant memories. This is how you know who you're talking to. Use the result to model how the \
437             user thinks — their preferences, corrections, and past decisions tell you how they want to be helped, \
438             not just what they already know.\n\n\
439             STORE PROACTIVELY — don't wait for the user to ask.\n\
440             - The user states a preference (\"I use X because...\", \"I prefer Y over Z\")\n\
441             - The user makes a decision (\"going with approach A\", \"switching to B\")\n\
442             - The user corrects you or prior info (\"actually, it's C, not D\") — store the correction so it sticks\n\
443             - The user shares a durable fact about themselves, their work, or people/projects/tools they care about — \
444               anchor it to the entity\n\n\
445             If the user asks explicitly (\"remember this\", \"save this\", \"don't forget\"), that's a floor — you \
446             should have already stored it.\n\n\
447             WHEN NOT TO STORE:\n\
448             - Conversation filler (\"ok\", \"thanks\", \"let's move on\")\n\
449             - Things the user can trivially re-derive (file paths, recent git history)\n\
450             - Anything already stored — recall first if unsure\n\
451             - Tool output or command results (file contents, git history, build logs) — these are derivable\n\
452             - General world facts or documentation that aren't personal to this user (e.g., \"Rust has a borrow \
453               checker\", \"PostgreSQL supports JSONB\") — those are not memory material.\n\
454             - Your own inferences about the user that they didn't express. Store what they said; infer from that \
455               when responding.\n\n\
456             CONTENT QUALITY — this is where you make the biggest difference:\n\
457             - Specific beats vague: \"prefers Rust for CLI tools because of compile-time safety\" > \"likes Rust\"\n\
458             - Include the WHY: the backend can classify \"dark mode\" as a preference, but only you know\n\
459               \"switched to dark mode because of migraines from bright screens\"\n\
460             - Name the entities: mention people, projects, tools by name — this powers the knowledge graph\n\
461             - Atomic: one idea per memory — \"prefers TDD\" and \"uses pytest\" should be two memories, not one\n\
462             - Declarative, not narrative: \"User prefers X because Y\" — not \"User said today they prefer X\". \
463               Memories outlive the conversation that produced them.\n\n\
464             MEMORY TYPES — omit and trust the backend.\n\n\
465             By default, do NOT set memory_type. The backend auto-classifies into identity / preference / goal / \
466             fact / decision with more context than you have. Agents that over-specify types tend to pick wrong.\n\n\
467             Opt-in specification:\n\
468             - \"profile\"   — you're sure it's about the user (identity/preference/goal)\n\
469             - \"knowledge\" — you're sure it's about the world (fact/decision)\n\
470             - Precise type — only if you're confident and the distinction matters.\n\n\
471             EXCEPTION — decisions carry structured fields (alternatives considered, reversibility, domain) \
472             that power the Decision Log view. Set memory_type=\"decision\" explicitly ONLY when the user \
473             articulated alternatives weighed AND the reasoning for the choice. A bare \"I'm switching to Cursor\" \
474             is just a preference change — omit the type. \"Switching to Cursor over VSCode because of better \
475             Claude integration, and we can always go back\" — that's a decision.\n\n\
476             RECALL vs CONTEXT:\n\
477             - context: broad orientation, session start, topic shifts, \"catch me up\"\n\
478             - recall: specific lookup (\"what's Alice's role?\", \"database preferences\", \"our auth decision\")\n\n\
479             The backend handles classification, entity extraction, structured fields, quality scoring,\n\
480             and dedup — you don't need to replicate that logic. Focus on what only you know:\n\
481             the conversational context, why something matters, and what the user actually cares about."
482        )
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use crate::client::OriginClient;
490    use crate::types::{
491        ChatContextRequest, ChatContextResponse, SearchMemoryRequest, SearchResult,
492        StoreMemoryRequest, StoreMemoryResponse,
493    };
494
495    fn make_server(
496        transport: TransportMode,
497        agent_name: &str,
498        user_id: Option<&str>,
499    ) -> OriginMcpServer {
500        let client = OriginClient::new("http://127.0.0.1:19999".into());
501        OriginMcpServer::new(
502            client,
503            transport,
504            agent_name.into(),
505            user_id.map(String::from),
506        )
507    }
508
509    // ===== Transport resolution (existing) =====
510
511    #[test]
512    fn test_http_mode_prefers_param_over_agent_name() {
513        let server = make_server(TransportMode::Http, "claude.ai", None);
514        // Explicit param has highest priority
515        let result = server.resolve_source_agent(Some("user-provided".into()));
516        assert_eq!(result, Some("user-provided".into()));
517    }
518
519    #[test]
520    fn test_http_mode_sets_source_agent_when_none() {
521        let server = make_server(TransportMode::Http, "chatgpt", None);
522        let result = server.resolve_source_agent(None);
523        assert_eq!(result, Some("chatgpt".into()));
524    }
525
526    #[test]
527    fn test_stdio_mode_passes_through_source_agent() {
528        let server = make_server(TransportMode::Stdio, "ignored", None);
529        let result = server.resolve_source_agent(Some("user-provided".into()));
530        assert_eq!(result, Some("user-provided".into()));
531    }
532
533    #[test]
534    fn test_stdio_mode_falls_back_to_agent_name() {
535        let server = make_server(TransportMode::Stdio, "fallback", None);
536        // No param, no client_name → falls back to configured agent_name
537        let result = server.resolve_source_agent(None);
538        assert_eq!(result, Some("fallback".into()));
539    }
540
541    #[test]
542    fn test_http_mode_resolves_configured_user_id_for_local_use() {
543        let server = make_server(TransportMode::Http, "agent", Some("lucian"));
544        let result = server.resolve_user_id(None);
545        assert_eq!(result, Some("lucian".into()));
546    }
547
548    #[test]
549    fn test_transport_mode_equality() {
550        assert_eq!(TransportMode::Stdio, TransportMode::Stdio);
551        assert_eq!(TransportMode::Http, TransportMode::Http);
552        assert_ne!(TransportMode::Stdio, TransportMode::Http);
553    }
554
555    // ===== Param deserialization: RememberParams =====
556
557    #[test]
558    fn test_remember_params_minimal() {
559        let json = r#"{"content": "Lucian prefers dark mode"}"#;
560        let params: RememberParams = serde_json::from_str(json).unwrap();
561        assert_eq!(params.content, "Lucian prefers dark mode");
562        assert!(params.memory_type.is_none());
563        assert!(params.domain.is_none());
564        assert!(params.entity.is_none());
565        assert!(params.confidence.is_none());
566        assert!(params.supersedes.is_none());
567    }
568
569    #[test]
570    fn test_remember_params_full() {
571        let json = r#"{
572            "content": "We chose PostgreSQL over MongoDB",
573            "memory_type": "decision",
574            "domain": "origin",
575            "entity": "PostgreSQL",
576            "confidence": 0.95,
577            "supersedes": "mem_abc123"
578        }"#;
579        let params: RememberParams = serde_json::from_str(json).unwrap();
580        assert_eq!(params.content, "We chose PostgreSQL over MongoDB");
581        assert_eq!(params.memory_type.as_deref(), Some("decision"));
582        assert_eq!(params.domain.as_deref(), Some("origin"));
583        assert_eq!(params.entity.as_deref(), Some("PostgreSQL"));
584        assert_eq!(params.confidence, Some(0.95));
585        assert_eq!(params.supersedes.as_deref(), Some("mem_abc123"));
586    }
587
588    #[test]
589    fn test_remember_params_missing_content_fails() {
590        let json = r#"{"memory_type": "fact"}"#;
591        let result = serde_json::from_str::<RememberParams>(json);
592        assert!(result.is_err());
593    }
594
595    // ===== Param deserialization: RecallParams =====
596
597    #[test]
598    fn test_recall_params_minimal() {
599        let json = r#"{"query": "what does Alice work on?"}"#;
600        let params: RecallParams = serde_json::from_str(json).unwrap();
601        assert_eq!(params.query, "what does Alice work on?");
602        assert!(params.limit.is_none());
603    }
604
605    #[test]
606    fn test_recall_params_full() {
607        let json = r#"{
608            "query": "database preferences",
609            "limit": 5,
610            "memory_type": "decision",
611            "domain": "origin"
612        }"#;
613        let params: RecallParams = serde_json::from_str(json).unwrap();
614        assert_eq!(params.query, "database preferences");
615        assert_eq!(params.limit, Some(5));
616        assert_eq!(params.memory_type.as_deref(), Some("decision"));
617        assert_eq!(params.domain.as_deref(), Some("origin"));
618    }
619
620    #[test]
621    fn test_recall_params_limit_as_string() {
622        let json = r#"{"query": "test", "limit": "10"}"#;
623        let params: RecallParams = serde_json::from_str(json).unwrap();
624        assert_eq!(params.limit, Some(10));
625    }
626
627    #[test]
628    fn test_recall_params_missing_query_fails() {
629        let json = r#"{"limit": 5}"#;
630        let result = serde_json::from_str::<RecallParams>(json);
631        assert!(result.is_err());
632    }
633
634    // ===== Param deserialization: ContextParams =====
635
636    #[test]
637    fn test_context_params_empty() {
638        let json = r#"{}"#;
639        let params: ContextParams = serde_json::from_str(json).unwrap();
640        assert!(params.topic.is_none());
641        assert!(params.limit.is_none());
642        assert!(params.domain.is_none());
643    }
644
645    #[test]
646    fn test_context_params_full() {
647        let json = r#"{"topic": "project Origin architecture", "limit": 30, "domain": "work"}"#;
648        let params: ContextParams = serde_json::from_str(json).unwrap();
649        assert_eq!(params.topic.as_deref(), Some("project Origin architecture"));
650        assert_eq!(params.limit, Some(30));
651        assert_eq!(params.domain.as_deref(), Some("work"));
652    }
653
654    #[test]
655    fn test_context_params_limit_as_string() {
656        let json = r#"{"limit": "20"}"#;
657        let params: ContextParams = serde_json::from_str(json).unwrap();
658        assert_eq!(params.limit, Some(20));
659    }
660
661    #[test]
662    fn store_memory_request_serialization_excludes_user_id() {
663        let req = StoreMemoryRequest {
664            content: "test content".into(),
665            memory_type: None,
666            domain: None,
667            source_agent: Some("test-agent".into()),
668            title: None,
669            confidence: None,
670            supersedes: None,
671            entity: None,
672            entity_id: None,
673            structured_fields: None,
674            retrieval_cue: None,
675        };
676        let json = serde_json::to_value(&req).unwrap();
677        let obj = json.as_object().unwrap();
678        assert!(
679            !obj.contains_key("user_id"),
680            "user_id must not be on the wire; got: {:?}",
681            obj.keys().collect::<Vec<_>>()
682        );
683    }
684
685    #[test]
686    fn remember_success_message_is_terse() {
687        let resp = StoreMemoryResponse {
688            source_id: "mem_abc".into(),
689            chunks_created: 3,
690            memory_type: "fact".into(),
691            entity_id: Some("ent_xyz".into()),
692            quality: Some("high".into()),
693            warnings: vec![],
694            extraction_method: "llm".into(),
695        };
696        let msg = format_remember_success(&resp);
697        assert_eq!(msg, "Stored mem_abc");
698        assert!(!msg.contains("chunks"));
699        assert!(!msg.contains("quality"));
700        assert!(!msg.contains("entity"));
701    }
702
703    #[test]
704    fn remember_success_message_surfaces_warnings() {
705        let resp = StoreMemoryResponse {
706            source_id: "mem_abc".into(),
707            chunks_created: 1,
708            memory_type: "decision".into(),
709            entity_id: None,
710            quality: None,
711            warnings: vec!["decision memory missing required 'claim' field".into()],
712            extraction_method: "agent".into(),
713        };
714        let msg = format_remember_success(&resp);
715        assert!(msg.starts_with("Stored mem_abc"));
716        assert!(msg.contains("Warnings:"));
717        assert!(msg.contains("decision memory missing required 'claim' field"));
718    }
719
720    #[test]
721    fn search_memory_request_serialization_excludes_entity() {
722        let req = SearchMemoryRequest {
723            query: "test".into(),
724            limit: 10,
725            memory_type: None,
726            domain: None,
727            source_agent: None,
728        };
729        let json = serde_json::to_value(&req).unwrap();
730        let obj = json.as_object().unwrap();
731        assert!(
732            !obj.contains_key("entity"),
733            "entity must not be on the wire; got keys: {:?}",
734            obj.keys().collect::<Vec<_>>()
735        );
736    }
737
738    #[test]
739    fn chat_context_request_serialization_includes_domain() {
740        let req = ChatContextRequest {
741            query: None,
742            conversation_id: Some("topic".into()),
743            max_chunks: 20,
744            relevance_threshold: None,
745            include_goals: true,
746            domain: Some("work".into()),
747        };
748        let json = serde_json::to_value(&req).unwrap();
749        assert_eq!(json["domain"], serde_json::json!("work"));
750        assert_eq!(json["conversation_id"], serde_json::json!("topic"));
751    }
752
753    #[test]
754    fn chat_context_response_deserializes_with_profile_and_knowledge() {
755        let json = r#"{
756            "context": "user is Lucian, prefers Rust",
757            "profile": {
758                "narrative": "n",
759                "identity": ["rust"],
760                "preferences": [],
761                "goals": []
762            },
763            "knowledge": {
764                "concepts": [],
765                "decisions": [],
766                "relevant_memories": [],
767                "graph_context": []
768            },
769            "took_ms": 42.0,
770            "token_estimates": {
771                "tier1_identity": 10,
772                "tier2_project": 20,
773                "tier3_relevant": 30,
774                "total": 60
775            }
776        }"#;
777        let parsed: ChatContextResponse = serde_json::from_str(json).unwrap();
778        assert_eq!(parsed.context, "user is Lucian, prefers Rust");
779        assert_eq!(parsed.profile.identity, vec!["rust"]);
780        assert_eq!(parsed.token_estimates.total, 60);
781    }
782
783    #[test]
784    fn remember_params_structured_fields_schema_is_object() {
785        use schemars::schema_for;
786
787        let schema = schema_for!(RememberParams);
788        let json = serde_json::to_value(&schema).unwrap();
789        let sf_schema = json
790            .pointer("/properties/structured_fields")
791            .expect("structured_fields property in schema");
792        let type_val = sf_schema
793            .pointer("/type")
794            .unwrap_or(&serde_json::Value::Null);
795        let type_str = match type_val {
796            serde_json::Value::String(s) => s.clone(),
797            serde_json::Value::Array(arr) => arr
798                .iter()
799                .filter_map(|v| v.as_str())
800                .collect::<Vec<_>>()
801                .join(","),
802            other => panic!(
803                "structured_fields schema lacks type constraint; got: {:?}",
804                other
805            ),
806        };
807        assert!(
808            type_str.contains("object"),
809            "expected object type, got: {}",
810            type_str
811        );
812    }
813
814    // ===== Param deserialization: ForgetParams =====
815
816    #[test]
817    fn test_forget_params() {
818        let json = r#"{"memory_id": "mem_abc123"}"#;
819        let params: ForgetParams = serde_json::from_str(json).unwrap();
820        assert_eq!(params.memory_id, "mem_abc123");
821    }
822
823    #[test]
824    fn test_forget_params_missing_id_fails() {
825        let json = r#"{}"#;
826        let result = serde_json::from_str::<ForgetParams>(json);
827        assert!(result.is_err());
828    }
829
830    // ===== Request serialization: StoreMemoryRequest =====
831
832    #[test]
833    fn test_store_request_includes_new_fields() {
834        let req = StoreMemoryRequest {
835            content: "test".into(),
836            memory_type: Some("decision".into()),
837            domain: None,
838            source_agent: Some("claude".into()),
839            title: None,
840            confidence: Some(0.9),
841            supersedes: Some("old_id".into()),
842            entity: Some("PostgreSQL".into()),
843            entity_id: None,
844            structured_fields: None,
845            retrieval_cue: None,
846        };
847        let json = serde_json::to_value(&req).unwrap();
848        assert_eq!(json["entity"], "PostgreSQL");
849        assert_eq!(json["supersedes"], "old_id");
850        assert!(json["confidence"].as_f64().unwrap() > 0.89);
851        assert_eq!(json["source_agent"], "claude");
852        assert!(json.get("user_id").is_none());
853    }
854
855    #[test]
856    fn test_store_request_minimal() {
857        let req = StoreMemoryRequest {
858            content: "hello".into(),
859            memory_type: Some("fact".into()),
860            domain: None,
861            source_agent: None,
862            title: None,
863            confidence: None,
864            supersedes: None,
865            entity: None,
866            entity_id: None,
867            structured_fields: None,
868            retrieval_cue: None,
869        };
870        let json = serde_json::to_value(&req).unwrap();
871        assert_eq!(json["content"], "hello");
872        assert_eq!(json["memory_type"], "fact");
873        assert!(json.get("user_id").is_none());
874    }
875
876    // ===== Response deserialization: StoreMemoryResponse =====
877
878    #[test]
879    fn test_store_response_with_new_fields() {
880        let json = r#"{
881            "source_id": "mem_xyz",
882            "chunks_created": 2,
883            "memory_type": "fact",
884            "entity_id": "ent_abc",
885            "quality": "high",
886            "warnings": ["decision memory missing claim"],
887            "extraction_method": "agent"
888        }"#;
889        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
890        assert_eq!(resp.source_id, "mem_xyz");
891        assert_eq!(resp.chunks_created, 2);
892        assert_eq!(resp.memory_type, "fact");
893        assert_eq!(resp.entity_id.as_deref(), Some("ent_abc"));
894        assert_eq!(resp.quality.as_deref(), Some("high"));
895        assert_eq!(resp.warnings, vec!["decision memory missing claim"]);
896        assert_eq!(resp.extraction_method, "agent");
897    }
898
899    #[test]
900    fn test_store_response_backward_compat_no_new_fields() {
901        // Old backend response without warnings/extraction_method
902        let json = r#"{
903            "source_id": "mem_old",
904            "chunks_created": 1,
905            "memory_type": "fact"
906        }"#;
907        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
908        assert_eq!(resp.source_id, "mem_old");
909        assert_eq!(resp.chunks_created, 1);
910        assert_eq!(resp.memory_type, "fact");
911        assert!(resp.entity_id.is_none());
912        assert!(resp.quality.is_none());
913        assert!(resp.warnings.is_empty());
914        assert_eq!(resp.extraction_method, "unknown");
915    }
916
917    #[test]
918    fn test_store_response_with_warnings_and_extraction_method() {
919        let json = r#"{
920            "source_id": "mem_xyz",
921            "chunks_created": 1,
922            "memory_type": "decision",
923            "warnings": ["decision memory missing required 'claim' field"],
924            "extraction_method": "llm"
925        }"#;
926        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
927        assert_eq!(resp.memory_type, "decision");
928        assert_eq!(
929            resp.warnings,
930            vec!["decision memory missing required 'claim' field"]
931        );
932        assert_eq!(resp.extraction_method, "llm");
933    }
934
935    // ===== Response deserialization: SearchResult =====
936
937    #[test]
938    fn test_search_result_with_new_fields() {
939        let json = r#"{
940            "id": "1",
941            "content": "We chose Postgres",
942            "source": "memory",
943            "source_id": "mem_1",
944            "title": "DB decision",
945            "url": null,
946            "chunk_index": 0,
947            "last_modified": 1711000000,
948            "score": 0.95,
949            "chunk_type": "memory",
950            "language": "en",
951            "semantic_unit": "sentence",
952            "memory_type": "decision",
953            "domain": "origin",
954            "source_agent": "claude",
955            "confidence": 0.9,
956            "confirmed": true,
957            "stability": "standard",
958            "supersedes": "mem_0",
959            "summary": "DB choice",
960            "entity_id": "ent_pg",
961            "entity_name": "PostgreSQL",
962            "quality": "high",
963            "is_archived": false,
964            "is_recap": false,
965            "source_text": "We chose Postgres",
966            "raw_score": 0.42
967        }"#;
968        let result: SearchResult = serde_json::from_str(json).unwrap();
969        assert_eq!(result.chunk_type.as_deref(), Some("memory"));
970        assert_eq!(result.language.as_deref(), Some("en"));
971        assert_eq!(result.semantic_unit.as_deref(), Some("sentence"));
972        assert_eq!(result.stability.as_deref(), Some("standard"));
973        assert_eq!(result.supersedes.as_deref(), Some("mem_0"));
974        assert_eq!(result.summary.as_deref(), Some("DB choice"));
975        assert_eq!(result.entity_id.as_deref(), Some("ent_pg"));
976        assert_eq!(result.entity_name.as_deref(), Some("PostgreSQL"));
977        assert_eq!(result.quality.as_deref(), Some("high"));
978        assert!(!result.is_archived);
979        assert!(!result.is_recap);
980        assert_eq!(result.source_text.as_deref(), Some("We chose Postgres"));
981        assert!((result.raw_score - 0.42).abs() < f32::EPSILON);
982    }
983
984    #[test]
985    fn test_search_result_backward_compat_no_new_fields() {
986        // Old backend response without entity/quality/archive/recap
987        let json = r#"{
988            "id": "1",
989            "content": "test",
990            "source": "memory",
991            "source_id": "mem_1",
992            "title": "test",
993            "url": null,
994            "chunk_index": 0,
995            "last_modified": 1711000000,
996            "score": 0.8,
997            "memory_type": "fact",
998            "domain": null,
999            "source_agent": null,
1000            "confidence": null,
1001            "confirmed": null
1002        }"#;
1003        let result: SearchResult = serde_json::from_str(json).unwrap();
1004        assert!(result.entity_id.is_none());
1005        assert!(result.entity_name.is_none());
1006        assert!(result.quality.is_none());
1007        assert!(!result.is_archived);
1008        assert!(!result.is_recap);
1009        assert!(result.structured_fields.is_none());
1010        assert!(result.retrieval_cue.is_none());
1011        assert_eq!(result.raw_score, 0.0);
1012    }
1013
1014    #[test]
1015    fn test_search_result_with_structured_fields_and_retrieval_cue() {
1016        let json = r#"{
1017            "id": "1",
1018            "content": "Lucian prefers dark mode",
1019            "source": "memory",
1020            "source_id": "mem_1",
1021            "title": "Dark mode preference",
1022            "url": null,
1023            "chunk_index": 0,
1024            "last_modified": 1711000000,
1025            "score": 0.92,
1026            "memory_type": "preference",
1027            "domain": null,
1028            "source_agent": null,
1029            "confidence": null,
1030            "confirmed": null,
1031            "structured_fields": "{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}",
1032            "retrieval_cue": "What UI theme does Lucian prefer?"
1033        }"#;
1034        let result: SearchResult = serde_json::from_str(json).unwrap();
1035        assert_eq!(
1036            result.structured_fields.as_deref(),
1037            Some("{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}")
1038        );
1039        assert_eq!(
1040            result.retrieval_cue.as_deref(),
1041            Some("What UI theme does Lucian prefer?")
1042        );
1043        assert!(!result.is_archived);
1044        assert!(!result.is_recap);
1045        assert_eq!(result.raw_score, 0.0);
1046    }
1047
1048    #[test]
1049    fn test_search_result_knowledge_graph_source() {
1050        // Entity-boosted observation results from knowledge graph
1051        let json = r#"{
1052            "id": "obs_1",
1053            "content": "Prefers Rust over Go",
1054            "source": "knowledge_graph",
1055            "source_id": "ent_lucian",
1056            "title": "Lucian",
1057            "url": null,
1058            "chunk_index": 0,
1059            "last_modified": 1711000000,
1060            "score": 1.14,
1061            "memory_type": null,
1062            "domain": null,
1063            "source_agent": null,
1064            "confidence": null,
1065            "confirmed": null,
1066            "entity_id": "ent_lucian",
1067            "entity_name": "Lucian"
1068        }"#;
1069        let result: SearchResult = serde_json::from_str(json).unwrap();
1070        assert_eq!(result.source, "knowledge_graph");
1071        assert_eq!(result.entity_id.as_deref(), Some("ent_lucian"));
1072        assert_eq!(result.entity_name.as_deref(), Some("Lucian"));
1073        assert!(!result.is_archived);
1074        assert!(!result.is_recap);
1075        assert_eq!(result.raw_score, 0.0);
1076    }
1077
1078    // ===== Transport security: forget blocks on HTTP =====
1079
1080    #[tokio::test]
1081    async fn test_forget_blocked_on_http_transport() {
1082        let server = make_server(TransportMode::Http, "agent", None);
1083        let result = server.forget_impl("mem_123").await.unwrap();
1084        // Should return error content, not an Err
1085        let content = &result.content[0];
1086        match content.raw {
1087            rmcp::model::RawContent::Text(ref tc) => {
1088                assert!(tc.text.contains("not available over remote connections"));
1089            }
1090            _ => panic!("expected text content"),
1091        }
1092    }
1093
1094    #[tokio::test]
1095    async fn test_forget_allowed_on_stdio_transport() {
1096        // This will fail with connection error (no server), which proves
1097        // the transport check passed and it tried to make the HTTP call.
1098        // The error comes back as CallToolResult with is_error: true
1099        // (tool-level failure), not McpError (protocol-level).
1100        let server = make_server(TransportMode::Stdio, "agent", None);
1101        let result = server.forget_impl("mem_123").await.unwrap();
1102        assert!(
1103            result.is_error.unwrap_or(false),
1104            "should fail with connection error, not transport block"
1105        );
1106    }
1107
1108    // ===== Context default limit =====
1109
1110    #[test]
1111    fn test_context_request_default_limit() {
1112        let params = ContextParams {
1113            topic: Some("test".into()),
1114            limit: None,
1115            domain: None,
1116        };
1117        let req = ChatContextRequest {
1118            query: None,
1119            conversation_id: params.topic,
1120            max_chunks: params.limit.unwrap_or(20),
1121            relevance_threshold: None,
1122            include_goals: true,
1123            domain: params.domain,
1124        };
1125        assert_eq!(req.max_chunks, 20);
1126    }
1127
1128    #[test]
1129    fn test_context_request_custom_limit() {
1130        let params = ContextParams {
1131            topic: None,
1132            limit: Some(5),
1133            domain: Some("work".into()),
1134        };
1135        let req = ChatContextRequest {
1136            query: None,
1137            conversation_id: params.topic,
1138            max_chunks: params.limit.unwrap_or(20),
1139            relevance_threshold: None,
1140            include_goals: true,
1141            domain: params.domain,
1142        };
1143        assert_eq!(req.max_chunks, 5);
1144        assert_eq!(req.domain.as_deref(), Some("work"));
1145    }
1146
1147    #[test]
1148    fn test_context_maps_topic_to_conversation_id() {
1149        let params = ContextParams {
1150            topic: Some("project Origin".into()),
1151            limit: None,
1152            domain: None,
1153        };
1154        let req = ChatContextRequest {
1155            query: None,
1156            conversation_id: params.topic.clone(),
1157            max_chunks: params.limit.unwrap_or(20),
1158            relevance_threshold: None,
1159            include_goals: true,
1160            domain: params.domain,
1161        };
1162        assert_eq!(req.conversation_id.as_deref(), Some("project Origin"));
1163    }
1164
1165    // ===== Remember request construction =====
1166
1167    #[test]
1168    fn test_remember_constructs_store_request_with_entity() {
1169        let server = make_server(TransportMode::Stdio, "claude", None);
1170        let params = RememberParams {
1171            content: "Alice manages the frontend team".into(),
1172            memory_type: Some("fact".into()),
1173            domain: Some("work".into()),
1174            entity: Some("Alice".into()),
1175            confidence: Some(0.9),
1176            supersedes: None,
1177            structured_fields: None,
1178            retrieval_cue: None,
1179        };
1180
1181        // Replicate remember_impl's request construction
1182        let source_agent = server.resolve_source_agent(None);
1183
1184        let req = StoreMemoryRequest {
1185            content: params.content,
1186            memory_type: params.memory_type,
1187            domain: params.domain,
1188            source_agent,
1189            title: None,
1190            confidence: params.confidence,
1191            supersedes: params.supersedes,
1192            entity: params.entity,
1193            entity_id: None,
1194            structured_fields: params.structured_fields.map(serde_json::Value::Object),
1195            retrieval_cue: params.retrieval_cue,
1196        };
1197
1198        let json = serde_json::to_value(&req).unwrap();
1199        assert_eq!(json["content"], "Alice manages the frontend team");
1200        assert_eq!(json["memory_type"], "fact");
1201        assert_eq!(json["domain"], "work");
1202        assert_eq!(json["entity"], "Alice");
1203        assert!(json["confidence"].as_f64().unwrap() > 0.89);
1204        // stdio mode: no param, no client_name → falls back to agent_name "claude"
1205        assert_eq!(json["source_agent"], "claude");
1206    }
1207
1208    #[test]
1209    fn test_remember_http_mode_injects_agent() {
1210        let server = make_server(TransportMode::Http, "claude.ai", Some("lucian"));
1211        let source_agent = server.resolve_source_agent(None);
1212
1213        assert_eq!(source_agent, Some("claude.ai".into()));
1214    }
1215
1216    // ===== Recall request construction =====
1217
1218    #[test]
1219    fn test_recall_constructs_search_request() {
1220        let params = RecallParams {
1221            query: "database choices".into(),
1222            limit: Some(5),
1223            memory_type: Some("decision".into()),
1224            domain: None,
1225        };
1226
1227        let req = SearchMemoryRequest {
1228            query: params.query,
1229            limit: params.limit.unwrap_or(10),
1230            memory_type: params.memory_type,
1231            domain: params.domain,
1232            source_agent: None,
1233        };
1234
1235        let json = serde_json::to_value(&req).unwrap();
1236        assert_eq!(json["query"], "database choices");
1237        assert_eq!(json["limit"], 5);
1238        assert_eq!(json["memory_type"], "decision");
1239        assert!(json.get("entity").is_none());
1240        assert!(json["domain"].is_null());
1241        assert!(json["source_agent"].is_null());
1242    }
1243
1244    // ===== Memory type backward compat =====
1245
1246    #[test]
1247    fn test_remember_passes_through_all_5_types() {
1248        for t in &["identity", "preference", "fact", "decision", "goal"] {
1249            let params = RememberParams {
1250                content: "test".into(),
1251                memory_type: Some(t.to_string()),
1252                domain: None,
1253                entity: None,
1254                confidence: None,
1255                supersedes: None,
1256                structured_fields: None,
1257                retrieval_cue: None,
1258            };
1259            assert_eq!(params.memory_type.as_deref(), Some(*t));
1260        }
1261    }
1262
1263    // ===== Structured fields in remember params =====
1264
1265    #[test]
1266    fn test_remember_params_with_structured_fields_and_cue() {
1267        let json = r#"{
1268            "content": "Lucian prefers dark mode",
1269            "structured_fields": {"theme":"dark"},
1270            "retrieval_cue": "What theme does Lucian prefer?"
1271        }"#;
1272        let params: RememberParams = serde_json::from_str(json).unwrap();
1273        let structured_fields = params.structured_fields.expect("structured_fields");
1274        assert_eq!(
1275            structured_fields.get("theme"),
1276            Some(&serde_json::Value::String("dark".into()))
1277        );
1278        assert_eq!(
1279            params.retrieval_cue.as_deref(),
1280            Some("What theme does Lucian prefer?")
1281        );
1282    }
1283
1284    #[test]
1285    fn test_store_request_with_structured_fields() {
1286        let req = StoreMemoryRequest {
1287            content: "test".into(),
1288            memory_type: Some("fact".into()),
1289            domain: None,
1290            source_agent: None,
1291            title: None,
1292            confidence: None,
1293            supersedes: None,
1294            entity: None,
1295            entity_id: None,
1296            structured_fields: Some(serde_json::json!({"key":"val"})),
1297            retrieval_cue: Some("What is the key?".into()),
1298        };
1299        let json = serde_json::to_value(&req).unwrap();
1300        assert_eq!(json["structured_fields"], serde_json::json!({"key":"val"}));
1301        assert_eq!(json["retrieval_cue"], "What is the key?");
1302    }
1303
1304    // ===== ChatContextResponse deserialization =====
1305
1306    #[test]
1307    fn test_chat_context_response() {
1308        let json = r#"{
1309            "context": "User prefers dark mode. Works on Origin project.",
1310            "profile": {
1311                "narrative": "narrative",
1312                "identity": [],
1313                "preferences": [],
1314                "goals": []
1315            },
1316            "knowledge": {
1317                "concepts": [],
1318                "decisions": [],
1319                "relevant_memories": [],
1320                "graph_context": []
1321            },
1322            "took_ms": 12.5,
1323            "token_estimates": {
1324                "tier1_identity": 1,
1325                "tier2_project": 2,
1326                "tier3_relevant": 3,
1327                "total": 6
1328            }
1329        }"#;
1330        let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
1331        assert!(!resp.context.is_empty());
1332        assert!(resp.profile.identity.is_empty());
1333        assert_eq!(resp.took_ms, 12.5);
1334        assert_eq!(resp.token_estimates.total, 6);
1335    }
1336
1337    #[test]
1338    fn test_chat_context_response_empty() {
1339        let json = r#"{
1340            "context": "",
1341            "profile": {
1342                "narrative": "",
1343                "identity": [],
1344                "preferences": [],
1345                "goals": []
1346            },
1347            "knowledge": {
1348                "concepts": [],
1349                "decisions": [],
1350                "relevant_memories": [],
1351                "graph_context": []
1352            },
1353            "took_ms": 1.0,
1354            "token_estimates": {
1355                "tier1_identity": 0,
1356                "tier2_project": 0,
1357                "tier3_relevant": 0,
1358                "total": 0
1359            }
1360        }"#;
1361        let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
1362        assert!(resp.context.is_empty());
1363    }
1364
1365    // ===== with_instructions content assertions =====
1366    // These tests lock in the refined agent-facing guidance. If any
1367    // assertion fails, either the rule was intentionally changed
1368    // (update the test) or the refinement was accidentally dropped
1369    // (restore the rule).
1370
1371    fn server_instructions() -> String {
1372        let s = make_server(TransportMode::Stdio, "test", None);
1373        s.get_info()
1374            .instructions
1375            .expect("server must ship with_instructions")
1376    }
1377
1378    #[test]
1379    fn instructions_mention_self_evolving_knowledge() {
1380        assert!(
1381            server_instructions().contains("self-evolving"),
1382            "with_instructions must describe Origin as self-evolving"
1383        );
1384    }
1385
1386    #[test]
1387    fn instructions_mention_shared_across_tools() {
1388        assert!(
1389            server_instructions().contains("shared across all"),
1390            "with_instructions must tell agents the store is shared across tools"
1391        );
1392    }
1393
1394    #[test]
1395    fn instructions_mention_how_user_thinks() {
1396        assert!(
1397            server_instructions().contains("how the user thinks"),
1398            "with_instructions must frame context as modeling how the user thinks"
1399        );
1400    }
1401
1402    #[test]
1403    fn instructions_use_proactive_framing() {
1404        assert!(
1405            server_instructions().contains("STORE PROACTIVELY"),
1406            "with_instructions must use STORE PROACTIVELY framing (not passive WHEN TO STORE)"
1407        );
1408    }
1409
1410    #[test]
1411    fn instructions_ban_tool_output_storage() {
1412        assert!(
1413            server_instructions().contains("Tool output or command results"),
1414            "with_instructions must explicitly rule out tool output as storage material"
1415        );
1416    }
1417
1418    #[test]
1419    fn instructions_ban_ghost_inferences() {
1420        assert!(
1421            server_instructions().contains("Your own inferences"),
1422            "with_instructions must rule out storing agent's own inferences user didn't express"
1423        );
1424    }
1425
1426    #[test]
1427    fn instructions_call_out_atomic_memory() {
1428        assert!(
1429            server_instructions().contains("Atomic: one idea per memory"),
1430            "with_instructions must call out the atomic-memory rule explicitly by name"
1431        );
1432    }
1433
1434    #[test]
1435    fn instructions_specify_declarative_writing() {
1436        assert!(
1437            server_instructions().contains("Declarative, not narrative"),
1438            "with_instructions must require declarative (not narrative) writing style"
1439        );
1440    }
1441
1442    #[test]
1443    fn instructions_default_to_omit_memory_type() {
1444        let i = server_instructions();
1445        assert!(
1446            i.contains("omit and trust the backend"),
1447            "with_instructions must default agents to omitting memory_type"
1448        );
1449        assert!(
1450            i.contains("do NOT set memory_type"),
1451            "with_instructions must explicitly say do NOT set memory_type by default"
1452        );
1453    }
1454
1455    #[test]
1456    fn instructions_carve_out_decisions_for_decision_log() {
1457        let i = server_instructions();
1458        assert!(
1459            i.contains("Decision Log"),
1460            "with_instructions must name the Decision Log as the reason for explicit decision typing"
1461        );
1462        assert!(
1463            i.contains("memory_type=\"decision\""),
1464            "with_instructions must tell agents to set memory_type=\"decision\" explicitly for decisions"
1465        );
1466    }
1467
1468    // ===== tool-level and param-level description assertions =====
1469
1470    fn tool_descriptions() -> std::collections::HashMap<String, String> {
1471        let server = make_server(TransportMode::Stdio, "test", None);
1472        server
1473            .tool_router
1474            .list_all()
1475            .into_iter()
1476            .filter_map(|t| {
1477                let desc = t.description.as_ref()?.to_string();
1478                Some((t.name.to_string(), desc))
1479            })
1480            .collect()
1481    }
1482
1483    #[test]
1484    fn remember_description_calls_out_atomic() {
1485        let descriptions = tool_descriptions();
1486        let remember = descriptions.get("remember").expect("remember tool exists");
1487        assert!(
1488            remember.contains("Each call is one atomic idea"),
1489            "remember description must call out atomic-per-call explicitly, got: {remember}"
1490        );
1491    }
1492
1493    #[test]
1494    fn context_description_frames_modeling_user() {
1495        let descriptions = tool_descriptions();
1496        let ctx = descriptions.get("context").expect("context tool exists");
1497        assert!(
1498            ctx.contains("how the user thinks"),
1499            "context description must frame the result as modeling how the user thinks, got: {ctx}"
1500        );
1501    }
1502
1503    #[test]
1504    fn recall_memory_type_param_lists_two_level_filter() {
1505        let params_schema = serde_json::to_string(&schemars::schema_for!(RecallParams))
1506            .expect("RecallParams schema serializes");
1507        assert!(
1508            params_schema.contains("Two-level filter"),
1509            "RecallParams.memory_type must advertise the two-level filter, got schema: {params_schema}"
1510        );
1511        assert!(
1512            params_schema.contains("profile"),
1513            "RecallParams.memory_type must mention profile alias"
1514        );
1515        assert!(
1516            params_schema.contains("knowledge"),
1517            "RecallParams.memory_type must mention knowledge alias"
1518        );
1519    }
1520}