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 CaptureParams {
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#[derive(Debug, Deserialize, schemars::JsonSchema)]
141pub struct DistillParams {
142    #[schemars(
143        description = "Optional page ID. If provided, re-distills only that page from its current sources. If omitted, runs a full distillation pass over any clusters with new sources."
144    )]
145    pub page_id: Option<String>,
146}
147
148#[derive(Debug, Deserialize, schemars::JsonSchema)]
149pub struct ListPendingParams {
150    #[schemars(
151        description = "Max results, default 20. Increase for full audit, decrease for quick check-in."
152    )]
153    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
154    pub limit: Option<usize>,
155}
156
157#[derive(Debug, Deserialize, schemars::JsonSchema)]
158pub struct ConfirmMemoryParams {
159    #[schemars(
160        description = "The source_id of the memory to confirm. Get this from list_pending or recall results."
161    )]
162    pub memory_id: String,
163}
164
165// ===== Internal Implementations =====
166
167fn format_capture_success(resp: &StoreMemoryResponse) -> String {
168    let mut msg = format!("Stored {}", resp.source_id);
169    if !resp.warnings.is_empty() {
170        msg.push_str("\nWarnings:");
171        for warning in &resp.warnings {
172            msg.push_str(&format!("\n  - {}", warning));
173        }
174    }
175    msg
176}
177
178fn daemon_setup_hint() -> &'static str {
179    "Install the local Origin runtime and run `origin setup`.
180
181Setup choices:
182- Basic Memory: store, search, and recall now. No model download or API key.
183- On-device Model: private local extraction and background refinement after model download.
184- Anthropic Key: richer extraction and background refinement using your API key.
185
186Install:
187  curl -fsSL https://raw.githubusercontent.com/7xuanlu/origin/main/install.sh | bash
188  export PATH=\"$HOME/.origin/bin:$PATH\"
189  origin setup
190  origin install
191  origin status"
192}
193
194/// Convert a backend error into a tool-level error result (isError: true)
195/// with an actionable message. This keeps the MCP transport healthy
196/// (no protocol-level McpError) while telling the caller what happened.
197fn tool_error(e: OriginError, verb: &str) -> CallToolResult {
198    let msg = match &e {
199        OriginError::Unreachable(_) => format!(
200            "Origin daemon is not reachable (retried 3x over ~6s). \
201             The {verb} was NOT completed.\n\n{}",
202            daemon_setup_hint()
203        ),
204        OriginError::Api { status, body } => format!(
205            "Origin daemon returned HTTP {status}: {body}. The {verb} may not have completed."
206        ),
207        OriginError::Deserialize(detail) => format!(
208            "Failed to parse daemon response: {detail}. \
209             This may indicate a version mismatch between origin-mcp and the daemon."
210        ),
211    };
212    CallToolResult::error(vec![Content::text(msg)])
213}
214
215fn format_doctor_message(status: &serde_json::Value) -> String {
216    let mode = status
217        .get("mode")
218        .and_then(|v| v.as_str())
219        .unwrap_or("unknown");
220    let setup_completed = status
221        .get("setup_completed")
222        .and_then(|v| v.as_bool())
223        .unwrap_or(false);
224    let anthropic_key_configured = status
225        .get("anthropic_key_configured")
226        .and_then(|v| v.as_bool())
227        .unwrap_or(false);
228    let local_model_selected = status.get("local_model_selected").and_then(|v| v.as_str());
229    let local_model_loaded = status.get("local_model_loaded").and_then(|v| v.as_str());
230    let local_model_cached = status
231        .get("local_model_cached")
232        .and_then(|v| v.as_bool())
233        .unwrap_or(false);
234
235    let mode_label = match mode {
236        "basic-memory" => "Basic Memory",
237        "local-model" => "On-device Model",
238        "anthropic-key" => "Anthropic Key",
239        other => other,
240    };
241    let local_model_line = match local_model_selected {
242        Some(id) => {
243            let cache_status = if local_model_cached {
244                "downloaded"
245            } else {
246                "not downloaded"
247            };
248            let loaded_status = if Some(id) == local_model_loaded {
249                ", loaded"
250            } else {
251                ""
252            };
253            format!("{id} ({cache_status}{loaded_status})")
254        }
255        None => "not selected".to_string(),
256    };
257    let refinement_line = if anthropic_key_configured || local_model_loaded.is_some() {
258        "enabled (richer extraction and background refinement are active)"
259    } else if setup_completed {
260        "paused (Basic Memory stores, searches, and recalls now. Choose an on-device model or Anthropic key for richer extraction.)"
261    } else {
262        "not configured"
263    };
264
265    let mut msg = format!(
266        "Origin daemon: running\n\
267         Setup: {}\n\
268         Mode: {mode_label}\n\
269         Anthropic key: {}\n\
270         On-device model: {local_model_line}\n\
271         Background refinement: {refinement_line}",
272        if setup_completed {
273            "completed"
274        } else {
275            "not completed"
276        },
277        if anthropic_key_configured {
278            "configured"
279        } else {
280            "not configured"
281        }
282    );
283
284    if !setup_completed {
285        msg.push_str(
286            "\n\nRun `origin setup` to choose Basic Memory, On-device Model, or Anthropic Key.",
287        );
288    } else if !anthropic_key_configured && local_model_loaded.is_none() {
289        msg.push_str(
290            "\n\nBasic Memory works now: capture, recall, and context are available. \
291             To enable richer extraction and background refinement, run `origin model install` \
292             or `origin key set anthropic`.",
293        );
294    }
295
296    msg
297}
298
299impl OriginMcpServer {
300    /// Resolve the source_agent for a write operation.
301    /// Priority: explicit param > MCP client name (from initialize) > configured agent_name.
302    fn resolve_source_agent(&self, param_agent: Option<String>) -> Option<String> {
303        // 1. Explicit param from tool call
304        if let Some(ref agent) = param_agent {
305            if !agent.is_empty() {
306                return param_agent;
307            }
308        }
309        // 2. Client name captured from MCP initialize handshake
310        if let Ok(guard) = self.client_name.lock() {
311            if let Some(ref name) = *guard {
312                return Some(name.clone());
313            }
314        }
315        // 3. Configured --agent-name flag
316        Some(self.agent_name.clone())
317    }
318
319    /// Resolve a local user_id for logging or future use.
320    /// This value is intentionally not sent on the wire (D4).
321    fn resolve_user_id(&self, param_user_id: Option<String>) -> Option<String> {
322        if self.transport == TransportMode::Http {
323            self.user_id.clone().or(param_user_id)
324        } else {
325            param_user_id
326        }
327    }
328
329    pub async fn capture_impl(&self, params: CaptureParams) -> Result<CallToolResult, McpError> {
330        // Tool was renamed `remember -> capture` in v0.4. The HTTP request
331        // body shape (StoreMemoryRequest) is unchanged; only the MCP-facing
332        // tool name shifted.
333        let source_agent = self.resolve_source_agent(None);
334        if let Some(uid) = self.resolve_user_id(None) {
335            tracing::debug!(user_id = %uid, "capture invoked");
336        }
337
338        let req = StoreMemoryRequest {
339            content: params.content,
340            memory_type: params.memory_type,
341            domain: params.domain,
342            source_agent,
343            title: None,
344            confidence: params.confidence,
345            supersedes: params.supersedes,
346            entity: params.entity,
347            entity_id: None,
348            structured_fields: params.structured_fields.map(serde_json::Value::Object),
349            retrieval_cue: params.retrieval_cue,
350        };
351
352        let resp: StoreMemoryResponse = match self.client.post("/api/memory/store", &req).await {
353            Ok(r) => r,
354            Err(e) => return Ok(tool_error(e, "memory store")),
355        };
356
357        Ok(CallToolResult::success(vec![Content::text(
358            format_capture_success(&resp),
359        )]))
360    }
361
362    pub async fn recall_impl(&self, params: RecallParams) -> Result<CallToolResult, McpError> {
363        let req = SearchMemoryRequest {
364            query: params.query,
365            limit: params.limit.unwrap_or(10),
366            memory_type: params.memory_type,
367            domain: params.domain,
368            source_agent: self.resolve_source_agent(None),
369        };
370
371        let resp: SearchMemoryResponse = match self.client.post("/api/memory/search", &req).await {
372            Ok(r) => r,
373            Err(e) => return Ok(tool_error(e, "search")),
374        };
375
376        let json = serde_json::to_string_pretty(&resp.results)
377            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
378
379        Ok(CallToolResult::success(vec![Content::text(format!(
380            "{} results ({:.1}ms)\n{}",
381            resp.results.len(),
382            resp.took_ms,
383            json
384        ))]))
385    }
386
387    pub async fn context_impl(&self, params: ContextParams) -> Result<CallToolResult, McpError> {
388        #[allow(deprecated)]
389        let req = ChatContextRequest {
390            query: None,
391            conversation_id: params.topic,
392            max_chunks: params.limit.unwrap_or(20),
393            relevance_threshold: None,
394            include_goals: true,
395            domain: params.domain,
396        };
397
398        // Extract only the `context` string field from the response.
399        //
400        // The full ChatContextResponse embeds Vec<SearchResult> which may
401        // contain fields added after the published origin-types version.
402        // Since context_impl only uses `resp.context`, we parse the raw
403        // JSON and pull that field directly — this makes the tool forward-
404        // compatible with any new fields the daemon might add.
405        let raw: serde_json::Value = match self.client.post("/api/chat-context", &req).await {
406            Ok(r) => r,
407            Err(e) => return Ok(tool_error(e, "context load")),
408        };
409
410        let context = raw
411            .get("context")
412            .and_then(|v| v.as_str())
413            .unwrap_or_default()
414            .to_string();
415
416        if context.is_empty() {
417            Ok(CallToolResult::success(vec![Content::text(
418                "No relevant context found".to_string(),
419            )]))
420        } else {
421            Ok(CallToolResult::success(vec![Content::text(context)]))
422        }
423    }
424
425    pub async fn doctor_impl(&self) -> Result<CallToolResult, McpError> {
426        let status: serde_json::Value = match self.client.get("/api/setup/status").await {
427            Ok(r) => r,
428            Err(OriginError::Api { status: 404, .. }) => {
429                return Ok(CallToolResult::error(vec![Content::text(
430                    "Origin daemon is running, but it does not expose /api/setup/status. \
431                     Update Origin, then run `origin doctor`."
432                        .to_string(),
433                )]));
434            }
435            Err(e) => return Ok(tool_error(e, "status check")),
436        };
437
438        Ok(CallToolResult::success(vec![Content::text(
439            format_doctor_message(&status),
440        )]))
441    }
442
443    pub async fn forget_impl(&self, memory_id: &str) -> Result<CallToolResult, McpError> {
444        if self.transport == TransportMode::Http {
445            return Ok(CallToolResult::error(vec![Content::text(
446                "Delete operations are not available over remote connections. \
447                 Use local MCP on the machine running Origin to delete memories."
448                    .to_string(),
449            )]));
450        }
451
452        let resp: DeleteResponse = match self
453            .client
454            .delete(&format!("/api/memory/delete/{}", memory_id))
455            .await
456        {
457            Ok(r) => r,
458            Err(e) => return Ok(tool_error(e, "delete")),
459        };
460
461        Ok(CallToolResult::success(vec![Content::text(
462            if resp.deleted {
463                "Memory deleted"
464            } else {
465                "Memory not found"
466            }
467            .to_string(),
468        )]))
469    }
470
471    pub async fn distill_impl(&self, params: DistillParams) -> Result<CallToolResult, McpError> {
472        let path = match params.page_id.as_deref() {
473            Some(id) if !id.is_empty() => format!("/api/distill/{}", id),
474            _ => "/api/distill".to_string(),
475        };
476        match self
477            .client
478            .post::<serde_json::Value, serde_json::Value>(&path, &serde_json::json!({}))
479            .await
480        {
481            Ok(_) => Ok(CallToolResult::success(vec![Content::text(
482                match params.page_id {
483                    Some(id) => format!("Re-distilled page {}.", id),
484                    None => "Distillation pass triggered.".to_string(),
485                },
486            )])),
487            Err(e) => Ok(tool_error(e, "distill")),
488        }
489    }
490
491    pub async fn list_pending_impl(
492        &self,
493        params: ListPendingParams,
494    ) -> Result<CallToolResult, McpError> {
495        let limit = params.limit.unwrap_or(20).min(100);
496        let path = format!("/api/memory/list?confirmed=false&limit={}", limit);
497        let value: serde_json::Value = match self.client.get(&path).await {
498            Ok(v) => v,
499            Err(e) => return Ok(tool_error(e, "list_pending")),
500        };
501        let body = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
502        Ok(CallToolResult::success(vec![Content::text(body)]))
503    }
504
505    pub async fn confirm_memory_impl(&self, memory_id: &str) -> Result<CallToolResult, McpError> {
506        if self.transport == TransportMode::Http {
507            return Ok(CallToolResult::error(vec![Content::text(
508                "Confirm operations are not available over remote connections. \
509                 Use local MCP on the machine running Origin for review."
510                    .to_string(),
511            )]));
512        }
513        let path = format!("/api/memory/confirm/{}", memory_id);
514        match self
515            .client
516            .post::<serde_json::Value, serde_json::Value>(&path, &serde_json::json!({}))
517            .await
518        {
519            Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!(
520                "Memory {} confirmed.",
521                memory_id
522            ))])),
523            Err(e) => Ok(tool_error(e, "confirm_memory")),
524        }
525    }
526}
527
528// ===== Tool Registrations =====
529
530#[tool_router]
531impl OriginMcpServer {
532    pub fn new(
533        client: OriginClient,
534        transport: TransportMode,
535        agent_name: String,
536        user_id: Option<String>,
537    ) -> Self {
538        Self {
539            tool_router: Self::tool_router(),
540            client,
541            transport,
542            agent_name,
543            client_name: std::sync::Arc::new(std::sync::Mutex::new(None)),
544            user_id,
545        }
546    }
547
548    // --- Primary Tools ---
549
550    #[tool(
551        description = "Capture 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' or 'capture that' — that phrasing is 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, lessons, gotchas, and identity information. Each call is one atomic idea — \"prefers TDD\" and \"uses pytest\" are two calls, not one.",
552        annotations(
553            title = "Capture",
554            read_only_hint = false,
555            destructive_hint = false,
556            idempotent_hint = false,
557            open_world_hint = false
558        )
559    )]
560    async fn capture(
561        &self,
562        Parameters(params): Parameters<CaptureParams>,
563    ) -> Result<CallToolResult, McpError> {
564        self.capture_impl(params).await
565    }
566
567    #[tool(
568        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.",
569        annotations(title = "Recall", read_only_hint = true, open_world_hint = false)
570    )]
571    async fn recall(
572        &self,
573        Parameters(params): Parameters<RecallParams>,
574    ) -> Result<CallToolResult, McpError> {
575        self.recall_impl(params).await
576    }
577
578    #[tool(
579        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.",
580        annotations(title = "Context", read_only_hint = true, open_world_hint = false)
581    )]
582    async fn context(
583        &self,
584        Parameters(params): Parameters<ContextParams>,
585    ) -> Result<CallToolResult, McpError> {
586        self.context_impl(params).await
587    }
588
589    #[tool(
590        description = "Diagnose the local Origin runtime. This is not part of the memory loop. Use only when Origin tools fail, when onboarding a new MCP client, or when the user asks why setup, extraction, or background refinement is paused. Reports daemon reachability, setup mode, Basic Memory, On-device Model, Anthropic key state, and on-device model state.",
591        annotations(title = "Doctor", read_only_hint = true, open_world_hint = false)
592    )]
593    async fn doctor(&self) -> Result<CallToolResult, McpError> {
594        self.doctor_impl().await
595    }
596
597    #[tool(
598        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.",
599        annotations(
600            title = "Forget",
601            read_only_hint = false,
602            destructive_hint = true,
603            idempotent_hint = true,
604            open_world_hint = false
605        )
606    )]
607    async fn forget(
608        &self,
609        Parameters(params): Parameters<ForgetParams>,
610    ) -> Result<CallToolResult, McpError> {
611        self.forget_impl(&params.memory_id).await
612    }
613
614    #[tool(
615        description = "Trigger Origin's distillation pass. With no `page_id`, runs a full pass that clusters new memories into pages and refreshes the wiki view. With a `page_id`, re-distills that single page from its current sources. Use when the user explicitly asks to synthesize, distill, or rebuild a page. The daemon also runs distillation periodically in the background, so don't trigger redundantly during normal flow.",
616        annotations(
617            title = "Distill",
618            read_only_hint = false,
619            destructive_hint = false,
620            idempotent_hint = true,
621            open_world_hint = false
622        )
623    )]
624    async fn distill(
625        &self,
626        Parameters(params): Parameters<DistillParams>,
627    ) -> Result<CallToolResult, McpError> {
628        self.distill_impl(params).await
629    }
630
631    #[tool(
632        description = "List unconfirmed memories pending review. Use when the user wants to audit what got captured before it becomes authoritative — typical phrases: 'review pending', 'show unconfirmed', 'what got captured'. Pair with `confirm_memory` to accept and `forget` to reject.",
633        annotations(title = "List pending", read_only_hint = true, open_world_hint = false)
634    )]
635    async fn list_pending(
636        &self,
637        Parameters(params): Parameters<ListPendingParams>,
638    ) -> Result<CallToolResult, McpError> {
639        self.list_pending_impl(params).await
640    }
641
642    #[tool(
643        description = "Confirm a pending memory by source_id. Use during review to accept a memory the agent captured. The user typically picks from a `list_pending` result. To reject instead, call `forget` with the same `memory_id`.",
644        annotations(
645            title = "Confirm memory",
646            read_only_hint = false,
647            destructive_hint = false,
648            idempotent_hint = true,
649            open_world_hint = false
650        )
651    )]
652    async fn confirm_memory(
653        &self,
654        Parameters(params): Parameters<ConfirmMemoryParams>,
655    ) -> Result<CallToolResult, McpError> {
656        self.confirm_memory_impl(&params.memory_id).await
657    }
658}
659
660// ===== ServerHandler =====
661
662#[tool_handler]
663impl ServerHandler for OriginMcpServer {
664    async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
665        // Capture client name from MCP initialize handshake
666        if let Some(client_info) = context.peer.peer_info() {
667            let name = &client_info.client_info.name;
668            if !name.is_empty() {
669                if let Ok(mut guard) = self.client_name.lock() {
670                    tracing::info!("MCP client identified: {}", name);
671                    *guard = Some(name.clone());
672                }
673            }
674        }
675    }
676
677    fn get_info(&self) -> InitializeResult {
678        InitializeResult::new(
679            ServerCapabilities::builder()
680                .enable_tools()
681                .build(),
682        )
683        .with_server_info(
684            Implementation::new("origin-mcp", env!("CARGO_PKG_VERSION"))
685        )
686        .with_instructions(
687            "Origin is your personal memory layer — a local knowledge base that persists across sessions and tools.\n\
688             Think of yourself as a curator, not a logger. Store insights, not conversation artifacts.\n\n\
689             Origin is cumulative: each memory you store can be recalled, linked, and distilled into knowledge over time. \
690             It's also shared across all the user's tools: what you write, other agents (Claude Desktop, Claude Code, \
691             ChatGPT, Cursor, etc.) will read later. Write for any future reader, not just this conversation.\n\n\
692             FIRST THING EVERY SESSION: Call context to load the user's identity, preferences, goals, and\n\
693             topic-relevant memories. This is how you know who you're talking to. Use the result to model how the \
694             user thinks — their preferences, corrections, and past decisions tell you how they want to be helped, \
695             not just what they already know.\n\n\
696             STORE PROACTIVELY — don't wait for the user to ask.\n\
697             - The user states a preference (\"I use X because...\", \"I prefer Y over Z\")\n\
698             - The user makes a decision (\"going with approach A\", \"switching to B\")\n\
699             - The user corrects you or prior info (\"actually, it's C, not D\") — store the correction so it sticks\n\
700             - The user shares a durable fact about themselves, their work, or people/projects/tools they care about — \
701               anchor it to the entity\n\n\
702             If the user asks explicitly (\"remember this\", \"save this\", \"don't forget\"), that's a floor — you \
703             should have already stored it.\n\n\
704             WHEN NOT TO STORE:\n\
705             - Conversation filler (\"ok\", \"thanks\", \"let's move on\")\n\
706             - Things the user can trivially re-derive (file paths, recent git history)\n\
707             - Anything already stored — recall first if unsure\n\
708             - Tool output or command results (file contents, git history, build logs) — these are derivable\n\
709             - General world facts or documentation that aren't personal to this user (e.g., \"Rust has a borrow \
710               checker\", \"PostgreSQL supports JSONB\") — those are not memory material.\n\
711             - Your own inferences about the user that they didn't express. Store what they said; infer from that \
712               when responding.\n\n\
713             CONTENT QUALITY — this is where you make the biggest difference:\n\
714             - Specific beats vague: \"prefers Rust for CLI tools because of compile-time safety\" > \"likes Rust\"\n\
715             - Include the WHY: the backend can classify \"dark mode\" as a preference, but only you know\n\
716               \"switched to dark mode because of migraines from bright screens\"\n\
717             - Name the entities: mention people, projects, tools by name — this powers the knowledge graph\n\
718             - Atomic: one idea per memory — \"prefers TDD\" and \"uses pytest\" should be two memories, not one\n\
719             - Declarative, not narrative: \"User prefers X because Y\" — not \"User said today they prefer X\". \
720               Memories outlive the conversation that produced them.\n\n\
721             MEMORY TYPES — omit and trust the backend.\n\n\
722             By default, do NOT set memory_type. The backend auto-classifies into identity / preference / goal / \
723             fact / decision with more context than you have. Agents that over-specify types tend to pick wrong.\n\n\
724             Opt-in specification:\n\
725             - \"profile\"   — you're sure it's about the user (identity/preference/goal)\n\
726             - \"knowledge\" — you're sure it's about the world (fact/decision)\n\
727             - Precise type — only if you're confident and the distinction matters.\n\n\
728             EXCEPTION — decisions carry structured fields (alternatives considered, reversibility, domain) \
729             that power the Decision Log view. Set memory_type=\"decision\" explicitly ONLY when the user \
730             articulated alternatives weighed AND the reasoning for the choice. A bare \"I'm switching to Cursor\" \
731             is just a preference change — omit the type. \"Switching to Cursor over VSCode because of better \
732             Claude integration, and we can always go back\" — that's a decision.\n\n\
733             RECALL vs CONTEXT:\n\
734             - context: broad orientation, session start, topic shifts, \"catch me up\"\n\
735             - recall: specific lookup (\"what's Alice's role?\", \"database preferences\", \"our auth decision\")\n\n\
736             The backend handles classification, entity extraction, structured fields, quality scoring,\n\
737             and dedup — you don't need to replicate that logic. Focus on what only you know:\n\
738             the conversational context, why something matters, and what the user actually cares about."
739        )
740    }
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746    use crate::client::OriginClient;
747    use crate::types::{
748        ChatContextRequest, ChatContextResponse, SearchMemoryRequest, SearchResult,
749        StoreMemoryRequest, StoreMemoryResponse,
750    };
751
752    fn make_server(
753        transport: TransportMode,
754        agent_name: &str,
755        user_id: Option<&str>,
756    ) -> OriginMcpServer {
757        let client = OriginClient::new("http://127.0.0.1:19999".into());
758        OriginMcpServer::new(
759            client,
760            transport,
761            agent_name.into(),
762            user_id.map(String::from),
763        )
764    }
765
766    // ===== Transport resolution (existing) =====
767
768    #[test]
769    fn test_http_mode_prefers_param_over_agent_name() {
770        let server = make_server(TransportMode::Http, "claude.ai", None);
771        // Explicit param has highest priority
772        let result = server.resolve_source_agent(Some("user-provided".into()));
773        assert_eq!(result, Some("user-provided".into()));
774    }
775
776    #[test]
777    fn test_http_mode_sets_source_agent_when_none() {
778        let server = make_server(TransportMode::Http, "chatgpt", None);
779        let result = server.resolve_source_agent(None);
780        assert_eq!(result, Some("chatgpt".into()));
781    }
782
783    #[test]
784    fn test_stdio_mode_passes_through_source_agent() {
785        let server = make_server(TransportMode::Stdio, "ignored", None);
786        let result = server.resolve_source_agent(Some("user-provided".into()));
787        assert_eq!(result, Some("user-provided".into()));
788    }
789
790    #[test]
791    fn test_stdio_mode_falls_back_to_agent_name() {
792        let server = make_server(TransportMode::Stdio, "fallback", None);
793        // No param, no client_name → falls back to configured agent_name
794        let result = server.resolve_source_agent(None);
795        assert_eq!(result, Some("fallback".into()));
796    }
797
798    #[test]
799    fn test_http_mode_resolves_configured_user_id_for_local_use() {
800        let server = make_server(TransportMode::Http, "agent", Some("lucian"));
801        let result = server.resolve_user_id(None);
802        assert_eq!(result, Some("lucian".into()));
803    }
804
805    #[test]
806    fn test_transport_mode_equality() {
807        assert_eq!(TransportMode::Stdio, TransportMode::Stdio);
808        assert_eq!(TransportMode::Http, TransportMode::Http);
809        assert_ne!(TransportMode::Stdio, TransportMode::Http);
810    }
811
812    // ===== Param deserialization: CaptureParams =====
813
814    #[test]
815    fn test_capture_params_minimal() {
816        let json = r#"{"content": "Lucian prefers dark mode"}"#;
817        let params: CaptureParams = serde_json::from_str(json).unwrap();
818        assert_eq!(params.content, "Lucian prefers dark mode");
819        assert!(params.memory_type.is_none());
820        assert!(params.domain.is_none());
821        assert!(params.entity.is_none());
822        assert!(params.confidence.is_none());
823        assert!(params.supersedes.is_none());
824    }
825
826    #[test]
827    fn test_capture_params_full() {
828        let json = r#"{
829            "content": "We chose PostgreSQL over MongoDB",
830            "memory_type": "decision",
831            "domain": "origin",
832            "entity": "PostgreSQL",
833            "confidence": 0.95,
834            "supersedes": "mem_abc123"
835        }"#;
836        let params: CaptureParams = serde_json::from_str(json).unwrap();
837        assert_eq!(params.content, "We chose PostgreSQL over MongoDB");
838        assert_eq!(params.memory_type.as_deref(), Some("decision"));
839        assert_eq!(params.domain.as_deref(), Some("origin"));
840        assert_eq!(params.entity.as_deref(), Some("PostgreSQL"));
841        assert_eq!(params.confidence, Some(0.95));
842        assert_eq!(params.supersedes.as_deref(), Some("mem_abc123"));
843    }
844
845    #[test]
846    fn test_capture_params_missing_content_fails() {
847        let json = r#"{"memory_type": "fact"}"#;
848        let result = serde_json::from_str::<CaptureParams>(json);
849        assert!(result.is_err());
850    }
851
852    // ===== Param deserialization: RecallParams =====
853
854    #[test]
855    fn test_recall_params_minimal() {
856        let json = r#"{"query": "what does Alice work on?"}"#;
857        let params: RecallParams = serde_json::from_str(json).unwrap();
858        assert_eq!(params.query, "what does Alice work on?");
859        assert!(params.limit.is_none());
860    }
861
862    #[test]
863    fn test_recall_params_full() {
864        let json = r#"{
865            "query": "database preferences",
866            "limit": 5,
867            "memory_type": "decision",
868            "domain": "origin"
869        }"#;
870        let params: RecallParams = serde_json::from_str(json).unwrap();
871        assert_eq!(params.query, "database preferences");
872        assert_eq!(params.limit, Some(5));
873        assert_eq!(params.memory_type.as_deref(), Some("decision"));
874        assert_eq!(params.domain.as_deref(), Some("origin"));
875    }
876
877    #[test]
878    fn test_recall_params_limit_as_string() {
879        let json = r#"{"query": "test", "limit": "10"}"#;
880        let params: RecallParams = serde_json::from_str(json).unwrap();
881        assert_eq!(params.limit, Some(10));
882    }
883
884    #[test]
885    fn test_recall_params_missing_query_fails() {
886        let json = r#"{"limit": 5}"#;
887        let result = serde_json::from_str::<RecallParams>(json);
888        assert!(result.is_err());
889    }
890
891    // ===== Param deserialization: ContextParams =====
892
893    #[test]
894    fn test_context_params_empty() {
895        let json = r#"{}"#;
896        let params: ContextParams = serde_json::from_str(json).unwrap();
897        assert!(params.topic.is_none());
898        assert!(params.limit.is_none());
899        assert!(params.domain.is_none());
900    }
901
902    #[test]
903    fn test_context_params_full() {
904        let json = r#"{"topic": "project Origin architecture", "limit": 30, "domain": "work"}"#;
905        let params: ContextParams = serde_json::from_str(json).unwrap();
906        assert_eq!(params.topic.as_deref(), Some("project Origin architecture"));
907        assert_eq!(params.limit, Some(30));
908        assert_eq!(params.domain.as_deref(), Some("work"));
909    }
910
911    #[test]
912    fn test_context_params_limit_as_string() {
913        let json = r#"{"limit": "20"}"#;
914        let params: ContextParams = serde_json::from_str(json).unwrap();
915        assert_eq!(params.limit, Some(20));
916    }
917
918    #[test]
919    fn store_memory_request_serialization_excludes_user_id() {
920        let req = StoreMemoryRequest {
921            content: "test content".into(),
922            memory_type: None,
923            domain: None,
924            source_agent: Some("test-agent".into()),
925            title: None,
926            confidence: None,
927            supersedes: None,
928            entity: None,
929            entity_id: None,
930            structured_fields: None,
931            retrieval_cue: None,
932        };
933        let json = serde_json::to_value(&req).unwrap();
934        let obj = json.as_object().unwrap();
935        assert!(
936            !obj.contains_key("user_id"),
937            "user_id must not be on the wire; got: {:?}",
938            obj.keys().collect::<Vec<_>>()
939        );
940    }
941
942    #[test]
943    fn capture_success_message_is_terse() {
944        let resp = StoreMemoryResponse {
945            source_id: "mem_abc".into(),
946            chunks_created: 3,
947            memory_type: "fact".into(),
948            entity_id: Some("ent_xyz".into()),
949            quality: Some("high".into()),
950            warnings: vec![],
951            extraction_method: "llm".into(),
952            enrichment: String::new(),
953            hint: String::new(),
954        };
955        let msg = format_capture_success(&resp);
956        assert_eq!(msg, "Stored mem_abc");
957        assert!(!msg.contains("chunks"));
958        assert!(!msg.contains("quality"));
959        assert!(!msg.contains("entity"));
960    }
961
962    #[test]
963    fn capture_success_message_surfaces_warnings() {
964        let resp = StoreMemoryResponse {
965            source_id: "mem_abc".into(),
966            chunks_created: 1,
967            memory_type: "decision".into(),
968            entity_id: None,
969            quality: None,
970            warnings: vec!["decision memory missing required 'claim' field".into()],
971            extraction_method: "agent".into(),
972            enrichment: String::new(),
973            hint: String::new(),
974        };
975        let msg = format_capture_success(&resp);
976        assert!(msg.starts_with("Stored mem_abc"));
977        assert!(msg.contains("Warnings:"));
978        assert!(msg.contains("decision memory missing required 'claim' field"));
979    }
980
981    #[test]
982    fn doctor_basic_memory_message_sets_expectations() {
983        let msg = format_doctor_message(&serde_json::json!({
984            "setup_completed": true,
985            "mode": "basic-memory",
986            "anthropic_key_configured": false,
987            "local_model_selected": null,
988            "local_model_loaded": null,
989            "local_model_cached": false
990        }));
991
992        assert!(msg.contains("Mode: Basic Memory"));
993        assert!(msg.contains("On-device model: not selected"));
994        assert!(msg.contains("Background refinement: paused"));
995        assert!(msg.contains("Basic Memory works now: capture, recall, and context are available"));
996        assert!(msg.contains("origin model install"));
997        assert!(msg.contains("origin key set anthropic"));
998    }
999
1000    #[test]
1001    fn doctor_on_device_model_message_shows_loaded_model() {
1002        let msg = format_doctor_message(&serde_json::json!({
1003            "setup_completed": true,
1004            "mode": "local-model",
1005            "anthropic_key_configured": false,
1006            "local_model_selected": "qwen3-1.7b",
1007            "local_model_loaded": "qwen3-1.7b",
1008            "local_model_cached": true
1009        }));
1010
1011        assert!(msg.contains("Mode: On-device Model"), "{msg}");
1012        assert!(
1013            msg.contains("On-device model: qwen3-1.7b (downloaded, loaded)"),
1014            "{msg}"
1015        );
1016        assert!(msg.contains("Background refinement: enabled"), "{msg}");
1017        assert!(!msg.contains("Basic Memory works now"));
1018    }
1019
1020    #[test]
1021    fn doctor_unconfigured_message_names_three_setup_paths() {
1022        let msg = format_doctor_message(&serde_json::json!({
1023            "setup_completed": false,
1024            "mode": "unknown",
1025            "anthropic_key_configured": false,
1026            "local_model_selected": null,
1027            "local_model_loaded": null,
1028            "local_model_cached": false
1029        }));
1030
1031        assert!(msg.contains("Setup: not completed"));
1032        assert!(msg.contains("Run `origin setup`"));
1033        assert!(msg.contains("Basic Memory, On-device Model, or Anthropic Key"));
1034    }
1035
1036    #[test]
1037    fn search_memory_request_serialization_excludes_entity() {
1038        let req = SearchMemoryRequest {
1039            query: "test".into(),
1040            limit: 10,
1041            memory_type: None,
1042            domain: None,
1043            source_agent: None,
1044        };
1045        let json = serde_json::to_value(&req).unwrap();
1046        let obj = json.as_object().unwrap();
1047        assert!(
1048            !obj.contains_key("entity"),
1049            "entity must not be on the wire; got keys: {:?}",
1050            obj.keys().collect::<Vec<_>>()
1051        );
1052    }
1053
1054    #[test]
1055    fn chat_context_request_serialization_includes_domain() {
1056        #[allow(deprecated)]
1057        let req = ChatContextRequest {
1058            query: None,
1059            conversation_id: Some("topic".into()),
1060            max_chunks: 20,
1061            relevance_threshold: None,
1062            include_goals: true,
1063            domain: Some("work".into()),
1064        };
1065        let json = serde_json::to_value(&req).unwrap();
1066        assert_eq!(json["domain"], serde_json::json!("work"));
1067        assert_eq!(json["conversation_id"], serde_json::json!("topic"));
1068    }
1069
1070    #[test]
1071    fn chat_context_response_deserializes_with_profile_and_knowledge() {
1072        let json = r#"{
1073            "context": "user is Lucian, prefers Rust",
1074            "profile": {
1075                "narrative": "n",
1076                "identity": ["rust"],
1077                "preferences": [],
1078                "goals": []
1079            },
1080            "knowledge": {
1081                "pages": [],
1082                "decisions": [],
1083                "relevant_memories": [],
1084                "graph_context": []
1085            },
1086            "took_ms": 42.0,
1087            "token_estimates": {
1088                "tier1_identity": 10,
1089                "tier2_project": 20,
1090                "tier3_relevant": 30,
1091                "total": 60
1092            }
1093        }"#;
1094        let parsed: ChatContextResponse = serde_json::from_str(json).unwrap();
1095        assert_eq!(parsed.context, "user is Lucian, prefers Rust");
1096        assert_eq!(parsed.profile.identity, vec!["rust"]);
1097        assert_eq!(parsed.token_estimates.total, 60);
1098    }
1099
1100    #[test]
1101    fn capture_params_structured_fields_schema_is_object() {
1102        use schemars::schema_for;
1103
1104        let schema = schema_for!(CaptureParams);
1105        let json = serde_json::to_value(&schema).unwrap();
1106        let sf_schema = json
1107            .pointer("/properties/structured_fields")
1108            .expect("structured_fields property in schema");
1109        let type_val = sf_schema
1110            .pointer("/type")
1111            .unwrap_or(&serde_json::Value::Null);
1112        let type_str = match type_val {
1113            serde_json::Value::String(s) => s.clone(),
1114            serde_json::Value::Array(arr) => arr
1115                .iter()
1116                .filter_map(|v| v.as_str())
1117                .collect::<Vec<_>>()
1118                .join(","),
1119            other => panic!(
1120                "structured_fields schema lacks type constraint; got: {:?}",
1121                other
1122            ),
1123        };
1124        assert!(
1125            type_str.contains("object"),
1126            "expected object type, got: {}",
1127            type_str
1128        );
1129    }
1130
1131    // ===== Param deserialization: ForgetParams =====
1132
1133    #[test]
1134    fn test_forget_params() {
1135        let json = r#"{"memory_id": "mem_abc123"}"#;
1136        let params: ForgetParams = serde_json::from_str(json).unwrap();
1137        assert_eq!(params.memory_id, "mem_abc123");
1138    }
1139
1140    #[test]
1141    fn test_forget_params_missing_id_fails() {
1142        let json = r#"{}"#;
1143        let result = serde_json::from_str::<ForgetParams>(json);
1144        assert!(result.is_err());
1145    }
1146
1147    // ===== Request serialization: StoreMemoryRequest =====
1148
1149    #[test]
1150    fn test_store_request_includes_new_fields() {
1151        let req = StoreMemoryRequest {
1152            content: "test".into(),
1153            memory_type: Some("decision".into()),
1154            domain: None,
1155            source_agent: Some("claude".into()),
1156            title: None,
1157            confidence: Some(0.9),
1158            supersedes: Some("old_id".into()),
1159            entity: Some("PostgreSQL".into()),
1160            entity_id: None,
1161            structured_fields: None,
1162            retrieval_cue: None,
1163        };
1164        let json = serde_json::to_value(&req).unwrap();
1165        assert_eq!(json["entity"], "PostgreSQL");
1166        assert_eq!(json["supersedes"], "old_id");
1167        assert!(json["confidence"].as_f64().unwrap() > 0.89);
1168        assert_eq!(json["source_agent"], "claude");
1169        assert!(json.get("user_id").is_none());
1170    }
1171
1172    #[test]
1173    fn test_store_request_minimal() {
1174        let req = StoreMemoryRequest {
1175            content: "hello".into(),
1176            memory_type: Some("fact".into()),
1177            domain: None,
1178            source_agent: None,
1179            title: None,
1180            confidence: None,
1181            supersedes: None,
1182            entity: None,
1183            entity_id: None,
1184            structured_fields: None,
1185            retrieval_cue: None,
1186        };
1187        let json = serde_json::to_value(&req).unwrap();
1188        assert_eq!(json["content"], "hello");
1189        assert_eq!(json["memory_type"], "fact");
1190        assert!(json.get("user_id").is_none());
1191    }
1192
1193    // ===== Response deserialization: StoreMemoryResponse =====
1194
1195    #[test]
1196    fn test_store_response_with_new_fields() {
1197        let json = r#"{
1198            "source_id": "mem_xyz",
1199            "chunks_created": 2,
1200            "memory_type": "fact",
1201            "entity_id": "ent_abc",
1202            "quality": "high",
1203            "warnings": ["decision memory missing claim"],
1204            "extraction_method": "agent"
1205        }"#;
1206        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1207        assert_eq!(resp.source_id, "mem_xyz");
1208        assert_eq!(resp.chunks_created, 2);
1209        assert_eq!(resp.memory_type, "fact");
1210        assert_eq!(resp.entity_id.as_deref(), Some("ent_abc"));
1211        assert_eq!(resp.quality.as_deref(), Some("high"));
1212        assert_eq!(resp.warnings, vec!["decision memory missing claim"]);
1213        assert_eq!(resp.extraction_method, "agent");
1214    }
1215
1216    #[test]
1217    fn test_store_response_backward_compat_no_new_fields() {
1218        // Old backend response without warnings/extraction_method
1219        let json = r#"{
1220            "source_id": "mem_old",
1221            "chunks_created": 1,
1222            "memory_type": "fact"
1223        }"#;
1224        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1225        assert_eq!(resp.source_id, "mem_old");
1226        assert_eq!(resp.chunks_created, 1);
1227        assert_eq!(resp.memory_type, "fact");
1228        assert!(resp.entity_id.is_none());
1229        assert!(resp.quality.is_none());
1230        assert!(resp.warnings.is_empty());
1231        assert_eq!(resp.extraction_method, "unknown");
1232    }
1233
1234    #[test]
1235    fn test_store_response_with_warnings_and_extraction_method() {
1236        let json = r#"{
1237            "source_id": "mem_xyz",
1238            "chunks_created": 1,
1239            "memory_type": "decision",
1240            "warnings": ["decision memory missing required 'claim' field"],
1241            "extraction_method": "llm"
1242        }"#;
1243        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1244        assert_eq!(resp.memory_type, "decision");
1245        assert_eq!(
1246            resp.warnings,
1247            vec!["decision memory missing required 'claim' field"]
1248        );
1249        assert_eq!(resp.extraction_method, "llm");
1250    }
1251
1252    // ===== Response deserialization: SearchResult =====
1253
1254    #[test]
1255    fn test_search_result_with_new_fields() {
1256        let json = r#"{
1257            "id": "1",
1258            "content": "We chose Postgres",
1259            "source": "memory",
1260            "source_id": "mem_1",
1261            "title": "DB decision",
1262            "url": null,
1263            "chunk_index": 0,
1264            "last_modified": 1711000000,
1265            "score": 0.95,
1266            "chunk_type": "memory",
1267            "language": "en",
1268            "semantic_unit": "sentence",
1269            "memory_type": "decision",
1270            "domain": "origin",
1271            "source_agent": "claude",
1272            "confidence": 0.9,
1273            "confirmed": true,
1274            "stability": "standard",
1275            "supersedes": "mem_0",
1276            "summary": "DB choice",
1277            "entity_id": "ent_pg",
1278            "entity_name": "PostgreSQL",
1279            "quality": "high",
1280            "is_archived": false,
1281            "is_recap": false,
1282            "source_text": "We chose Postgres",
1283            "raw_score": 0.42
1284        }"#;
1285        let result: SearchResult = serde_json::from_str(json).unwrap();
1286        assert_eq!(result.chunk_type.as_deref(), Some("memory"));
1287        assert_eq!(result.language.as_deref(), Some("en"));
1288        assert_eq!(result.semantic_unit.as_deref(), Some("sentence"));
1289        assert_eq!(result.stability.as_deref(), Some("standard"));
1290        assert_eq!(result.supersedes.as_deref(), Some("mem_0"));
1291        assert_eq!(result.summary.as_deref(), Some("DB choice"));
1292        assert_eq!(result.entity_id.as_deref(), Some("ent_pg"));
1293        assert_eq!(result.entity_name.as_deref(), Some("PostgreSQL"));
1294        assert_eq!(result.quality.as_deref(), Some("high"));
1295        assert!(!result.is_archived);
1296        assert!(!result.is_recap);
1297        assert_eq!(result.source_text.as_deref(), Some("We chose Postgres"));
1298        assert!((result.raw_score - 0.42).abs() < f32::EPSILON);
1299    }
1300
1301    #[test]
1302    fn test_search_result_backward_compat_no_new_fields() {
1303        // Old backend response without entity/quality/archive/recap
1304        let json = r#"{
1305            "id": "1",
1306            "content": "test",
1307            "source": "memory",
1308            "source_id": "mem_1",
1309            "title": "test",
1310            "url": null,
1311            "chunk_index": 0,
1312            "last_modified": 1711000000,
1313            "score": 0.8,
1314            "memory_type": "fact",
1315            "domain": null,
1316            "source_agent": null,
1317            "confidence": null,
1318            "confirmed": null
1319        }"#;
1320        let result: SearchResult = serde_json::from_str(json).unwrap();
1321        assert!(result.entity_id.is_none());
1322        assert!(result.entity_name.is_none());
1323        assert!(result.quality.is_none());
1324        assert!(!result.is_archived);
1325        assert!(!result.is_recap);
1326        assert!(result.structured_fields.is_none());
1327        assert!(result.retrieval_cue.is_none());
1328        assert_eq!(result.raw_score, 0.0);
1329    }
1330
1331    #[test]
1332    fn test_search_result_with_structured_fields_and_retrieval_cue() {
1333        let json = r#"{
1334            "id": "1",
1335            "content": "Lucian prefers dark mode",
1336            "source": "memory",
1337            "source_id": "mem_1",
1338            "title": "Dark mode preference",
1339            "url": null,
1340            "chunk_index": 0,
1341            "last_modified": 1711000000,
1342            "score": 0.92,
1343            "memory_type": "preference",
1344            "domain": null,
1345            "source_agent": null,
1346            "confidence": null,
1347            "confirmed": null,
1348            "structured_fields": "{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}",
1349            "retrieval_cue": "What UI theme does Lucian prefer?"
1350        }"#;
1351        let result: SearchResult = serde_json::from_str(json).unwrap();
1352        assert_eq!(
1353            result.structured_fields.as_deref(),
1354            Some("{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}")
1355        );
1356        assert_eq!(
1357            result.retrieval_cue.as_deref(),
1358            Some("What UI theme does Lucian prefer?")
1359        );
1360        assert!(!result.is_archived);
1361        assert!(!result.is_recap);
1362        assert_eq!(result.raw_score, 0.0);
1363    }
1364
1365    #[test]
1366    fn test_search_result_knowledge_graph_source() {
1367        // Entity-boosted observation results from knowledge graph
1368        let json = r#"{
1369            "id": "obs_1",
1370            "content": "Prefers Rust over Go",
1371            "source": "knowledge_graph",
1372            "source_id": "ent_lucian",
1373            "title": "Lucian",
1374            "url": null,
1375            "chunk_index": 0,
1376            "last_modified": 1711000000,
1377            "score": 1.14,
1378            "memory_type": null,
1379            "domain": null,
1380            "source_agent": null,
1381            "confidence": null,
1382            "confirmed": null,
1383            "entity_id": "ent_lucian",
1384            "entity_name": "Lucian"
1385        }"#;
1386        let result: SearchResult = serde_json::from_str(json).unwrap();
1387        assert_eq!(result.source, "knowledge_graph");
1388        assert_eq!(result.entity_id.as_deref(), Some("ent_lucian"));
1389        assert_eq!(result.entity_name.as_deref(), Some("Lucian"));
1390        assert!(!result.is_archived);
1391        assert!(!result.is_recap);
1392        assert_eq!(result.raw_score, 0.0);
1393    }
1394
1395    // ===== Transport security: forget blocks on HTTP =====
1396
1397    #[tokio::test]
1398    async fn test_forget_blocked_on_http_transport() {
1399        let server = make_server(TransportMode::Http, "agent", None);
1400        let result = server.forget_impl("mem_123").await.unwrap();
1401        // Should return error content, not an Err
1402        let content = &result.content[0];
1403        match content.raw {
1404            rmcp::model::RawContent::Text(ref tc) => {
1405                assert!(tc.text.contains("not available over remote connections"));
1406            }
1407            _ => panic!("expected text content"),
1408        }
1409    }
1410
1411    #[tokio::test]
1412    async fn test_forget_allowed_on_stdio_transport() {
1413        // This will fail with connection error (no server), which proves
1414        // the transport check passed and it tried to make the HTTP call.
1415        // The error comes back as CallToolResult with is_error: true
1416        // (tool-level failure), not McpError (protocol-level).
1417        let server = make_server(TransportMode::Stdio, "agent", None);
1418        let result = server.forget_impl("mem_123").await.unwrap();
1419        assert!(
1420            result.is_error.unwrap_or(false),
1421            "should fail with connection error, not transport block"
1422        );
1423    }
1424
1425    // ===== Context default limit =====
1426
1427    #[test]
1428    fn test_context_request_default_limit() {
1429        let params = ContextParams {
1430            topic: Some("test".into()),
1431            limit: None,
1432            domain: None,
1433        };
1434        #[allow(deprecated)]
1435        let req = ChatContextRequest {
1436            query: None,
1437            conversation_id: params.topic,
1438            max_chunks: params.limit.unwrap_or(20),
1439            relevance_threshold: None,
1440            include_goals: true,
1441            domain: params.domain,
1442        };
1443        assert_eq!(req.max_chunks, 20);
1444    }
1445
1446    #[test]
1447    fn test_context_request_custom_limit() {
1448        let params = ContextParams {
1449            topic: None,
1450            limit: Some(5),
1451            domain: Some("work".into()),
1452        };
1453        #[allow(deprecated)]
1454        let req = ChatContextRequest {
1455            query: None,
1456            conversation_id: params.topic,
1457            max_chunks: params.limit.unwrap_or(20),
1458            relevance_threshold: None,
1459            include_goals: true,
1460            domain: params.domain,
1461        };
1462        assert_eq!(req.max_chunks, 5);
1463        assert_eq!(req.domain.as_deref(), Some("work"));
1464    }
1465
1466    #[test]
1467    fn test_context_maps_topic_to_conversation_id() {
1468        let params = ContextParams {
1469            topic: Some("project Origin".into()),
1470            limit: None,
1471            domain: None,
1472        };
1473        #[allow(deprecated)]
1474        let req = ChatContextRequest {
1475            query: None,
1476            conversation_id: params.topic.clone(),
1477            max_chunks: params.limit.unwrap_or(20),
1478            relevance_threshold: None,
1479            include_goals: true,
1480            domain: params.domain,
1481        };
1482        assert_eq!(req.conversation_id.as_deref(), Some("project Origin"));
1483    }
1484
1485    // ===== Remember request construction =====
1486
1487    #[test]
1488    fn test_capture_constructs_store_request_with_entity() {
1489        let server = make_server(TransportMode::Stdio, "claude", None);
1490        let params = CaptureParams {
1491            content: "Alice manages the frontend team".into(),
1492            memory_type: Some("fact".into()),
1493            domain: Some("work".into()),
1494            entity: Some("Alice".into()),
1495            confidence: Some(0.9),
1496            supersedes: None,
1497            structured_fields: None,
1498            retrieval_cue: None,
1499        };
1500
1501        // Replicate capture_impl's request construction
1502        let source_agent = server.resolve_source_agent(None);
1503
1504        let req = StoreMemoryRequest {
1505            content: params.content,
1506            memory_type: params.memory_type,
1507            domain: params.domain,
1508            source_agent,
1509            title: None,
1510            confidence: params.confidence,
1511            supersedes: params.supersedes,
1512            entity: params.entity,
1513            entity_id: None,
1514            structured_fields: params.structured_fields.map(serde_json::Value::Object),
1515            retrieval_cue: params.retrieval_cue,
1516        };
1517
1518        let json = serde_json::to_value(&req).unwrap();
1519        assert_eq!(json["content"], "Alice manages the frontend team");
1520        assert_eq!(json["memory_type"], "fact");
1521        assert_eq!(json["domain"], "work");
1522        assert_eq!(json["entity"], "Alice");
1523        assert!(json["confidence"].as_f64().unwrap() > 0.89);
1524        // stdio mode: no param, no client_name → falls back to agent_name "claude"
1525        assert_eq!(json["source_agent"], "claude");
1526    }
1527
1528    #[test]
1529    fn test_remember_http_mode_injects_agent() {
1530        let server = make_server(TransportMode::Http, "claude.ai", Some("lucian"));
1531        let source_agent = server.resolve_source_agent(None);
1532
1533        assert_eq!(source_agent, Some("claude.ai".into()));
1534    }
1535
1536    // ===== Recall request construction =====
1537
1538    #[test]
1539    fn test_recall_constructs_search_request() {
1540        let params = RecallParams {
1541            query: "database choices".into(),
1542            limit: Some(5),
1543            memory_type: Some("decision".into()),
1544            domain: None,
1545        };
1546
1547        let req = SearchMemoryRequest {
1548            query: params.query,
1549            limit: params.limit.unwrap_or(10),
1550            memory_type: params.memory_type,
1551            domain: params.domain,
1552            source_agent: None,
1553        };
1554
1555        let json = serde_json::to_value(&req).unwrap();
1556        assert_eq!(json["query"], "database choices");
1557        assert_eq!(json["limit"], 5);
1558        assert_eq!(json["memory_type"], "decision");
1559        assert!(json.get("entity").is_none());
1560        assert!(json["domain"].is_null());
1561        assert!(json["source_agent"].is_null());
1562    }
1563
1564    // ===== Memory type backward compat =====
1565
1566    #[test]
1567    fn test_remember_passes_through_all_5_types() {
1568        for t in &["identity", "preference", "fact", "decision", "goal"] {
1569            let params = CaptureParams {
1570                content: "test".into(),
1571                memory_type: Some(t.to_string()),
1572                domain: None,
1573                entity: None,
1574                confidence: None,
1575                supersedes: None,
1576                structured_fields: None,
1577                retrieval_cue: None,
1578            };
1579            assert_eq!(params.memory_type.as_deref(), Some(*t));
1580        }
1581    }
1582
1583    // ===== Structured fields in remember params =====
1584
1585    #[test]
1586    fn test_capture_params_with_structured_fields_and_cue() {
1587        let json = r#"{
1588            "content": "Lucian prefers dark mode",
1589            "structured_fields": {"theme":"dark"},
1590            "retrieval_cue": "What theme does Lucian prefer?"
1591        }"#;
1592        let params: CaptureParams = serde_json::from_str(json).unwrap();
1593        let structured_fields = params.structured_fields.expect("structured_fields");
1594        assert_eq!(
1595            structured_fields.get("theme"),
1596            Some(&serde_json::Value::String("dark".into()))
1597        );
1598        assert_eq!(
1599            params.retrieval_cue.as_deref(),
1600            Some("What theme does Lucian prefer?")
1601        );
1602    }
1603
1604    #[test]
1605    fn test_store_request_with_structured_fields() {
1606        let req = StoreMemoryRequest {
1607            content: "test".into(),
1608            memory_type: Some("fact".into()),
1609            domain: None,
1610            source_agent: None,
1611            title: None,
1612            confidence: None,
1613            supersedes: None,
1614            entity: None,
1615            entity_id: None,
1616            structured_fields: Some(serde_json::json!({"key":"val"})),
1617            retrieval_cue: Some("What is the key?".into()),
1618        };
1619        let json = serde_json::to_value(&req).unwrap();
1620        assert_eq!(json["structured_fields"], serde_json::json!({"key":"val"}));
1621        assert_eq!(json["retrieval_cue"], "What is the key?");
1622    }
1623
1624    // ===== ChatContextResponse deserialization =====
1625
1626    #[test]
1627    fn test_chat_context_response() {
1628        let json = r#"{
1629            "context": "User prefers dark mode. Works on Origin project.",
1630            "profile": {
1631                "narrative": "narrative",
1632                "identity": [],
1633                "preferences": [],
1634                "goals": []
1635            },
1636            "knowledge": {
1637                "pages": [],
1638                "decisions": [],
1639                "relevant_memories": [],
1640                "graph_context": []
1641            },
1642            "took_ms": 12.5,
1643            "token_estimates": {
1644                "tier1_identity": 1,
1645                "tier2_project": 2,
1646                "tier3_relevant": 3,
1647                "total": 6
1648            }
1649        }"#;
1650        let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
1651        assert!(!resp.context.is_empty());
1652        assert!(resp.profile.identity.is_empty());
1653        assert_eq!(resp.took_ms, 12.5);
1654        assert_eq!(resp.token_estimates.total, 6);
1655    }
1656
1657    #[test]
1658    fn test_chat_context_response_empty() {
1659        let json = r#"{
1660            "context": "",
1661            "profile": {
1662                "narrative": "",
1663                "identity": [],
1664                "preferences": [],
1665                "goals": []
1666            },
1667            "knowledge": {
1668                "pages": [],
1669                "decisions": [],
1670                "relevant_memories": [],
1671                "graph_context": []
1672            },
1673            "took_ms": 1.0,
1674            "token_estimates": {
1675                "tier1_identity": 0,
1676                "tier2_project": 0,
1677                "tier3_relevant": 0,
1678                "total": 0
1679            }
1680        }"#;
1681        let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
1682        assert!(resp.context.is_empty());
1683    }
1684
1685    // ===== with_instructions content assertions =====
1686    // These tests lock in the refined agent-facing guidance. If any
1687    // assertion fails, either the rule was intentionally changed
1688    // (update the test) or the refinement was accidentally dropped
1689    // (restore the rule).
1690
1691    fn server_instructions() -> String {
1692        let s = make_server(TransportMode::Stdio, "test", None);
1693        s.get_info()
1694            .instructions
1695            .expect("server must ship with_instructions")
1696    }
1697
1698    #[test]
1699    fn instructions_mention_cumulative_knowledge() {
1700        assert!(
1701            server_instructions().contains("cumulative"),
1702            "with_instructions must describe Origin as cumulative"
1703        );
1704    }
1705
1706    #[test]
1707    fn instructions_mention_shared_across_tools() {
1708        assert!(
1709            server_instructions().contains("shared across all"),
1710            "with_instructions must tell agents the store is shared across tools"
1711        );
1712    }
1713
1714    #[test]
1715    fn instructions_mention_how_user_thinks() {
1716        assert!(
1717            server_instructions().contains("how the user thinks"),
1718            "with_instructions must frame context as modeling how the user thinks"
1719        );
1720    }
1721
1722    #[test]
1723    fn instructions_use_proactive_framing() {
1724        assert!(
1725            server_instructions().contains("STORE PROACTIVELY"),
1726            "with_instructions must use STORE PROACTIVELY framing (not passive WHEN TO STORE)"
1727        );
1728    }
1729
1730    #[test]
1731    fn instructions_ban_tool_output_storage() {
1732        assert!(
1733            server_instructions().contains("Tool output or command results"),
1734            "with_instructions must explicitly rule out tool output as storage material"
1735        );
1736    }
1737
1738    #[test]
1739    fn instructions_ban_ghost_inferences() {
1740        assert!(
1741            server_instructions().contains("Your own inferences"),
1742            "with_instructions must rule out storing agent's own inferences user didn't express"
1743        );
1744    }
1745
1746    #[test]
1747    fn instructions_call_out_atomic_memory() {
1748        assert!(
1749            server_instructions().contains("Atomic: one idea per memory"),
1750            "with_instructions must call out the atomic-memory rule explicitly by name"
1751        );
1752    }
1753
1754    #[test]
1755    fn instructions_specify_declarative_writing() {
1756        assert!(
1757            server_instructions().contains("Declarative, not narrative"),
1758            "with_instructions must require declarative (not narrative) writing style"
1759        );
1760    }
1761
1762    #[test]
1763    fn instructions_default_to_omit_memory_type() {
1764        let i = server_instructions();
1765        assert!(
1766            i.contains("omit and trust the backend"),
1767            "with_instructions must default agents to omitting memory_type"
1768        );
1769        assert!(
1770            i.contains("do NOT set memory_type"),
1771            "with_instructions must explicitly say do NOT set memory_type by default"
1772        );
1773    }
1774
1775    #[test]
1776    fn instructions_carve_out_decisions_for_decision_log() {
1777        let i = server_instructions();
1778        assert!(
1779            i.contains("Decision Log"),
1780            "with_instructions must name the Decision Log as the reason for explicit decision typing"
1781        );
1782        assert!(
1783            i.contains("memory_type=\"decision\""),
1784            "with_instructions must tell agents to set memory_type=\"decision\" explicitly for decisions"
1785        );
1786    }
1787
1788    // ===== tool-level and param-level description assertions =====
1789
1790    fn tool_descriptions() -> std::collections::HashMap<String, String> {
1791        let server = make_server(TransportMode::Stdio, "test", None);
1792        server
1793            .tool_router
1794            .list_all()
1795            .into_iter()
1796            .filter_map(|t| {
1797                let desc = t.description.as_ref()?.to_string();
1798                Some((t.name.to_string(), desc))
1799            })
1800            .collect()
1801    }
1802
1803    #[test]
1804    fn capture_description_calls_out_atomic() {
1805        let descriptions = tool_descriptions();
1806        let capture = descriptions.get("capture").expect("capture tool exists");
1807        assert!(
1808            capture.contains("Each call is one atomic idea"),
1809            "capture description must call out atomic-per-call explicitly, got: {capture}"
1810        );
1811    }
1812
1813    #[test]
1814    fn context_description_frames_modeling_user() {
1815        let descriptions = tool_descriptions();
1816        let ctx = descriptions.get("context").expect("context tool exists");
1817        assert!(
1818            ctx.contains("how the user thinks"),
1819            "context description must frame the result as modeling how the user thinks, got: {ctx}"
1820        );
1821    }
1822
1823    #[test]
1824    fn doctor_description_mentions_setup_mode() {
1825        let descriptions = tool_descriptions();
1826        let status = descriptions.get("doctor").expect("doctor tool exists");
1827        assert!(
1828            status.contains("Basic Memory"),
1829            "doctor description must mention setup modes, got: {status}"
1830        );
1831        assert!(
1832            status.contains("On-device Model"),
1833            "doctor description must mention on-device setup, got: {status}"
1834        );
1835        assert!(
1836            status.contains("not part of the memory loop"),
1837            "doctor description must frame itself as diagnostic-only, got: {status}"
1838        );
1839    }
1840
1841    #[test]
1842    fn recall_memory_type_param_lists_two_level_filter() {
1843        let params_schema = serde_json::to_string(&schemars::schema_for!(RecallParams))
1844            .expect("RecallParams schema serializes");
1845        assert!(
1846            params_schema.contains("Two-level filter"),
1847            "RecallParams.memory_type must advertise the two-level filter, got schema: {params_schema}"
1848        );
1849        assert!(
1850            params_schema.contains("profile"),
1851            "RecallParams.memory_type must mention profile alias"
1852        );
1853        assert!(
1854            params_schema.contains("knowledge"),
1855            "RecallParams.memory_type must mention knowledge alias"
1856        );
1857    }
1858}