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