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/// Deserialize an `Option<i64>` that also accepts stringified numbers (e.g. `"1715000000000"`).
36/// Same lenient shape as `deserialize_optional_usize_lenient`, for params that map onto
37/// signed daemon fields (timestamps, badge windows, etc.).
38fn deserialize_optional_i64_lenient<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
39where
40    D: Deserializer<'de>,
41{
42    #[derive(Deserialize)]
43    #[serde(untagged)]
44    enum StringOrNumber {
45        Number(i64),
46        Str(String),
47    }
48
49    match Option::<StringOrNumber>::deserialize(deserializer)? {
50        None => Ok(None),
51        Some(StringOrNumber::Number(n)) => Ok(Some(n)),
52        Some(StringOrNumber::Str(s)) => {
53            s.parse::<i64>().map(Some).map_err(serde::de::Error::custom)
54        }
55    }
56}
57
58/// Controls which operations are allowed based on transport.
59#[derive(Clone, Debug, PartialEq)]
60pub enum TransportMode {
61    /// Local stdio — full access, all tools
62    Stdio,
63    /// Remote HTTP — block deletes, inject source_agent
64    Http,
65}
66
67#[derive(Clone)]
68pub struct OriginMcpServer {
69    #[allow(dead_code)]
70    tool_router: ToolRouter<Self>,
71    client: OriginClient,
72    transport: TransportMode,
73    agent_name: String,
74    /// Client name from MCP initialize handshake (e.g., "Claude Code", "Claude Desktop")
75    client_name: std::sync::Arc<std::sync::Mutex<Option<String>>>,
76    user_id: Option<String>,
77}
78
79// ===== Parameter Structs =====
80
81// --- Primary tool params ---
82
83#[derive(Debug, Deserialize, schemars::JsonSchema)]
84pub struct CaptureParams {
85    #[schemars(
86        description = "The memory content. Write as a complete statement with context and reasoning, not shorthand. One idea per memory."
87    )]
88    pub content: String,
89    #[schemars(description = origin_types::MEMORY_TYPE_CAPTURE_DESCRIPTION)]
90    pub memory_type: Option<String>,
91    #[schemars(
92        description = "Topic scope (e.g. 'rust', 'work', 'health', 'origin'). Auto-detected if omitted."
93    )]
94    pub domain: Option<String>,
95    #[schemars(
96        description = "Person, project, or tool name to anchor to (e.g. 'Alice', 'Origin', 'PostgreSQL'). Helps build the knowledge graph."
97    )]
98    pub entity: Option<String>,
99    #[schemars(
100        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."
101    )]
102    pub confidence: Option<f32>,
103    #[schemars(
104        description = "source_id of a memory this replaces. Use when correcting or updating an existing memory — get the ID from recall first."
105    )]
106    pub supersedes: Option<String>,
107    #[schemars(
108        description = "Pre-extracted structured fields as a JSON object. Auto-extracted by backend; only supply if you have high-quality structured data already."
109    )]
110    pub structured_fields: Option<serde_json::Map<String, serde_json::Value>>,
111    #[schemars(
112        description = "A question this memory answers, for search matching. Auto-generated by backend; only supply to override."
113    )]
114    pub retrieval_cue: Option<String>,
115}
116
117#[derive(Debug, Deserialize, schemars::JsonSchema)]
118pub struct RecallParams {
119    #[schemars(
120        description = "Natural language search. Be specific: 'Alice database preference' finds more than 'database stuff'."
121    )]
122    pub query: String,
123    #[schemars(
124        description = "Max results, default 10. Use 3-5 for quick lookups, 10-20 for exploration."
125    )]
126    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
127    pub limit: Option<usize>,
128    #[schemars(description = origin_types::MEMORY_TYPE_FILTER_DESCRIPTION)]
129    pub memory_type: Option<String>,
130    #[schemars(description = "Filter by topic scope.")]
131    pub domain: Option<String>,
132}
133
134#[derive(Debug, Deserialize, schemars::JsonSchema)]
135pub struct ContextParams {
136    #[schemars(
137        description = "Topic or conversation summary to focus context retrieval. Omit at session start for general orientation; provide when shifting topics."
138    )]
139    pub topic: Option<String>,
140    #[schemars(
141        description = "Max context chunks, default 20. Increase for complex topics, decrease for quick check-ins."
142    )]
143    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
144    pub limit: Option<usize>,
145    #[schemars(
146        description = "Scope context to a domain/space (e.g. 'work', 'personal'). Auto-detected from conversation if omitted."
147    )]
148    pub domain: Option<String>,
149}
150
151#[derive(Debug, Deserialize, schemars::JsonSchema)]
152pub struct ForgetParams {
153    #[schemars(
154        description = "The source_id of the memory to delete. Get this from recall results first."
155    )]
156    pub memory_id: String,
157}
158
159#[derive(Debug, Deserialize, schemars::JsonSchema)]
160pub struct DistillParams {
161    #[schemars(
162        description = "Optional target scope. Accepts a page id (`page_*` or `concept_*`) to re-distill that single page, an entity name (e.g. `Origin`, `Alice`) to scope clustering to that entity, or a domain value (e.g. `work`, `personal`) to scope to that domain. Omit for a full pass over any clusters with new sources. The daemon resolves the string and falls back with a hint payload if nothing matches."
163    )]
164    #[serde(default, alias = "page_id")]
165    pub target: Option<String>,
166}
167
168#[derive(Debug, Deserialize, schemars::JsonSchema)]
169pub struct ListPendingParams {
170    #[schemars(
171        description = "Max results, default 20. Increase for full audit, decrease for quick check-in."
172    )]
173    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
174    pub limit: Option<usize>,
175}
176
177#[derive(Debug, Deserialize, schemars::JsonSchema)]
178pub struct ConfirmMemoryParams {
179    #[schemars(
180        description = "The source_id of the memory to confirm. Get this from list_pending or recall results."
181    )]
182    pub memory_id: String,
183}
184
185// --- Knowledge graph CRUD params ---
186
187#[derive(Debug, Deserialize, schemars::JsonSchema)]
188pub struct CreateEntityParams {
189    #[schemars(
190        description = "Canonical entity name (e.g. 'Alice', 'Origin', 'PostgreSQL'). Use the exact, full name — aliases resolve to this canonical form."
191    )]
192    pub name: String,
193    #[schemars(
194        description = "Entity category: 'person', 'project', 'tool', 'place', 'organization', etc. Free-form string; choose the noun that best describes what it is."
195    )]
196    pub entity_type: String,
197    #[schemars(description = "Topic scope (e.g. 'work', 'origin'). Optional.")]
198    pub domain: Option<String>,
199    #[schemars(
200        description = "0.0-1.0 confidence in the entity assertion. Leave unset for caller-default."
201    )]
202    pub confidence: Option<f32>,
203}
204
205#[derive(Debug, Deserialize, schemars::JsonSchema)]
206pub struct CreateRelationParams {
207    #[schemars(
208        description = "Canonical name of the source entity (e.g. 'Alice'). Must exist or will be created on the daemon side."
209    )]
210    pub from_entity: String,
211    #[schemars(
212        description = "Canonical name of the target entity (e.g. 'Origin'). Must exist or will be created on the daemon side."
213    )]
214    pub to_entity: String,
215    #[schemars(
216        description = "Verb describing the directed relation (e.g. 'works_on', 'prefers', 'uses', 'depends_on'). Snake_case, present-tense."
217    )]
218    pub relation_type: String,
219}
220
221#[derive(Debug, Deserialize, schemars::JsonSchema)]
222pub struct CreatePageParams {
223    #[schemars(
224        description = "Short noun phrase that names the page (e.g. 'Origin daemon architecture')."
225    )]
226    pub title: String,
227    #[schemars(
228        description = "Markdown body — 3-7 paragraphs of wiki prose with [[wikilinks]]. Cite source ids inline as (source: mem_XXX)."
229    )]
230    pub content: String,
231    #[schemars(description = "Optional one-sentence summary — the durable claim.")]
232    pub summary: Option<String>,
233    #[schemars(
234        description = "Optional entity_id (e.g. 'ent_abc') to anchor the page to a knowledge-graph entity."
235    )]
236    pub entity_id: Option<String>,
237    #[schemars(description = "Topic scope (e.g. 'origin', 'work'). Optional.")]
238    pub domain: Option<String>,
239    #[schemars(
240        description = "Memory source_ids the page is distilled from. Required for traceability."
241    )]
242    #[serde(default)]
243    pub source_memory_ids: Vec<String>,
244}
245
246#[derive(Debug, Deserialize, schemars::JsonSchema)]
247pub struct DeletePageParams {
248    #[schemars(
249        description = "Page id (e.g. 'page_abc' or legacy 'concept_abc'). Get it from get_page or distill output."
250    )]
251    pub page_id: String,
252}
253
254#[derive(Debug, Deserialize, schemars::JsonSchema)]
255pub struct UpdatePageParams {
256    #[schemars(
257        description = "Page id (e.g. 'page_abc' or legacy 'concept_abc'). Get it from the `stale_pages` block in distill output."
258    )]
259    pub page_id: String,
260    #[schemars(
261        description = "Refreshed markdown body — same wiki-prose style as create_page. Replaces the existing content."
262    )]
263    pub content: String,
264    #[schemars(
265        description = "Full source_memory_ids list for the refreshed page — typically the stale page's existing list (carry through from distill output)."
266    )]
267    pub source_memory_ids: Vec<String>,
268    #[schemars(
269        description = "Optional one-sentence summary. Omit to keep the existing summary; pass empty string to clear it."
270    )]
271    pub summary: Option<String>,
272}
273
274#[derive(Debug, Deserialize, schemars::JsonSchema)]
275pub struct GetPageParams {
276    #[schemars(
277        description = "Page id (e.g. 'page_abc' or legacy 'concept_abc'). For title-based lookup, search via recall or the daemon's /api/pages/search."
278    )]
279    pub page_id: String,
280}
281
282#[derive(Debug, Deserialize, schemars::JsonSchema)]
283pub struct GetPageLinksParams {
284    #[schemars(
285        description = "Page id (e.g. 'page_abc'). Returns inbound + outbound wikilink graph for that page."
286    )]
287    pub page_id: String,
288}
289
290#[derive(Debug, Deserialize, schemars::JsonSchema)]
291pub struct ListMemoriesParams {
292    #[schemars(
293        description = "Filter by memory type (e.g. 'fact', 'preference', 'decision'). Optional."
294    )]
295    pub memory_type: Option<String>,
296    #[schemars(description = "Filter by topic/domain. Optional.")]
297    pub domain: Option<String>,
298    #[schemars(
299        description = "Max results, default 100. Increase for bulk listings, decrease for quick scans."
300    )]
301    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
302    pub limit: Option<usize>,
303}
304
305#[derive(Debug, Deserialize, schemars::JsonSchema)]
306pub struct SearchPagesParams {
307    #[schemars(
308        description = "Natural-language search over page title + body content (e.g. 'mutex deadlock', 'distillation architecture')."
309    )]
310    pub query: String,
311    #[schemars(
312        description = "Max results, default 20. Use 1 to resolve a title to its id before calling get_page; higher for broader search."
313    )]
314    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
315    pub limit: Option<usize>,
316}
317
318#[derive(Debug, Deserialize, schemars::JsonSchema)]
319pub struct ListPagesRecentParams {
320    #[schemars(
321        description = "Max results, default 10. Use higher (up to ~50) for a wider sweep of recent activity."
322    )]
323    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
324    pub limit: Option<usize>,
325    #[schemars(
326        description = "Optional Unix milliseconds. Items modified before this timestamp lose their 'new'/'updated' badge; the feed itself is still top-N by recency. This is not a date filter — items before `since_ms` are still returned, just without badges. Omit for default badge behavior."
327    )]
328    #[serde(default, deserialize_with = "deserialize_optional_i64_lenient")]
329    pub since_ms: Option<i64>,
330}
331
332// ===== Internal Implementations =====
333
334fn format_capture_success(resp: &StoreMemoryResponse) -> String {
335    let mut msg = format!("Stored {}", resp.source_id);
336    if !resp.warnings.is_empty() {
337        msg.push_str("\nWarnings:");
338        for warning in &resp.warnings {
339            msg.push_str(&format!("\n  - {}", warning));
340        }
341    }
342    msg
343}
344
345fn daemon_setup_hint() -> &'static str {
346    "Install the local Origin runtime and run `origin setup`.
347
348Setup choices:
349- Basic Memory: store, search, and recall now. No model download or API key.
350- On-device Model: private local extraction and background refinement after model download.
351- Anthropic Key: richer extraction and background refinement using your API key.
352
353Install:
354  curl -fsSL https://raw.githubusercontent.com/7xuanlu/origin/main/install.sh | bash
355  export PATH=\"$HOME/.origin/bin:$PATH\"
356  origin setup
357  origin install
358  origin status"
359}
360
361/// Convert a backend error into a tool-level error result (isError: true)
362/// with an actionable message. This keeps the MCP transport healthy
363/// (no protocol-level McpError) while telling the caller what happened.
364fn tool_error(e: OriginError, verb: &str) -> CallToolResult {
365    let msg = match &e {
366        OriginError::Unreachable(_) => format!(
367            "Origin daemon is not reachable (retried 3x over ~6s). \
368             The {verb} was NOT completed.\n\n{}",
369            daemon_setup_hint()
370        ),
371        OriginError::Api { status, body } => format!(
372            "Origin daemon returned HTTP {status}: {body}. The {verb} may not have completed."
373        ),
374        OriginError::Deserialize(detail) => format!(
375            "Failed to parse daemon response: {detail}. \
376             This may indicate a version mismatch between origin-mcp and the daemon."
377        ),
378    };
379    CallToolResult::error(vec![Content::text(msg)])
380}
381
382fn format_doctor_message(status: &serde_json::Value) -> String {
383    let mode = status
384        .get("mode")
385        .and_then(|v| v.as_str())
386        .unwrap_or("unknown");
387    let setup_completed = status
388        .get("setup_completed")
389        .and_then(|v| v.as_bool())
390        .unwrap_or(false);
391    let anthropic_key_configured = status
392        .get("anthropic_key_configured")
393        .and_then(|v| v.as_bool())
394        .unwrap_or(false);
395    let local_model_selected = status.get("local_model_selected").and_then(|v| v.as_str());
396    let local_model_loaded = status.get("local_model_loaded").and_then(|v| v.as_str());
397    let local_model_cached = status
398        .get("local_model_cached")
399        .and_then(|v| v.as_bool())
400        .unwrap_or(false);
401
402    let mode_label = match mode {
403        "basic-memory" => "Basic Memory",
404        "local-model" => "On-device Model",
405        "anthropic-key" => "Anthropic Key",
406        other => other,
407    };
408    let local_model_line = match local_model_selected {
409        Some(id) => {
410            let cache_status = if local_model_cached {
411                "downloaded"
412            } else {
413                "not downloaded"
414            };
415            let loaded_status = if Some(id) == local_model_loaded {
416                ", loaded"
417            } else {
418                ""
419            };
420            format!("{id} ({cache_status}{loaded_status})")
421        }
422        None => "not selected".to_string(),
423    };
424    let refinement_line = if anthropic_key_configured || local_model_loaded.is_some() {
425        "enabled (richer extraction and background refinement are active)"
426    } else if setup_completed {
427        "paused (Basic Memory stores, searches, and recalls now. Choose an on-device model or Anthropic key for richer extraction.)"
428    } else {
429        "not configured"
430    };
431
432    let mut msg = format!(
433        "Origin daemon: running\n\
434         Setup: {}\n\
435         Mode: {mode_label}\n\
436         Anthropic key: {}\n\
437         On-device model: {local_model_line}\n\
438         Background refinement: {refinement_line}",
439        if setup_completed {
440            "completed"
441        } else {
442            "not completed"
443        },
444        if anthropic_key_configured {
445            "configured"
446        } else {
447            "not configured"
448        }
449    );
450
451    if !setup_completed {
452        msg.push_str(
453            "\n\nRun `origin setup` to choose Basic Memory, On-device Model, or Anthropic Key.",
454        );
455    } else if !anthropic_key_configured && local_model_loaded.is_none() {
456        msg.push_str(
457            "\n\nBasic Memory works now: capture, recall, and context are available. \
458             To enable richer extraction and background refinement, run `origin model install` \
459             or `origin key set anthropic`.",
460        );
461    }
462
463    msg
464}
465
466impl OriginMcpServer {
467    /// Resolve the source_agent for a write operation.
468    /// Priority: explicit param > MCP client name (from initialize) > configured agent_name.
469    fn resolve_source_agent(&self, param_agent: Option<String>) -> Option<String> {
470        // 1. Explicit param from tool call
471        if let Some(ref agent) = param_agent {
472            if !agent.is_empty() {
473                return param_agent;
474            }
475        }
476        // 2. Client name captured from MCP initialize handshake
477        if let Ok(guard) = self.client_name.lock() {
478            if let Some(ref name) = *guard {
479                return Some(name.clone());
480            }
481        }
482        // 3. Configured --agent-name flag
483        Some(self.agent_name.clone())
484    }
485
486    /// Resolve a local user_id for logging or future use.
487    /// This value is intentionally not sent on the wire (D4).
488    fn resolve_user_id(&self, param_user_id: Option<String>) -> Option<String> {
489        if self.transport == TransportMode::Http {
490            self.user_id.clone().or(param_user_id)
491        } else {
492            param_user_id
493        }
494    }
495
496    pub async fn capture_impl(&self, params: CaptureParams) -> Result<CallToolResult, McpError> {
497        // Tool was renamed `remember -> capture` in v0.4. The HTTP request
498        // body shape (StoreMemoryRequest) is unchanged; only the MCP-facing
499        // tool name shifted.
500        let source_agent = self.resolve_source_agent(None);
501        if let Some(uid) = self.resolve_user_id(None) {
502            tracing::debug!(user_id = %uid, "capture invoked");
503        }
504
505        let req = StoreMemoryRequest {
506            content: params.content,
507            memory_type: params.memory_type,
508            domain: params.domain,
509            source_agent,
510            title: None,
511            confidence: params.confidence,
512            supersedes: params.supersedes,
513            entity: params.entity,
514            entity_id: None,
515            structured_fields: params.structured_fields.map(serde_json::Value::Object),
516            retrieval_cue: params.retrieval_cue,
517        };
518
519        let resp: StoreMemoryResponse = match self.client.post("/api/memory/store", &req).await {
520            Ok(r) => r,
521            Err(e) => return Ok(tool_error(e, "memory store")),
522        };
523
524        Ok(CallToolResult::success(vec![Content::text(
525            format_capture_success(&resp),
526        )]))
527    }
528
529    pub async fn recall_impl(&self, params: RecallParams) -> Result<CallToolResult, McpError> {
530        let req = SearchMemoryRequest {
531            query: params.query,
532            limit: params.limit.unwrap_or(10),
533            memory_type: params.memory_type,
534            domain: params.domain,
535            source_agent: self.resolve_source_agent(None),
536        };
537
538        let resp: SearchMemoryResponse = match self.client.post("/api/memory/search", &req).await {
539            Ok(r) => r,
540            Err(e) => return Ok(tool_error(e, "search")),
541        };
542
543        let json = serde_json::to_string_pretty(&resp.results)
544            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
545
546        Ok(CallToolResult::success(vec![Content::text(format!(
547            "{} results ({:.1}ms)\n{}",
548            resp.results.len(),
549            resp.took_ms,
550            json
551        ))]))
552    }
553
554    pub async fn context_impl(&self, params: ContextParams) -> Result<CallToolResult, McpError> {
555        #[allow(deprecated)]
556        let req = ChatContextRequest {
557            query: None,
558            conversation_id: params.topic,
559            max_chunks: params.limit.unwrap_or(20),
560            relevance_threshold: None,
561            include_goals: true,
562            domain: params.domain,
563        };
564
565        // Extract only the `context` string field from the response.
566        //
567        // The full ChatContextResponse embeds Vec<SearchResult> which may
568        // contain fields added after the published origin-types version.
569        // Since context_impl only uses `resp.context`, we parse the raw
570        // JSON and pull that field directly — this makes the tool forward-
571        // compatible with any new fields the daemon might add.
572        let raw: serde_json::Value = match self.client.post("/api/chat-context", &req).await {
573            Ok(r) => r,
574            Err(e) => return Ok(tool_error(e, "context load")),
575        };
576
577        let context = raw
578            .get("context")
579            .and_then(|v| v.as_str())
580            .unwrap_or_default()
581            .to_string();
582
583        if context.is_empty() {
584            Ok(CallToolResult::success(vec![Content::text(
585                "No relevant context found".to_string(),
586            )]))
587        } else {
588            Ok(CallToolResult::success(vec![Content::text(context)]))
589        }
590    }
591
592    pub async fn doctor_impl(&self) -> Result<CallToolResult, McpError> {
593        let status: serde_json::Value = match self.client.get("/api/setup/status").await {
594            Ok(r) => r,
595            Err(OriginError::Api { status: 404, .. }) => {
596                return Ok(CallToolResult::error(vec![Content::text(
597                    "Origin daemon is running, but it does not expose /api/setup/status. \
598                     Update Origin, then run `origin doctor`."
599                        .to_string(),
600                )]));
601            }
602            Err(e) => return Ok(tool_error(e, "status check")),
603        };
604
605        Ok(CallToolResult::success(vec![Content::text(
606            format_doctor_message(&status),
607        )]))
608    }
609
610    pub async fn forget_impl(&self, memory_id: &str) -> Result<CallToolResult, McpError> {
611        if self.transport == TransportMode::Http {
612            return Ok(CallToolResult::error(vec![Content::text(
613                "Delete operations are not available over remote connections. \
614                 Use local MCP on the machine running Origin to delete memories."
615                    .to_string(),
616            )]));
617        }
618
619        let resp: DeleteResponse = match self
620            .client
621            .delete(&format!("/api/memory/delete/{}", memory_id))
622            .await
623        {
624            Ok(r) => r,
625            Err(e) => return Ok(tool_error(e, "delete")),
626        };
627
628        Ok(CallToolResult::success(vec![Content::text(
629            if resp.deleted {
630                "Memory deleted"
631            } else {
632                "Memory not found"
633            }
634            .to_string(),
635        )]))
636    }
637
638    pub async fn distill_impl(&self, params: DistillParams) -> Result<CallToolResult, McpError> {
639        let body = match params.target.as_deref() {
640            Some(t) if !t.is_empty() => serde_json::json!({ "target": t }),
641            _ => serde_json::json!({}),
642        };
643        match self
644            .client
645            .post::<serde_json::Value, serde_json::Value>("/api/distill", &body)
646            .await
647        {
648            Ok(resp) => {
649                if let Some(unresolved) = resp.get("unresolved").and_then(|v| v.as_str()) {
650                    let hint = resp
651                        .get("hint")
652                        .and_then(|v| v.as_str())
653                        .unwrap_or("no matching target");
654                    return Ok(CallToolResult::success(vec![Content::text(format!(
655                        "Could not resolve target `{}`. {}",
656                        unresolved, hint
657                    ))]));
658                }
659                // Return the daemon's structured response verbatim. The caller
660                // (agent in Claude Code, Cursor, etc.) reads `pending` from the
661                // payload, synthesizes each cluster in-session, and POSTs the
662                // resulting pages back to /api/pages. The MCP tool stays as a
663                // thin wrapper; the synthesis lives where the LLM is.
664                let pretty =
665                    serde_json::to_string_pretty(&resp).unwrap_or_else(|_| resp.to_string());
666                Ok(CallToolResult::success(vec![Content::text(pretty)]))
667            }
668            Err(e) => Ok(tool_error(e, "distill")),
669        }
670    }
671
672    pub async fn list_pending_impl(
673        &self,
674        params: ListPendingParams,
675    ) -> Result<CallToolResult, McpError> {
676        let limit = params.limit.unwrap_or(20).min(100);
677        let path = format!("/api/memory/list?confirmed=false&limit={}", limit);
678        let value: serde_json::Value = match self.client.get(&path).await {
679            Ok(v) => v,
680            Err(e) => return Ok(tool_error(e, "list_pending")),
681        };
682        let body = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
683        Ok(CallToolResult::success(vec![Content::text(body)]))
684    }
685
686    pub async fn confirm_memory_impl(&self, memory_id: &str) -> Result<CallToolResult, McpError> {
687        if self.transport == TransportMode::Http {
688            return Ok(CallToolResult::error(vec![Content::text(
689                "Confirm operations are not available over remote connections. \
690                 Use local MCP on the machine running Origin for review."
691                    .to_string(),
692            )]));
693        }
694        let path = format!("/api/memory/confirm/{}", memory_id);
695        match self
696            .client
697            .post::<serde_json::Value, serde_json::Value>(&path, &serde_json::json!({}))
698            .await
699        {
700            Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!(
701                "Memory {} confirmed.",
702                memory_id
703            ))])),
704            Err(e) => Ok(tool_error(e, "confirm_memory")),
705        }
706    }
707
708    pub async fn create_entity_impl(
709        &self,
710        params: CreateEntityParams,
711    ) -> Result<CallToolResult, McpError> {
712        let source_agent = self.resolve_source_agent(None);
713        let req = CreateEntityRequest {
714            name: params.name,
715            entity_type: params.entity_type,
716            domain: params.domain,
717            source_agent,
718            confidence: params.confidence,
719        };
720        let resp: CreateEntityResponse = match self.client.post("/api/memory/entities", &req).await
721        {
722            Ok(r) => r,
723            Err(e) => return Ok(tool_error(e, "create_entity")),
724        };
725        Ok(CallToolResult::success(vec![Content::text(format!(
726            "Created entity {}",
727            resp.id
728        ))]))
729    }
730
731    pub async fn create_relation_impl(
732        &self,
733        params: CreateRelationParams,
734    ) -> Result<CallToolResult, McpError> {
735        let source_agent = self.resolve_source_agent(None);
736        let req = CreateRelationRequest {
737            from_entity: params.from_entity,
738            to_entity: params.to_entity,
739            relation_type: params.relation_type,
740            source_agent,
741        };
742        let resp: CreateRelationResponse =
743            match self.client.post("/api/memory/relations", &req).await {
744                Ok(r) => r,
745                Err(e) => return Ok(tool_error(e, "create_relation")),
746            };
747        Ok(CallToolResult::success(vec![Content::text(format!(
748            "Created relation {}",
749            resp.id
750        ))]))
751    }
752
753    pub async fn create_page_impl(
754        &self,
755        params: CreatePageParams,
756    ) -> Result<CallToolResult, McpError> {
757        let req = CreateConceptRequest {
758            title: params.title,
759            content: params.content,
760            summary: params.summary,
761            entity_id: params.entity_id,
762            domain: params.domain,
763            source_memory_ids: params.source_memory_ids,
764        };
765        let resp: serde_json::Value = match self.client.post("/api/pages", &req).await {
766            Ok(r) => r,
767            Err(e) => return Ok(tool_error(e, "create_page")),
768        };
769        let id = resp
770            .get("id")
771            .and_then(|v| v.as_str())
772            .unwrap_or("(unknown)");
773        Ok(CallToolResult::success(vec![Content::text(format!(
774            "Created page {}",
775            id
776        ))]))
777    }
778
779    pub async fn update_page_impl(
780        &self,
781        params: UpdatePageParams,
782    ) -> Result<CallToolResult, McpError> {
783        let req = origin_types::requests::RefreshPageRequest {
784            content: params.content,
785            source_memory_ids: params.source_memory_ids,
786            summary: params.summary,
787        };
788        let path = format!("/api/pages/{}", params.page_id);
789        // Typed end-to-end: a wire-shape drift on the daemon side fails at
790        // deserialize instead of silently returning the no-op "Refreshed"
791        // line. Same discipline as PR #77's search_pages / list_pages_recent.
792        let _: origin_types::responses::SuccessResponse = match self.client.put(&path, &req).await {
793            Ok(r) => r,
794            Err(e) => return Ok(tool_error(e, "update_page")),
795        };
796        Ok(CallToolResult::success(vec![Content::text(format!(
797            "Refreshed page {}",
798            params.page_id
799        ))]))
800    }
801
802    pub async fn delete_page_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
803        if self.transport == TransportMode::Http {
804            return Ok(CallToolResult::error(vec![Content::text(
805                "Delete operations are not available over remote connections. \
806                 Use local MCP on the machine running Origin to delete pages."
807                    .to_string(),
808            )]));
809        }
810
811        let path = format!("/api/pages/{}", page_id);
812        let resp: serde_json::Value = match self.client.delete(&path).await {
813            Ok(r) => r,
814            Err(e) => return Ok(tool_error(e, "delete_page")),
815        };
816        let status = resp
817            .get("status")
818            .and_then(|v| v.as_str())
819            .unwrap_or("deleted");
820        Ok(CallToolResult::success(vec![Content::text(format!(
821            "Page {} {}",
822            page_id, status
823        ))]))
824    }
825
826    pub async fn get_page_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
827        let path = format!("/api/pages/{}", page_id);
828        let resp: serde_json::Value = match self.client.get(&path).await {
829            Ok(r) => r,
830            Err(e) => return Ok(tool_error(e, "get_page")),
831        };
832        let pretty = serde_json::to_string_pretty(&resp).unwrap_or_else(|_| resp.to_string());
833        Ok(CallToolResult::success(vec![Content::text(pretty)]))
834    }
835
836    pub async fn get_page_links_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
837        let path = format!("/api/pages/{}/links", page_id);
838        // Typed end-to-end via PageLinksResponse — keeps wire shape pinned.
839        let resp: origin_types::responses::PageLinksResponse = match self.client.get(&path).await {
840            Ok(r) => r,
841            Err(e) => return Ok(tool_error(e, "get_page_links")),
842        };
843        let pretty = serde_json::to_string_pretty(&resp).unwrap_or_else(|_| String::new());
844        Ok(CallToolResult::success(vec![Content::text(pretty)]))
845    }
846
847    pub async fn list_memories_impl(
848        &self,
849        params: ListMemoriesParams,
850    ) -> Result<CallToolResult, McpError> {
851        let req = ListMemoriesRequest {
852            memory_type: params.memory_type,
853            domain: params.domain,
854            limit: params.limit.unwrap_or(100),
855        };
856        let resp: ListMemoriesResponse = match self.client.post("/api/memory/list", &req).await {
857            Ok(r) => r,
858            Err(e) => return Ok(tool_error(e, "list_memories")),
859        };
860        let pretty = serde_json::to_string_pretty(&resp.memories)
861            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
862        Ok(CallToolResult::success(vec![Content::text(format!(
863            "{} memories\n{}",
864            resp.memories.len(),
865            pretty
866        ))]))
867    }
868
869    pub async fn search_pages_impl(
870        &self,
871        params: SearchPagesParams,
872    ) -> Result<CallToolResult, McpError> {
873        let req = SearchPagesRequest {
874            query: params.query,
875            limit: params.limit,
876        };
877        let resp: SearchPagesResponse = match self.client.post("/api/pages/search", &req).await {
878            Ok(r) => r,
879            Err(e) => return Ok(tool_error(e, "search_pages")),
880        };
881        let pretty = serde_json::to_string_pretty(&resp.pages)
882            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
883        Ok(CallToolResult::success(vec![Content::text(format!(
884            "{} pages\n{}",
885            resp.pages.len(),
886            pretty
887        ))]))
888    }
889
890    pub async fn list_pages_recent_impl(
891        &self,
892        params: ListPagesRecentParams,
893    ) -> Result<CallToolResult, McpError> {
894        let path = build_recent_pages_path(params.limit, params.since_ms);
895        let resp: Vec<RecentActivityItem> = match self.client.get(&path).await {
896            Ok(r) => r,
897            Err(e) => return Ok(tool_error(e, "list_pages_recent")),
898        };
899        let pretty = serde_json::to_string_pretty(&resp)
900            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
901        Ok(CallToolResult::success(vec![Content::text(format!(
902            "{} recent pages\n{}",
903            resp.len(),
904            pretty
905        ))]))
906    }
907}
908
909/// Build the `/api/pages/recent` URL with optional `limit` + `since_ms` query
910/// params. Pure function so the test can exercise the actual builder rather
911/// than a duplicate.
912fn build_recent_pages_path(limit: Option<usize>, since_ms: Option<i64>) -> String {
913    let mut path = String::from("/api/pages/recent");
914    let mut q: Vec<String> = Vec::new();
915    if let Some(l) = limit {
916        q.push(format!("limit={}", l));
917    }
918    if let Some(s) = since_ms {
919        q.push(format!("since_ms={}", s));
920    }
921    if !q.is_empty() {
922        path.push('?');
923        path.push_str(&q.join("&"));
924    }
925    path
926}
927
928// ===== Tool Registrations =====
929
930#[tool_router]
931impl OriginMcpServer {
932    pub fn new(
933        client: OriginClient,
934        transport: TransportMode,
935        agent_name: String,
936        user_id: Option<String>,
937    ) -> Self {
938        Self {
939            tool_router: Self::tool_router(),
940            client,
941            transport,
942            agent_name,
943            client_name: std::sync::Arc::new(std::sync::Mutex::new(None)),
944            user_id,
945        }
946    }
947
948    // --- Primary Tools ---
949
950    #[tool(
951        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.",
952        annotations(
953            title = "Capture",
954            read_only_hint = false,
955            destructive_hint = false,
956            idempotent_hint = false,
957            open_world_hint = false
958        )
959    )]
960    async fn capture(
961        &self,
962        Parameters(params): Parameters<CaptureParams>,
963    ) -> Result<CallToolResult, McpError> {
964        self.capture_impl(params).await
965    }
966
967    #[tool(
968        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.",
969        annotations(title = "Recall", read_only_hint = true, open_world_hint = false)
970    )]
971    async fn recall(
972        &self,
973        Parameters(params): Parameters<RecallParams>,
974    ) -> Result<CallToolResult, McpError> {
975        self.recall_impl(params).await
976    }
977
978    #[tool(
979        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.",
980        annotations(title = "Context", read_only_hint = true, open_world_hint = false)
981    )]
982    async fn context(
983        &self,
984        Parameters(params): Parameters<ContextParams>,
985    ) -> Result<CallToolResult, McpError> {
986        self.context_impl(params).await
987    }
988
989    #[tool(
990        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.",
991        annotations(title = "Doctor", read_only_hint = true, open_world_hint = false)
992    )]
993    async fn doctor(&self) -> Result<CallToolResult, McpError> {
994        self.doctor_impl().await
995    }
996
997    #[tool(
998        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.",
999        annotations(
1000            title = "Forget",
1001            read_only_hint = false,
1002            destructive_hint = true,
1003            idempotent_hint = true,
1004            open_world_hint = false
1005        )
1006    )]
1007    async fn forget(
1008        &self,
1009        Parameters(params): Parameters<ForgetParams>,
1010    ) -> Result<CallToolResult, McpError> {
1011        self.forget_impl(&params.memory_id).await
1012    }
1013
1014    #[tool(
1015        description = "Trigger Origin's distillation pass. With no `target`, runs a full pass that clusters new memories into pages and refreshes the wiki view. With a `target`, scopes the pass: a page id (`page_*` or `concept_*`) re-distills that single page, an entity name scopes clustering to that entity, a domain value scopes to that domain. 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.",
1016        annotations(
1017            title = "Distill",
1018            read_only_hint = false,
1019            destructive_hint = false,
1020            idempotent_hint = true,
1021            open_world_hint = false
1022        )
1023    )]
1024    async fn distill(
1025        &self,
1026        Parameters(params): Parameters<DistillParams>,
1027    ) -> Result<CallToolResult, McpError> {
1028        self.distill_impl(params).await
1029    }
1030
1031    #[tool(
1032        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.",
1033        annotations(title = "List pending", read_only_hint = true, open_world_hint = false)
1034    )]
1035    async fn list_pending(
1036        &self,
1037        Parameters(params): Parameters<ListPendingParams>,
1038    ) -> Result<CallToolResult, McpError> {
1039        self.list_pending_impl(params).await
1040    }
1041
1042    #[tool(
1043        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`.",
1044        annotations(
1045            title = "Confirm memory",
1046            read_only_hint = false,
1047            destructive_hint = false,
1048            idempotent_hint = true,
1049            open_world_hint = false
1050        )
1051    )]
1052    async fn confirm_memory(
1053        &self,
1054        Parameters(params): Parameters<ConfirmMemoryParams>,
1055    ) -> Result<CallToolResult, McpError> {
1056        self.confirm_memory_impl(&params.memory_id).await
1057    }
1058
1059    // --- Knowledge graph CRUD ---
1060
1061    #[tool(
1062        description = "Create an entity in the knowledge graph. Use when the user names a person, project, tool, or place that isn't yet linked, or when you need a stable id to anchor memories or pages to. The daemon's post-ingest enrichment usually creates entities automatically when an LLM is configured — call this explicitly only when there is no LLM (Basic Memory mode) or you need the id back synchronously.",
1063        annotations(
1064            title = "Create entity",
1065            read_only_hint = false,
1066            destructive_hint = false,
1067            idempotent_hint = false,
1068            open_world_hint = false
1069        )
1070    )]
1071    async fn create_entity(
1072        &self,
1073        Parameters(params): Parameters<CreateEntityParams>,
1074    ) -> Result<CallToolResult, McpError> {
1075        self.create_entity_impl(params).await
1076    }
1077
1078    #[tool(
1079        description = "Create a directed relation between two entities in the knowledge graph. Use sparingly — most relations come out of the daemon's enrichment when an LLM is configured. Call this explicitly to record a relation the user articulated that the daemon couldn't infer, or in Basic Memory mode where extraction does not run.",
1080        annotations(
1081            title = "Create relation",
1082            read_only_hint = false,
1083            destructive_hint = false,
1084            idempotent_hint = false,
1085            open_world_hint = false
1086        )
1087    )]
1088    async fn create_relation(
1089        &self,
1090        Parameters(params): Parameters<CreateRelationParams>,
1091    ) -> Result<CallToolResult, McpError> {
1092        self.create_relation_impl(params).await
1093    }
1094
1095    #[tool(
1096        description = "Create a distilled wiki page from a memory cluster. The /distill flow uses this to post agent-synthesized pages back to the daemon. Provide a markdown body with [[wikilinks]] and inline (source: mem_XXX) citations. Pass the source memory ids so the page stays traceable. The daemon writes both the DB row and the on-disk .origin/pages/<slug>.md projection atomically.",
1097        annotations(
1098            title = "Create page",
1099            read_only_hint = false,
1100            destructive_hint = false,
1101            idempotent_hint = false,
1102            open_world_hint = false
1103        )
1104    )]
1105    async fn create_page(
1106        &self,
1107        Parameters(params): Parameters<CreatePageParams>,
1108    ) -> Result<CallToolResult, McpError> {
1109        self.create_page_impl(params).await
1110    }
1111
1112    #[tool(
1113        description = "Refresh a stale page in place. Replaces content + source_memory_ids + optional summary, clears the daemon's stale_reason in the same call. Preserves page_id, created_at, and bumps version monotonically — external [[wikilinks]] keep working. Use this on entries in the /distill response's `stale_pages` block instead of delete_page + create_page (which churned ids and lost version history).",
1114        annotations(
1115            title = "Refresh page",
1116            read_only_hint = false,
1117            destructive_hint = false,
1118            idempotent_hint = false,
1119            open_world_hint = false
1120        )
1121    )]
1122    async fn update_page(
1123        &self,
1124        Parameters(params): Parameters<UpdatePageParams>,
1125    ) -> Result<CallToolResult, McpError> {
1126        self.update_page_impl(params).await
1127    }
1128
1129    #[tool(
1130        description = "Delete a page by id. Destructive — removes both the DB row and the on-disk md projection. Use during a /distill refresh to drop a stale page before creating its replacement, or when the user explicitly asks to remove a page. Pages without sources can be re-derived by running /distill again on the same scope.",
1131        annotations(
1132            title = "Delete page",
1133            read_only_hint = false,
1134            destructive_hint = true,
1135            idempotent_hint = true,
1136            open_world_hint = false
1137        )
1138    )]
1139    async fn delete_page(
1140        &self,
1141        Parameters(params): Parameters<DeletePageParams>,
1142    ) -> Result<CallToolResult, McpError> {
1143        self.delete_page_impl(&params.page_id).await
1144    }
1145
1146    #[tool(
1147        description = "Fetch a page by id. Returns the full page row including title, summary, body, source memory ids, and metadata. The /read skill uses this for the preview block — agents reading a page should call this rather than guessing the on-disk path, because the md slug is daemon-controlled.",
1148        annotations(title = "Get page", read_only_hint = true, open_world_hint = false)
1149    )]
1150    async fn get_page(
1151        &self,
1152        Parameters(params): Parameters<GetPageParams>,
1153    ) -> Result<CallToolResult, McpError> {
1154        self.get_page_impl(&params.page_id).await
1155    }
1156
1157    #[tool(
1158        description = "Fetch the wikilink graph centered on one page: `outbound` (labels parsed out of this page's body, with target_page_id set when matched; NULL means broken/orphan) and `inbound` (active pages whose body cites this title). Use this for the /read preview to surface 'N inbound, M broken' without parsing the full body.",
1159        annotations(
1160            title = "Get page links",
1161            read_only_hint = true,
1162            destructive_hint = false,
1163            idempotent_hint = true,
1164            open_world_hint = false
1165        )
1166    )]
1167    async fn get_page_links(
1168        &self,
1169        Parameters(params): Parameters<GetPageLinksParams>,
1170    ) -> Result<CallToolResult, McpError> {
1171        self.get_page_links_impl(&params.page_id).await
1172    }
1173
1174    #[tool(
1175        description = "List memories filtered by type and/or domain. Returns the raw memory rows — useful for bulk review, type audits, or feeding a downstream tool. For semantic search use recall; for orientation use context. This is the listing path: predictable order, no relevance ranking.",
1176        annotations(
1177            title = "List memories",
1178            read_only_hint = true,
1179            open_world_hint = false
1180        )
1181    )]
1182    async fn list_memories(
1183        &self,
1184        Parameters(params): Parameters<ListMemoriesParams>,
1185    ) -> Result<CallToolResult, McpError> {
1186        self.list_memories_impl(params).await
1187    }
1188
1189    #[tool(
1190        description = "Search pages by query. Use to resolve a page title to its id before calling get_page (set `limit: 1` for that), or to browse pages on a topic. Returns matching pages with id, title, and summary. For listing recent activity instead, use list_pages_recent.",
1191        annotations(title = "Search pages", read_only_hint = true, open_world_hint = false)
1192    )]
1193    async fn search_pages(
1194        &self,
1195        Parameters(params): Parameters<SearchPagesParams>,
1196    ) -> Result<CallToolResult, McpError> {
1197        self.search_pages_impl(params).await
1198    }
1199
1200    #[tool(
1201        description = "List recently created or updated pages. Use when the user asks 'what's new', 'recent pages', 'what got synthesized lately'. Returns top-N pages by activity timestamp with optional badge deltas (`since_ms` scopes the badge window). For a topic search instead, use search_pages.",
1202        annotations(title = "Recent pages", read_only_hint = true, open_world_hint = false)
1203    )]
1204    async fn list_pages_recent(
1205        &self,
1206        Parameters(params): Parameters<ListPagesRecentParams>,
1207    ) -> Result<CallToolResult, McpError> {
1208        self.list_pages_recent_impl(params).await
1209    }
1210}
1211
1212// ===== ServerHandler =====
1213
1214#[tool_handler]
1215impl ServerHandler for OriginMcpServer {
1216    async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
1217        // Capture client name from MCP initialize handshake
1218        if let Some(client_info) = context.peer.peer_info() {
1219            let name = &client_info.client_info.name;
1220            if !name.is_empty() {
1221                if let Ok(mut guard) = self.client_name.lock() {
1222                    tracing::info!("MCP client identified: {}", name);
1223                    *guard = Some(name.clone());
1224                }
1225            }
1226        }
1227    }
1228
1229    fn get_info(&self) -> InitializeResult {
1230        InitializeResult::new(
1231            ServerCapabilities::builder()
1232                .enable_tools()
1233                .build(),
1234        )
1235        .with_server_info(
1236            Implementation::new("origin-mcp", env!("CARGO_PKG_VERSION"))
1237        )
1238        .with_instructions(
1239            "Origin is your personal memory layer — a local knowledge base that persists across sessions and tools.\n\
1240             Think of yourself as a curator, not a logger. Store insights, not conversation artifacts.\n\n\
1241             Origin is cumulative: each memory you store can be recalled, linked, and distilled into knowledge over time. \
1242             It's also shared across all the user's tools: what you write, other agents (Claude Desktop, Claude Code, \
1243             ChatGPT, Cursor, etc.) will read later. Write for any future reader, not just this conversation.\n\n\
1244             FIRST THING EVERY SESSION: Call context to load the user's identity, preferences, goals, and\n\
1245             topic-relevant memories. This is how you know who you're talking to. Use the result to model how the \
1246             user thinks — their preferences, corrections, and past decisions tell you how they want to be helped, \
1247             not just what they already know.\n\n\
1248             STORE PROACTIVELY — don't wait for the user to ask.\n\
1249             - The user states a preference (\"I use X because...\", \"I prefer Y over Z\")\n\
1250             - The user makes a decision (\"going with approach A\", \"switching to B\")\n\
1251             - The user corrects you or prior info (\"actually, it's C, not D\") — store the correction so it sticks\n\
1252             - The user shares a durable fact about themselves, their work, or people/projects/tools they care about — \
1253               anchor it to the entity\n\n\
1254             If the user asks explicitly (\"remember this\", \"save this\", \"don't forget\"), that's a floor — you \
1255             should have already stored it.\n\n\
1256             WHEN NOT TO STORE:\n\
1257             - Conversation filler (\"ok\", \"thanks\", \"let's move on\")\n\
1258             - Things the user can trivially re-derive (file paths, recent git history)\n\
1259             - Anything already stored — recall first if unsure\n\
1260             - Tool output or command results (file contents, git history, build logs) — these are derivable\n\
1261             - General world facts or documentation that aren't personal to this user (e.g., \"Rust has a borrow \
1262               checker\", \"PostgreSQL supports JSONB\") — those are not memory material.\n\
1263             - Your own inferences about the user that they didn't express. Store what they said; infer from that \
1264               when responding.\n\n\
1265             CONTENT QUALITY — this is where you make the biggest difference:\n\
1266             - Specific beats vague: \"prefers Rust for CLI tools because of compile-time safety\" > \"likes Rust\"\n\
1267             - Include the WHY: the backend can classify \"dark mode\" as a preference, but only you know\n\
1268               \"switched to dark mode because of migraines from bright screens\"\n\
1269             - Name the entities: mention people, projects, tools by name — this powers the knowledge graph\n\
1270             - Atomic: one idea per memory — \"prefers TDD\" and \"uses pytest\" should be two memories, not one\n\
1271             - Declarative, not narrative: \"User prefers X because Y\" — not \"User said today they prefer X\". \
1272               Memories outlive the conversation that produced them.\n\n\
1273             MEMORY TYPES — omit and trust the backend.\n\n\
1274             By default, do NOT set memory_type. The backend auto-classifies into identity / preference / \
1275             decision / lesson / gotcha / fact with more context than you have. Agents that over-specify \
1276             types tend to pick wrong.\n\n\
1277             Opt-in specification:\n\
1278             - \"profile\"   — you're sure it's about the user (identity / preference)\n\
1279             - \"knowledge\" — you're sure it's about the world (decision / lesson / gotcha / fact)\n\
1280             - Precise type — only if you're confident and the distinction matters.\n\n\
1281             EXCEPTION — decisions carry structured fields (alternatives considered, reversibility, domain) \
1282             that power the Decision Log view. Set memory_type=\"decision\" explicitly ONLY when the user \
1283             articulated alternatives weighed AND the reasoning for the choice. A bare \"I'm switching to Cursor\" \
1284             is just a preference change — omit the type. \"Switching to Cursor over VSCode because of better \
1285             Claude integration, and we can always go back\" — that's a decision.\n\n\
1286             RECALL vs CONTEXT:\n\
1287             - context: broad orientation, session start, topic shifts, \"catch me up\"\n\
1288             - recall: specific lookup (\"what's Alice's role?\", \"database preferences\", \"our auth decision\")\n\n\
1289             The backend handles classification, entity extraction, structured fields, quality scoring,\n\
1290             and dedup — you don't need to replicate that logic. Focus on what only you know:\n\
1291             the conversational context, why something matters, and what the user actually cares about."
1292        )
1293    }
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298    use super::*;
1299    use crate::client::OriginClient;
1300    use crate::types::{
1301        ChatContextRequest, ChatContextResponse, SearchMemoryRequest, SearchResult,
1302        StoreMemoryRequest, StoreMemoryResponse,
1303    };
1304
1305    fn make_server(
1306        transport: TransportMode,
1307        agent_name: &str,
1308        user_id: Option<&str>,
1309    ) -> OriginMcpServer {
1310        let client = OriginClient::new("http://127.0.0.1:19999".into());
1311        OriginMcpServer::new(
1312            client,
1313            transport,
1314            agent_name.into(),
1315            user_id.map(String::from),
1316        )
1317    }
1318
1319    // ===== Transport resolution (existing) =====
1320
1321    #[test]
1322    fn test_http_mode_prefers_param_over_agent_name() {
1323        let server = make_server(TransportMode::Http, "claude.ai", None);
1324        // Explicit param has highest priority
1325        let result = server.resolve_source_agent(Some("user-provided".into()));
1326        assert_eq!(result, Some("user-provided".into()));
1327    }
1328
1329    #[test]
1330    fn test_http_mode_sets_source_agent_when_none() {
1331        let server = make_server(TransportMode::Http, "chatgpt", None);
1332        let result = server.resolve_source_agent(None);
1333        assert_eq!(result, Some("chatgpt".into()));
1334    }
1335
1336    #[test]
1337    fn test_stdio_mode_passes_through_source_agent() {
1338        let server = make_server(TransportMode::Stdio, "ignored", None);
1339        let result = server.resolve_source_agent(Some("user-provided".into()));
1340        assert_eq!(result, Some("user-provided".into()));
1341    }
1342
1343    #[test]
1344    fn test_stdio_mode_falls_back_to_agent_name() {
1345        let server = make_server(TransportMode::Stdio, "fallback", None);
1346        // No param, no client_name → falls back to configured agent_name
1347        let result = server.resolve_source_agent(None);
1348        assert_eq!(result, Some("fallback".into()));
1349    }
1350
1351    #[test]
1352    fn test_http_mode_resolves_configured_user_id_for_local_use() {
1353        let server = make_server(TransportMode::Http, "agent", Some("lucian"));
1354        let result = server.resolve_user_id(None);
1355        assert_eq!(result, Some("lucian".into()));
1356    }
1357
1358    #[test]
1359    fn test_transport_mode_equality() {
1360        assert_eq!(TransportMode::Stdio, TransportMode::Stdio);
1361        assert_eq!(TransportMode::Http, TransportMode::Http);
1362        assert_ne!(TransportMode::Stdio, TransportMode::Http);
1363    }
1364
1365    // ===== Param deserialization: CaptureParams =====
1366
1367    #[test]
1368    fn test_capture_params_minimal() {
1369        let json = r#"{"content": "Lucian prefers dark mode"}"#;
1370        let params: CaptureParams = serde_json::from_str(json).unwrap();
1371        assert_eq!(params.content, "Lucian prefers dark mode");
1372        assert!(params.memory_type.is_none());
1373        assert!(params.domain.is_none());
1374        assert!(params.entity.is_none());
1375        assert!(params.confidence.is_none());
1376        assert!(params.supersedes.is_none());
1377    }
1378
1379    #[test]
1380    fn test_capture_params_full() {
1381        let json = r#"{
1382            "content": "We chose PostgreSQL over MongoDB",
1383            "memory_type": "decision",
1384            "domain": "origin",
1385            "entity": "PostgreSQL",
1386            "confidence": 0.95,
1387            "supersedes": "mem_abc123"
1388        }"#;
1389        let params: CaptureParams = serde_json::from_str(json).unwrap();
1390        assert_eq!(params.content, "We chose PostgreSQL over MongoDB");
1391        assert_eq!(params.memory_type.as_deref(), Some("decision"));
1392        assert_eq!(params.domain.as_deref(), Some("origin"));
1393        assert_eq!(params.entity.as_deref(), Some("PostgreSQL"));
1394        assert_eq!(params.confidence, Some(0.95));
1395        assert_eq!(params.supersedes.as_deref(), Some("mem_abc123"));
1396    }
1397
1398    #[test]
1399    fn test_capture_params_missing_content_fails() {
1400        let json = r#"{"memory_type": "fact"}"#;
1401        let result = serde_json::from_str::<CaptureParams>(json);
1402        assert!(result.is_err());
1403    }
1404
1405    // ===== Param deserialization: RecallParams =====
1406
1407    #[test]
1408    fn test_recall_params_minimal() {
1409        let json = r#"{"query": "what does Alice work on?"}"#;
1410        let params: RecallParams = serde_json::from_str(json).unwrap();
1411        assert_eq!(params.query, "what does Alice work on?");
1412        assert!(params.limit.is_none());
1413    }
1414
1415    #[test]
1416    fn test_recall_params_full() {
1417        let json = r#"{
1418            "query": "database preferences",
1419            "limit": 5,
1420            "memory_type": "decision",
1421            "domain": "origin"
1422        }"#;
1423        let params: RecallParams = serde_json::from_str(json).unwrap();
1424        assert_eq!(params.query, "database preferences");
1425        assert_eq!(params.limit, Some(5));
1426        assert_eq!(params.memory_type.as_deref(), Some("decision"));
1427        assert_eq!(params.domain.as_deref(), Some("origin"));
1428    }
1429
1430    #[test]
1431    fn test_recall_params_limit_as_string() {
1432        let json = r#"{"query": "test", "limit": "10"}"#;
1433        let params: RecallParams = serde_json::from_str(json).unwrap();
1434        assert_eq!(params.limit, Some(10));
1435    }
1436
1437    #[test]
1438    fn test_recall_params_missing_query_fails() {
1439        let json = r#"{"limit": 5}"#;
1440        let result = serde_json::from_str::<RecallParams>(json);
1441        assert!(result.is_err());
1442    }
1443
1444    // ===== Param deserialization: ContextParams =====
1445
1446    #[test]
1447    fn test_context_params_empty() {
1448        let json = r#"{}"#;
1449        let params: ContextParams = serde_json::from_str(json).unwrap();
1450        assert!(params.topic.is_none());
1451        assert!(params.limit.is_none());
1452        assert!(params.domain.is_none());
1453    }
1454
1455    #[test]
1456    fn test_context_params_full() {
1457        let json = r#"{"topic": "project Origin architecture", "limit": 30, "domain": "work"}"#;
1458        let params: ContextParams = serde_json::from_str(json).unwrap();
1459        assert_eq!(params.topic.as_deref(), Some("project Origin architecture"));
1460        assert_eq!(params.limit, Some(30));
1461        assert_eq!(params.domain.as_deref(), Some("work"));
1462    }
1463
1464    #[test]
1465    fn test_context_params_limit_as_string() {
1466        let json = r#"{"limit": "20"}"#;
1467        let params: ContextParams = serde_json::from_str(json).unwrap();
1468        assert_eq!(params.limit, Some(20));
1469    }
1470
1471    #[test]
1472    fn store_memory_request_serialization_excludes_user_id() {
1473        let req = StoreMemoryRequest {
1474            content: "test content".into(),
1475            memory_type: None,
1476            domain: None,
1477            source_agent: Some("test-agent".into()),
1478            title: None,
1479            confidence: None,
1480            supersedes: None,
1481            entity: None,
1482            entity_id: None,
1483            structured_fields: None,
1484            retrieval_cue: None,
1485        };
1486        let json = serde_json::to_value(&req).unwrap();
1487        let obj = json.as_object().unwrap();
1488        assert!(
1489            !obj.contains_key("user_id"),
1490            "user_id must not be on the wire; got: {:?}",
1491            obj.keys().collect::<Vec<_>>()
1492        );
1493    }
1494
1495    #[test]
1496    fn capture_success_message_is_terse() {
1497        let resp = StoreMemoryResponse {
1498            source_id: "mem_abc".into(),
1499            chunks_created: 3,
1500            memory_type: "fact".into(),
1501            entity_id: Some("ent_xyz".into()),
1502            quality: Some("high".into()),
1503            warnings: vec![],
1504            extraction_method: "llm".into(),
1505            enrichment: String::new(),
1506            hint: String::new(),
1507        };
1508        let msg = format_capture_success(&resp);
1509        assert_eq!(msg, "Stored mem_abc");
1510        assert!(!msg.contains("chunks"));
1511        assert!(!msg.contains("quality"));
1512        assert!(!msg.contains("entity"));
1513    }
1514
1515    #[test]
1516    fn capture_success_message_surfaces_warnings() {
1517        let resp = StoreMemoryResponse {
1518            source_id: "mem_abc".into(),
1519            chunks_created: 1,
1520            memory_type: "decision".into(),
1521            entity_id: None,
1522            quality: None,
1523            warnings: vec!["decision memory missing required 'claim' field".into()],
1524            extraction_method: "agent".into(),
1525            enrichment: String::new(),
1526            hint: String::new(),
1527        };
1528        let msg = format_capture_success(&resp);
1529        assert!(msg.starts_with("Stored mem_abc"));
1530        assert!(msg.contains("Warnings:"));
1531        assert!(msg.contains("decision memory missing required 'claim' field"));
1532    }
1533
1534    #[test]
1535    fn doctor_basic_memory_message_sets_expectations() {
1536        let msg = format_doctor_message(&serde_json::json!({
1537            "setup_completed": true,
1538            "mode": "basic-memory",
1539            "anthropic_key_configured": false,
1540            "local_model_selected": null,
1541            "local_model_loaded": null,
1542            "local_model_cached": false
1543        }));
1544
1545        assert!(msg.contains("Mode: Basic Memory"));
1546        assert!(msg.contains("On-device model: not selected"));
1547        assert!(msg.contains("Background refinement: paused"));
1548        assert!(msg.contains("Basic Memory works now: capture, recall, and context are available"));
1549        assert!(msg.contains("origin model install"));
1550        assert!(msg.contains("origin key set anthropic"));
1551    }
1552
1553    #[test]
1554    fn doctor_on_device_model_message_shows_loaded_model() {
1555        let msg = format_doctor_message(&serde_json::json!({
1556            "setup_completed": true,
1557            "mode": "local-model",
1558            "anthropic_key_configured": false,
1559            "local_model_selected": "qwen3-1.7b",
1560            "local_model_loaded": "qwen3-1.7b",
1561            "local_model_cached": true
1562        }));
1563
1564        assert!(msg.contains("Mode: On-device Model"), "{msg}");
1565        assert!(
1566            msg.contains("On-device model: qwen3-1.7b (downloaded, loaded)"),
1567            "{msg}"
1568        );
1569        assert!(msg.contains("Background refinement: enabled"), "{msg}");
1570        assert!(!msg.contains("Basic Memory works now"));
1571    }
1572
1573    #[test]
1574    fn doctor_unconfigured_message_names_three_setup_paths() {
1575        let msg = format_doctor_message(&serde_json::json!({
1576            "setup_completed": false,
1577            "mode": "unknown",
1578            "anthropic_key_configured": false,
1579            "local_model_selected": null,
1580            "local_model_loaded": null,
1581            "local_model_cached": false
1582        }));
1583
1584        assert!(msg.contains("Setup: not completed"));
1585        assert!(msg.contains("Run `origin setup`"));
1586        assert!(msg.contains("Basic Memory, On-device Model, or Anthropic Key"));
1587    }
1588
1589    #[test]
1590    fn search_memory_request_serialization_excludes_entity() {
1591        let req = SearchMemoryRequest {
1592            query: "test".into(),
1593            limit: 10,
1594            memory_type: None,
1595            domain: None,
1596            source_agent: None,
1597        };
1598        let json = serde_json::to_value(&req).unwrap();
1599        let obj = json.as_object().unwrap();
1600        assert!(
1601            !obj.contains_key("entity"),
1602            "entity must not be on the wire; got keys: {:?}",
1603            obj.keys().collect::<Vec<_>>()
1604        );
1605    }
1606
1607    #[test]
1608    fn chat_context_request_serialization_includes_domain() {
1609        #[allow(deprecated)]
1610        let req = ChatContextRequest {
1611            query: None,
1612            conversation_id: Some("topic".into()),
1613            max_chunks: 20,
1614            relevance_threshold: None,
1615            include_goals: true,
1616            domain: Some("work".into()),
1617        };
1618        let json = serde_json::to_value(&req).unwrap();
1619        assert_eq!(json["domain"], serde_json::json!("work"));
1620        assert_eq!(json["conversation_id"], serde_json::json!("topic"));
1621    }
1622
1623    #[test]
1624    fn chat_context_response_deserializes_with_profile_and_knowledge() {
1625        let json = r#"{
1626            "context": "user is Lucian, prefers Rust",
1627            "profile": {
1628                "narrative": "n",
1629                "identity": ["rust"],
1630                "preferences": [],
1631                "goals": []
1632            },
1633            "knowledge": {
1634                "pages": [],
1635                "decisions": [],
1636                "relevant_memories": [],
1637                "graph_context": []
1638            },
1639            "took_ms": 42.0,
1640            "token_estimates": {
1641                "tier1_identity": 10,
1642                "tier2_project": 20,
1643                "tier3_relevant": 30,
1644                "total": 60
1645            }
1646        }"#;
1647        let parsed: ChatContextResponse = serde_json::from_str(json).unwrap();
1648        assert_eq!(parsed.context, "user is Lucian, prefers Rust");
1649        assert_eq!(parsed.profile.identity, vec!["rust"]);
1650        assert_eq!(parsed.token_estimates.total, 60);
1651    }
1652
1653    #[test]
1654    fn capture_params_structured_fields_schema_is_object() {
1655        use schemars::schema_for;
1656
1657        let schema = schema_for!(CaptureParams);
1658        let json = serde_json::to_value(&schema).unwrap();
1659        let sf_schema = json
1660            .pointer("/properties/structured_fields")
1661            .expect("structured_fields property in schema");
1662        let type_val = sf_schema
1663            .pointer("/type")
1664            .unwrap_or(&serde_json::Value::Null);
1665        let type_str = match type_val {
1666            serde_json::Value::String(s) => s.clone(),
1667            serde_json::Value::Array(arr) => arr
1668                .iter()
1669                .filter_map(|v| v.as_str())
1670                .collect::<Vec<_>>()
1671                .join(","),
1672            other => panic!(
1673                "structured_fields schema lacks type constraint; got: {:?}",
1674                other
1675            ),
1676        };
1677        assert!(
1678            type_str.contains("object"),
1679            "expected object type, got: {}",
1680            type_str
1681        );
1682    }
1683
1684    // ===== Param deserialization: ForgetParams =====
1685
1686    #[test]
1687    fn test_forget_params() {
1688        let json = r#"{"memory_id": "mem_abc123"}"#;
1689        let params: ForgetParams = serde_json::from_str(json).unwrap();
1690        assert_eq!(params.memory_id, "mem_abc123");
1691    }
1692
1693    #[test]
1694    fn test_forget_params_missing_id_fails() {
1695        let json = r#"{}"#;
1696        let result = serde_json::from_str::<ForgetParams>(json);
1697        assert!(result.is_err());
1698    }
1699
1700    // ===== Request serialization: StoreMemoryRequest =====
1701
1702    #[test]
1703    fn test_store_request_includes_new_fields() {
1704        let req = StoreMemoryRequest {
1705            content: "test".into(),
1706            memory_type: Some("decision".into()),
1707            domain: None,
1708            source_agent: Some("claude".into()),
1709            title: None,
1710            confidence: Some(0.9),
1711            supersedes: Some("old_id".into()),
1712            entity: Some("PostgreSQL".into()),
1713            entity_id: None,
1714            structured_fields: None,
1715            retrieval_cue: None,
1716        };
1717        let json = serde_json::to_value(&req).unwrap();
1718        assert_eq!(json["entity"], "PostgreSQL");
1719        assert_eq!(json["supersedes"], "old_id");
1720        assert!(json["confidence"].as_f64().unwrap() > 0.89);
1721        assert_eq!(json["source_agent"], "claude");
1722        assert!(json.get("user_id").is_none());
1723    }
1724
1725    #[test]
1726    fn test_store_request_minimal() {
1727        let req = StoreMemoryRequest {
1728            content: "hello".into(),
1729            memory_type: Some("fact".into()),
1730            domain: None,
1731            source_agent: None,
1732            title: None,
1733            confidence: None,
1734            supersedes: None,
1735            entity: None,
1736            entity_id: None,
1737            structured_fields: None,
1738            retrieval_cue: None,
1739        };
1740        let json = serde_json::to_value(&req).unwrap();
1741        assert_eq!(json["content"], "hello");
1742        assert_eq!(json["memory_type"], "fact");
1743        assert!(json.get("user_id").is_none());
1744    }
1745
1746    // ===== Response deserialization: StoreMemoryResponse =====
1747
1748    #[test]
1749    fn test_store_response_with_new_fields() {
1750        let json = r#"{
1751            "source_id": "mem_xyz",
1752            "chunks_created": 2,
1753            "memory_type": "fact",
1754            "entity_id": "ent_abc",
1755            "quality": "high",
1756            "warnings": ["decision memory missing claim"],
1757            "extraction_method": "agent"
1758        }"#;
1759        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1760        assert_eq!(resp.source_id, "mem_xyz");
1761        assert_eq!(resp.chunks_created, 2);
1762        assert_eq!(resp.memory_type, "fact");
1763        assert_eq!(resp.entity_id.as_deref(), Some("ent_abc"));
1764        assert_eq!(resp.quality.as_deref(), Some("high"));
1765        assert_eq!(resp.warnings, vec!["decision memory missing claim"]);
1766        assert_eq!(resp.extraction_method, "agent");
1767    }
1768
1769    #[test]
1770    fn test_store_response_backward_compat_no_new_fields() {
1771        // Old backend response without warnings/extraction_method
1772        let json = r#"{
1773            "source_id": "mem_old",
1774            "chunks_created": 1,
1775            "memory_type": "fact"
1776        }"#;
1777        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1778        assert_eq!(resp.source_id, "mem_old");
1779        assert_eq!(resp.chunks_created, 1);
1780        assert_eq!(resp.memory_type, "fact");
1781        assert!(resp.entity_id.is_none());
1782        assert!(resp.quality.is_none());
1783        assert!(resp.warnings.is_empty());
1784        assert_eq!(resp.extraction_method, "unknown");
1785    }
1786
1787    #[test]
1788    fn test_store_response_with_warnings_and_extraction_method() {
1789        let json = r#"{
1790            "source_id": "mem_xyz",
1791            "chunks_created": 1,
1792            "memory_type": "decision",
1793            "warnings": ["decision memory missing required 'claim' field"],
1794            "extraction_method": "llm"
1795        }"#;
1796        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1797        assert_eq!(resp.memory_type, "decision");
1798        assert_eq!(
1799            resp.warnings,
1800            vec!["decision memory missing required 'claim' field"]
1801        );
1802        assert_eq!(resp.extraction_method, "llm");
1803    }
1804
1805    // ===== Response deserialization: SearchResult =====
1806
1807    #[test]
1808    fn test_search_result_with_new_fields() {
1809        let json = r#"{
1810            "id": "1",
1811            "content": "We chose Postgres",
1812            "source": "memory",
1813            "source_id": "mem_1",
1814            "title": "DB decision",
1815            "url": null,
1816            "chunk_index": 0,
1817            "last_modified": 1711000000,
1818            "score": 0.95,
1819            "chunk_type": "memory",
1820            "language": "en",
1821            "semantic_unit": "sentence",
1822            "memory_type": "decision",
1823            "domain": "origin",
1824            "source_agent": "claude",
1825            "confidence": 0.9,
1826            "confirmed": true,
1827            "stability": "standard",
1828            "supersedes": "mem_0",
1829            "summary": "DB choice",
1830            "entity_id": "ent_pg",
1831            "entity_name": "PostgreSQL",
1832            "quality": "high",
1833            "is_archived": false,
1834            "is_recap": false,
1835            "source_text": "We chose Postgres",
1836            "raw_score": 0.42
1837        }"#;
1838        let result: SearchResult = serde_json::from_str(json).unwrap();
1839        assert_eq!(result.chunk_type.as_deref(), Some("memory"));
1840        assert_eq!(result.language.as_deref(), Some("en"));
1841        assert_eq!(result.semantic_unit.as_deref(), Some("sentence"));
1842        assert_eq!(result.stability.as_deref(), Some("standard"));
1843        assert_eq!(result.supersedes.as_deref(), Some("mem_0"));
1844        assert_eq!(result.summary.as_deref(), Some("DB choice"));
1845        assert_eq!(result.entity_id.as_deref(), Some("ent_pg"));
1846        assert_eq!(result.entity_name.as_deref(), Some("PostgreSQL"));
1847        assert_eq!(result.quality.as_deref(), Some("high"));
1848        assert!(!result.is_archived);
1849        assert!(!result.is_recap);
1850        assert_eq!(result.source_text.as_deref(), Some("We chose Postgres"));
1851        assert!((result.raw_score - 0.42).abs() < f32::EPSILON);
1852    }
1853
1854    #[test]
1855    fn test_search_result_backward_compat_no_new_fields() {
1856        // Old backend response without entity/quality/archive/recap
1857        let json = r#"{
1858            "id": "1",
1859            "content": "test",
1860            "source": "memory",
1861            "source_id": "mem_1",
1862            "title": "test",
1863            "url": null,
1864            "chunk_index": 0,
1865            "last_modified": 1711000000,
1866            "score": 0.8,
1867            "memory_type": "fact",
1868            "domain": null,
1869            "source_agent": null,
1870            "confidence": null,
1871            "confirmed": null
1872        }"#;
1873        let result: SearchResult = serde_json::from_str(json).unwrap();
1874        assert!(result.entity_id.is_none());
1875        assert!(result.entity_name.is_none());
1876        assert!(result.quality.is_none());
1877        assert!(!result.is_archived);
1878        assert!(!result.is_recap);
1879        assert!(result.structured_fields.is_none());
1880        assert!(result.retrieval_cue.is_none());
1881        assert_eq!(result.raw_score, 0.0);
1882    }
1883
1884    #[test]
1885    fn test_search_result_with_structured_fields_and_retrieval_cue() {
1886        let json = r#"{
1887            "id": "1",
1888            "content": "Lucian prefers dark mode",
1889            "source": "memory",
1890            "source_id": "mem_1",
1891            "title": "Dark mode preference",
1892            "url": null,
1893            "chunk_index": 0,
1894            "last_modified": 1711000000,
1895            "score": 0.92,
1896            "memory_type": "preference",
1897            "domain": null,
1898            "source_agent": null,
1899            "confidence": null,
1900            "confirmed": null,
1901            "structured_fields": "{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}",
1902            "retrieval_cue": "What UI theme does Lucian prefer?"
1903        }"#;
1904        let result: SearchResult = serde_json::from_str(json).unwrap();
1905        assert_eq!(
1906            result.structured_fields.as_deref(),
1907            Some("{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}")
1908        );
1909        assert_eq!(
1910            result.retrieval_cue.as_deref(),
1911            Some("What UI theme does Lucian prefer?")
1912        );
1913        assert!(!result.is_archived);
1914        assert!(!result.is_recap);
1915        assert_eq!(result.raw_score, 0.0);
1916    }
1917
1918    #[test]
1919    fn test_search_result_knowledge_graph_source() {
1920        // Entity-boosted observation results from knowledge graph
1921        let json = r#"{
1922            "id": "obs_1",
1923            "content": "Prefers Rust over Go",
1924            "source": "knowledge_graph",
1925            "source_id": "ent_lucian",
1926            "title": "Lucian",
1927            "url": null,
1928            "chunk_index": 0,
1929            "last_modified": 1711000000,
1930            "score": 1.14,
1931            "memory_type": null,
1932            "domain": null,
1933            "source_agent": null,
1934            "confidence": null,
1935            "confirmed": null,
1936            "entity_id": "ent_lucian",
1937            "entity_name": "Lucian"
1938        }"#;
1939        let result: SearchResult = serde_json::from_str(json).unwrap();
1940        assert_eq!(result.source, "knowledge_graph");
1941        assert_eq!(result.entity_id.as_deref(), Some("ent_lucian"));
1942        assert_eq!(result.entity_name.as_deref(), Some("Lucian"));
1943        assert!(!result.is_archived);
1944        assert!(!result.is_recap);
1945        assert_eq!(result.raw_score, 0.0);
1946    }
1947
1948    // ===== Transport security: forget blocks on HTTP =====
1949
1950    #[tokio::test]
1951    async fn test_forget_blocked_on_http_transport() {
1952        let server = make_server(TransportMode::Http, "agent", None);
1953        let result = server.forget_impl("mem_123").await.unwrap();
1954        // Should return error content, not an Err
1955        let content = &result.content[0];
1956        match content.raw {
1957            rmcp::model::RawContent::Text(ref tc) => {
1958                assert!(tc.text.contains("not available over remote connections"));
1959            }
1960            _ => panic!("expected text content"),
1961        }
1962    }
1963
1964    #[tokio::test]
1965    async fn test_forget_allowed_on_stdio_transport() {
1966        // This will fail with connection error (no server), which proves
1967        // the transport check passed and it tried to make the HTTP call.
1968        // The error comes back as CallToolResult with is_error: true
1969        // (tool-level failure), not McpError (protocol-level).
1970        let server = make_server(TransportMode::Stdio, "agent", None);
1971        let result = server.forget_impl("mem_123").await.unwrap();
1972        assert!(
1973            result.is_error.unwrap_or(false),
1974            "should fail with connection error, not transport block"
1975        );
1976    }
1977
1978    // ===== Context default limit =====
1979
1980    #[test]
1981    fn test_context_request_default_limit() {
1982        let params = ContextParams {
1983            topic: Some("test".into()),
1984            limit: None,
1985            domain: None,
1986        };
1987        #[allow(deprecated)]
1988        let req = ChatContextRequest {
1989            query: None,
1990            conversation_id: params.topic,
1991            max_chunks: params.limit.unwrap_or(20),
1992            relevance_threshold: None,
1993            include_goals: true,
1994            domain: params.domain,
1995        };
1996        assert_eq!(req.max_chunks, 20);
1997    }
1998
1999    #[test]
2000    fn test_context_request_custom_limit() {
2001        let params = ContextParams {
2002            topic: None,
2003            limit: Some(5),
2004            domain: Some("work".into()),
2005        };
2006        #[allow(deprecated)]
2007        let req = ChatContextRequest {
2008            query: None,
2009            conversation_id: params.topic,
2010            max_chunks: params.limit.unwrap_or(20),
2011            relevance_threshold: None,
2012            include_goals: true,
2013            domain: params.domain,
2014        };
2015        assert_eq!(req.max_chunks, 5);
2016        assert_eq!(req.domain.as_deref(), Some("work"));
2017    }
2018
2019    #[test]
2020    fn test_context_maps_topic_to_conversation_id() {
2021        let params = ContextParams {
2022            topic: Some("project Origin".into()),
2023            limit: None,
2024            domain: None,
2025        };
2026        #[allow(deprecated)]
2027        let req = ChatContextRequest {
2028            query: None,
2029            conversation_id: params.topic.clone(),
2030            max_chunks: params.limit.unwrap_or(20),
2031            relevance_threshold: None,
2032            include_goals: true,
2033            domain: params.domain,
2034        };
2035        assert_eq!(req.conversation_id.as_deref(), Some("project Origin"));
2036    }
2037
2038    // ===== Remember request construction =====
2039
2040    #[test]
2041    fn test_capture_constructs_store_request_with_entity() {
2042        let server = make_server(TransportMode::Stdio, "claude", None);
2043        let params = CaptureParams {
2044            content: "Alice manages the frontend team".into(),
2045            memory_type: Some("fact".into()),
2046            domain: Some("work".into()),
2047            entity: Some("Alice".into()),
2048            confidence: Some(0.9),
2049            supersedes: None,
2050            structured_fields: None,
2051            retrieval_cue: None,
2052        };
2053
2054        // Replicate capture_impl's request construction
2055        let source_agent = server.resolve_source_agent(None);
2056
2057        let req = StoreMemoryRequest {
2058            content: params.content,
2059            memory_type: params.memory_type,
2060            domain: params.domain,
2061            source_agent,
2062            title: None,
2063            confidence: params.confidence,
2064            supersedes: params.supersedes,
2065            entity: params.entity,
2066            entity_id: None,
2067            structured_fields: params.structured_fields.map(serde_json::Value::Object),
2068            retrieval_cue: params.retrieval_cue,
2069        };
2070
2071        let json = serde_json::to_value(&req).unwrap();
2072        assert_eq!(json["content"], "Alice manages the frontend team");
2073        assert_eq!(json["memory_type"], "fact");
2074        assert_eq!(json["domain"], "work");
2075        assert_eq!(json["entity"], "Alice");
2076        assert!(json["confidence"].as_f64().unwrap() > 0.89);
2077        // stdio mode: no param, no client_name → falls back to agent_name "claude"
2078        assert_eq!(json["source_agent"], "claude");
2079    }
2080
2081    #[test]
2082    fn test_remember_http_mode_injects_agent() {
2083        let server = make_server(TransportMode::Http, "claude.ai", Some("lucian"));
2084        let source_agent = server.resolve_source_agent(None);
2085
2086        assert_eq!(source_agent, Some("claude.ai".into()));
2087    }
2088
2089    // ===== Recall request construction =====
2090
2091    #[test]
2092    fn test_recall_constructs_search_request() {
2093        let params = RecallParams {
2094            query: "database choices".into(),
2095            limit: Some(5),
2096            memory_type: Some("decision".into()),
2097            domain: None,
2098        };
2099
2100        let req = SearchMemoryRequest {
2101            query: params.query,
2102            limit: params.limit.unwrap_or(10),
2103            memory_type: params.memory_type,
2104            domain: params.domain,
2105            source_agent: None,
2106        };
2107
2108        let json = serde_json::to_value(&req).unwrap();
2109        assert_eq!(json["query"], "database choices");
2110        assert_eq!(json["limit"], 5);
2111        assert_eq!(json["memory_type"], "decision");
2112        assert!(json.get("entity").is_none());
2113        assert!(json["domain"].is_null());
2114        assert!(json["source_agent"].is_null());
2115    }
2116
2117    // ===== Memory type pass-through =====
2118
2119    /// CaptureParams must pass every canonical memory_type through to the
2120    /// daemon verbatim. The MCP layer is dumb wire — it doesn't validate or
2121    /// rewrite the value; the daemon owns that. Drift test sourced from
2122    /// `MemoryType::all_values()` so adding a variant extends coverage
2123    /// automatically.
2124    #[test]
2125    fn test_capture_passes_through_all_canonical_types() {
2126        for t in origin_types::MemoryType::all_values() {
2127            let params = CaptureParams {
2128                content: "test".into(),
2129                memory_type: Some((*t).to_string()),
2130                domain: None,
2131                entity: None,
2132                confidence: None,
2133                supersedes: None,
2134                structured_fields: None,
2135                retrieval_cue: None,
2136            };
2137            assert_eq!(params.memory_type.as_deref(), Some(*t));
2138        }
2139    }
2140
2141    /// Legacy "goal" alias still flows through the wire untouched —
2142    /// `MemoryType::FromStr` folds it to "identity" daemon-side. The MCP
2143    /// layer must not pre-reject it (the daemon owns the fold decision).
2144    #[test]
2145    fn test_capture_passes_through_legacy_goal_alias() {
2146        let params = CaptureParams {
2147            content: "test".into(),
2148            memory_type: Some("goal".into()),
2149            domain: None,
2150            entity: None,
2151            confidence: None,
2152            supersedes: None,
2153            structured_fields: None,
2154            retrieval_cue: None,
2155        };
2156        assert_eq!(params.memory_type.as_deref(), Some("goal"));
2157    }
2158
2159    // ===== Structured fields in remember params =====
2160
2161    #[test]
2162    fn test_capture_params_with_structured_fields_and_cue() {
2163        let json = r#"{
2164            "content": "Lucian prefers dark mode",
2165            "structured_fields": {"theme":"dark"},
2166            "retrieval_cue": "What theme does Lucian prefer?"
2167        }"#;
2168        let params: CaptureParams = serde_json::from_str(json).unwrap();
2169        let structured_fields = params.structured_fields.expect("structured_fields");
2170        assert_eq!(
2171            structured_fields.get("theme"),
2172            Some(&serde_json::Value::String("dark".into()))
2173        );
2174        assert_eq!(
2175            params.retrieval_cue.as_deref(),
2176            Some("What theme does Lucian prefer?")
2177        );
2178    }
2179
2180    #[test]
2181    fn test_store_request_with_structured_fields() {
2182        let req = StoreMemoryRequest {
2183            content: "test".into(),
2184            memory_type: Some("fact".into()),
2185            domain: None,
2186            source_agent: None,
2187            title: None,
2188            confidence: None,
2189            supersedes: None,
2190            entity: None,
2191            entity_id: None,
2192            structured_fields: Some(serde_json::json!({"key":"val"})),
2193            retrieval_cue: Some("What is the key?".into()),
2194        };
2195        let json = serde_json::to_value(&req).unwrap();
2196        assert_eq!(json["structured_fields"], serde_json::json!({"key":"val"}));
2197        assert_eq!(json["retrieval_cue"], "What is the key?");
2198    }
2199
2200    // ===== ChatContextResponse deserialization =====
2201
2202    #[test]
2203    fn test_chat_context_response() {
2204        let json = r#"{
2205            "context": "User prefers dark mode. Works on Origin project.",
2206            "profile": {
2207                "narrative": "narrative",
2208                "identity": [],
2209                "preferences": [],
2210                "goals": []
2211            },
2212            "knowledge": {
2213                "pages": [],
2214                "decisions": [],
2215                "relevant_memories": [],
2216                "graph_context": []
2217            },
2218            "took_ms": 12.5,
2219            "token_estimates": {
2220                "tier1_identity": 1,
2221                "tier2_project": 2,
2222                "tier3_relevant": 3,
2223                "total": 6
2224            }
2225        }"#;
2226        let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
2227        assert!(!resp.context.is_empty());
2228        assert!(resp.profile.identity.is_empty());
2229        assert_eq!(resp.took_ms, 12.5);
2230        assert_eq!(resp.token_estimates.total, 6);
2231    }
2232
2233    #[test]
2234    fn test_chat_context_response_empty() {
2235        let json = r#"{
2236            "context": "",
2237            "profile": {
2238                "narrative": "",
2239                "identity": [],
2240                "preferences": [],
2241                "goals": []
2242            },
2243            "knowledge": {
2244                "pages": [],
2245                "decisions": [],
2246                "relevant_memories": [],
2247                "graph_context": []
2248            },
2249            "took_ms": 1.0,
2250            "token_estimates": {
2251                "tier1_identity": 0,
2252                "tier2_project": 0,
2253                "tier3_relevant": 0,
2254                "total": 0
2255            }
2256        }"#;
2257        let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
2258        assert!(resp.context.is_empty());
2259    }
2260
2261    // ===== with_instructions content assertions =====
2262    // These tests lock in the refined agent-facing guidance. If any
2263    // assertion fails, either the rule was intentionally changed
2264    // (update the test) or the refinement was accidentally dropped
2265    // (restore the rule).
2266
2267    fn server_instructions() -> String {
2268        let s = make_server(TransportMode::Stdio, "test", None);
2269        s.get_info()
2270            .instructions
2271            .expect("server must ship with_instructions")
2272    }
2273
2274    #[test]
2275    fn instructions_mention_cumulative_knowledge() {
2276        assert!(
2277            server_instructions().contains("cumulative"),
2278            "with_instructions must describe Origin as cumulative"
2279        );
2280    }
2281
2282    #[test]
2283    fn instructions_mention_shared_across_tools() {
2284        assert!(
2285            server_instructions().contains("shared across all"),
2286            "with_instructions must tell agents the store is shared across tools"
2287        );
2288    }
2289
2290    #[test]
2291    fn instructions_mention_how_user_thinks() {
2292        assert!(
2293            server_instructions().contains("how the user thinks"),
2294            "with_instructions must frame context as modeling how the user thinks"
2295        );
2296    }
2297
2298    #[test]
2299    fn instructions_use_proactive_framing() {
2300        assert!(
2301            server_instructions().contains("STORE PROACTIVELY"),
2302            "with_instructions must use STORE PROACTIVELY framing (not passive WHEN TO STORE)"
2303        );
2304    }
2305
2306    #[test]
2307    fn instructions_ban_tool_output_storage() {
2308        assert!(
2309            server_instructions().contains("Tool output or command results"),
2310            "with_instructions must explicitly rule out tool output as storage material"
2311        );
2312    }
2313
2314    #[test]
2315    fn instructions_ban_ghost_inferences() {
2316        assert!(
2317            server_instructions().contains("Your own inferences"),
2318            "with_instructions must rule out storing agent's own inferences user didn't express"
2319        );
2320    }
2321
2322    #[test]
2323    fn instructions_call_out_atomic_memory() {
2324        assert!(
2325            server_instructions().contains("Atomic: one idea per memory"),
2326            "with_instructions must call out the atomic-memory rule explicitly by name"
2327        );
2328    }
2329
2330    #[test]
2331    fn instructions_specify_declarative_writing() {
2332        assert!(
2333            server_instructions().contains("Declarative, not narrative"),
2334            "with_instructions must require declarative (not narrative) writing style"
2335        );
2336    }
2337
2338    #[test]
2339    fn instructions_default_to_omit_memory_type() {
2340        let i = server_instructions();
2341        assert!(
2342            i.contains("omit and trust the backend"),
2343            "with_instructions must default agents to omitting memory_type"
2344        );
2345        assert!(
2346            i.contains("do NOT set memory_type"),
2347            "with_instructions must explicitly say do NOT set memory_type by default"
2348        );
2349    }
2350
2351    #[test]
2352    fn instructions_list_every_canonical_memory_type() {
2353        let i = server_instructions();
2354        for ty in origin_types::MemoryType::all_values() {
2355            assert!(
2356                contains_word(&i, ty),
2357                "with_instructions must list canonical memory type \"{ty}\" so MCP clients see the full vocabulary",
2358            );
2359        }
2360    }
2361
2362    #[test]
2363    fn instructions_omit_legacy_goal_type() {
2364        let i = server_instructions();
2365        // "goal" (singular) is a legacy memory_type folded to Identity by
2366        // MemoryType::FromStr. The plural English noun "goals" (life goals,
2367        // profile.goals chat-context field) is a separate concern and must
2368        // NOT trigger this test — tokenizing on word boundaries lets one
2369        // through while still catching the legacy memory-type token.
2370        assert!(
2371            !contains_word(&i, "goal"),
2372            "with_instructions must not advertise legacy \"goal\" memory_type"
2373        );
2374    }
2375
2376    /// Tokenize on non-alphanumeric boundaries and check whether `needle`
2377    /// appears as a standalone token. Mirrors the helper used by the
2378    /// origin-types drift tests so "goals" (plural noun) does not false-match
2379    /// the legacy "goal" memory_type token.
2380    fn contains_word(haystack: &str, needle: &str) -> bool {
2381        haystack
2382            .split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
2383            .any(|tok| tok == needle)
2384    }
2385
2386    #[test]
2387    fn instructions_carve_out_decisions_for_decision_log() {
2388        let i = server_instructions();
2389        assert!(
2390            i.contains("Decision Log"),
2391            "with_instructions must name the Decision Log as the reason for explicit decision typing"
2392        );
2393        assert!(
2394            i.contains("memory_type=\"decision\""),
2395            "with_instructions must tell agents to set memory_type=\"decision\" explicitly for decisions"
2396        );
2397    }
2398
2399    // ===== tool-level and param-level description assertions =====
2400
2401    fn tool_descriptions() -> std::collections::HashMap<String, String> {
2402        let server = make_server(TransportMode::Stdio, "test", None);
2403        server
2404            .tool_router
2405            .list_all()
2406            .into_iter()
2407            .filter_map(|t| {
2408                let desc = t.description.as_ref()?.to_string();
2409                Some((t.name.to_string(), desc))
2410            })
2411            .collect()
2412    }
2413
2414    #[test]
2415    fn capture_description_calls_out_atomic() {
2416        let descriptions = tool_descriptions();
2417        let capture = descriptions.get("capture").expect("capture tool exists");
2418        assert!(
2419            capture.contains("Each call is one atomic idea"),
2420            "capture description must call out atomic-per-call explicitly, got: {capture}"
2421        );
2422    }
2423
2424    #[test]
2425    fn context_description_frames_modeling_user() {
2426        let descriptions = tool_descriptions();
2427        let ctx = descriptions.get("context").expect("context tool exists");
2428        assert!(
2429            ctx.contains("how the user thinks"),
2430            "context description must frame the result as modeling how the user thinks, got: {ctx}"
2431        );
2432    }
2433
2434    #[test]
2435    fn doctor_description_mentions_setup_mode() {
2436        let descriptions = tool_descriptions();
2437        let status = descriptions.get("doctor").expect("doctor tool exists");
2438        assert!(
2439            status.contains("Basic Memory"),
2440            "doctor description must mention setup modes, got: {status}"
2441        );
2442        assert!(
2443            status.contains("On-device Model"),
2444            "doctor description must mention on-device setup, got: {status}"
2445        );
2446        assert!(
2447            status.contains("not part of the memory loop"),
2448            "doctor description must frame itself as diagnostic-only, got: {status}"
2449        );
2450    }
2451
2452    #[test]
2453    fn recall_memory_type_param_lists_two_level_filter() {
2454        let params_schema = serde_json::to_string(&schemars::schema_for!(RecallParams))
2455            .expect("RecallParams schema serializes");
2456        assert!(
2457            params_schema.contains("Two-level filter"),
2458            "RecallParams.memory_type must advertise the two-level filter, got schema: {params_schema}"
2459        );
2460        assert!(
2461            params_schema.contains("profile"),
2462            "RecallParams.memory_type must mention profile alias"
2463        );
2464        assert!(
2465            params_schema.contains("knowledge"),
2466            "RecallParams.memory_type must mention knowledge alias"
2467        );
2468    }
2469
2470    // ===== Knowledge graph / page CRUD =====
2471
2472    // --- CreateEntityParams ---
2473
2474    #[test]
2475    fn test_create_entity_params_minimal() {
2476        let json = r#"{"name": "Alice", "entity_type": "person"}"#;
2477        let params: CreateEntityParams = serde_json::from_str(json).unwrap();
2478        assert_eq!(params.name, "Alice");
2479        assert_eq!(params.entity_type, "person");
2480        assert!(params.domain.is_none());
2481        assert!(params.confidence.is_none());
2482    }
2483
2484    #[test]
2485    fn test_create_entity_params_full() {
2486        let json = r#"{
2487            "name": "PostgreSQL",
2488            "entity_type": "tool",
2489            "domain": "origin",
2490            "confidence": 0.9
2491        }"#;
2492        let params: CreateEntityParams = serde_json::from_str(json).unwrap();
2493        assert_eq!(params.name, "PostgreSQL");
2494        assert_eq!(params.entity_type, "tool");
2495        assert_eq!(params.domain.as_deref(), Some("origin"));
2496        assert_eq!(params.confidence, Some(0.9));
2497    }
2498
2499    #[test]
2500    fn test_create_entity_params_missing_name_fails() {
2501        let json = r#"{"entity_type": "person"}"#;
2502        let result = serde_json::from_str::<CreateEntityParams>(json);
2503        assert!(result.is_err());
2504    }
2505
2506    #[test]
2507    fn test_create_entity_params_missing_type_fails() {
2508        let json = r#"{"name": "Alice"}"#;
2509        let result = serde_json::from_str::<CreateEntityParams>(json);
2510        assert!(result.is_err());
2511    }
2512
2513    #[test]
2514    fn test_create_entity_request_body_shape() {
2515        let server = make_server(TransportMode::Stdio, "claude", None);
2516        let params = CreateEntityParams {
2517            name: "Origin".into(),
2518            entity_type: "project".into(),
2519            domain: Some("origin".into()),
2520            confidence: Some(0.95),
2521        };
2522        let source_agent = server.resolve_source_agent(None);
2523        let req = CreateEntityRequest {
2524            name: params.name,
2525            entity_type: params.entity_type,
2526            domain: params.domain,
2527            source_agent,
2528            confidence: params.confidence,
2529        };
2530        let json = serde_json::to_value(&req).unwrap();
2531        assert_eq!(json["name"], "Origin");
2532        assert_eq!(json["entity_type"], "project");
2533        assert_eq!(json["domain"], "origin");
2534        assert_eq!(json["source_agent"], "claude");
2535        assert!(json["confidence"].as_f64().unwrap() > 0.94);
2536    }
2537
2538    // --- CreateRelationParams ---
2539
2540    #[test]
2541    fn test_create_relation_params() {
2542        let json = r#"{
2543            "from_entity": "Alice",
2544            "to_entity": "Origin",
2545            "relation_type": "works_on"
2546        }"#;
2547        let params: CreateRelationParams = serde_json::from_str(json).unwrap();
2548        assert_eq!(params.from_entity, "Alice");
2549        assert_eq!(params.to_entity, "Origin");
2550        assert_eq!(params.relation_type, "works_on");
2551    }
2552
2553    #[test]
2554    fn test_create_relation_params_missing_field_fails() {
2555        let json = r#"{"from_entity": "Alice", "to_entity": "Origin"}"#;
2556        let result = serde_json::from_str::<CreateRelationParams>(json);
2557        assert!(result.is_err());
2558    }
2559
2560    #[test]
2561    fn test_create_relation_request_body_shape() {
2562        let server = make_server(TransportMode::Stdio, "claude", None);
2563        let params = CreateRelationParams {
2564            from_entity: "Alice".into(),
2565            to_entity: "Origin".into(),
2566            relation_type: "prefers".into(),
2567        };
2568        let source_agent = server.resolve_source_agent(None);
2569        let req = CreateRelationRequest {
2570            from_entity: params.from_entity,
2571            to_entity: params.to_entity,
2572            relation_type: params.relation_type,
2573            source_agent,
2574        };
2575        let json = serde_json::to_value(&req).unwrap();
2576        assert_eq!(json["from_entity"], "Alice");
2577        assert_eq!(json["to_entity"], "Origin");
2578        assert_eq!(json["relation_type"], "prefers");
2579        assert_eq!(json["source_agent"], "claude");
2580    }
2581
2582    // --- CreatePageParams ---
2583
2584    #[test]
2585    fn test_create_page_params_minimal() {
2586        let json = r#"{"title": "Origin daemon", "content": "Body text."}"#;
2587        let params: CreatePageParams = serde_json::from_str(json).unwrap();
2588        assert_eq!(params.title, "Origin daemon");
2589        assert_eq!(params.content, "Body text.");
2590        assert!(params.summary.is_none());
2591        assert!(params.entity_id.is_none());
2592        assert!(params.domain.is_none());
2593        assert!(params.source_memory_ids.is_empty());
2594    }
2595
2596    #[test]
2597    fn test_create_page_params_full() {
2598        let json = r##"{
2599            "title": "Origin daemon",
2600            "content": "Markdown body with [[wikilinks]].",
2601            "summary": "The headless HTTP daemon at the heart of Origin.",
2602            "entity_id": "ent_origin",
2603            "domain": "origin",
2604            "source_memory_ids": ["mem_1", "mem_2"]
2605        }"##;
2606        let params: CreatePageParams = serde_json::from_str(json).unwrap();
2607        assert_eq!(params.title, "Origin daemon");
2608        assert_eq!(
2609            params.summary.as_deref(),
2610            Some("The headless HTTP daemon at the heart of Origin.")
2611        );
2612        assert_eq!(params.entity_id.as_deref(), Some("ent_origin"));
2613        assert_eq!(params.domain.as_deref(), Some("origin"));
2614        assert_eq!(params.source_memory_ids, vec!["mem_1", "mem_2"]);
2615    }
2616
2617    #[test]
2618    fn test_create_page_params_missing_required_fails() {
2619        let json = r#"{"title": "Only title"}"#;
2620        let result = serde_json::from_str::<CreatePageParams>(json);
2621        assert!(result.is_err());
2622    }
2623
2624    #[test]
2625    fn test_create_page_request_body_shape() {
2626        let params = CreatePageParams {
2627            title: "Page".into(),
2628            content: "Body".into(),
2629            summary: Some("S".into()),
2630            entity_id: Some("ent_1".into()),
2631            domain: Some("origin".into()),
2632            source_memory_ids: vec!["mem_1".into()],
2633        };
2634        let req = CreateConceptRequest {
2635            title: params.title,
2636            content: params.content,
2637            summary: params.summary,
2638            entity_id: params.entity_id,
2639            domain: params.domain,
2640            source_memory_ids: params.source_memory_ids,
2641        };
2642        let json = serde_json::to_value(&req).unwrap();
2643        assert_eq!(json["title"], "Page");
2644        assert_eq!(json["content"], "Body");
2645        assert_eq!(json["summary"], "S");
2646        assert_eq!(json["entity_id"], "ent_1");
2647        assert_eq!(json["domain"], "origin");
2648        assert_eq!(json["source_memory_ids"], serde_json::json!(["mem_1"]));
2649    }
2650
2651    // --- DeletePageParams ---
2652
2653    #[test]
2654    fn test_delete_page_params() {
2655        let json = r#"{"page_id": "page_abc"}"#;
2656        let params: DeletePageParams = serde_json::from_str(json).unwrap();
2657        assert_eq!(params.page_id, "page_abc");
2658    }
2659
2660    #[test]
2661    fn test_delete_page_params_missing_fails() {
2662        let json = r#"{}"#;
2663        let result = serde_json::from_str::<DeletePageParams>(json);
2664        assert!(result.is_err());
2665    }
2666
2667    #[tokio::test]
2668    async fn test_delete_page_blocked_on_http_transport() {
2669        let server = make_server(TransportMode::Http, "agent", None);
2670        let result = server.delete_page_impl("page_123").await.unwrap();
2671        let content = &result.content[0];
2672        match content.raw {
2673            rmcp::model::RawContent::Text(ref tc) => {
2674                assert!(tc.text.contains("not available over remote connections"));
2675            }
2676            _ => panic!("expected text content"),
2677        }
2678    }
2679
2680    #[tokio::test]
2681    async fn test_delete_page_allowed_on_stdio_transport() {
2682        // No daemon running → falls through to connection error (not transport block).
2683        let server = make_server(TransportMode::Stdio, "agent", None);
2684        let result = server.delete_page_impl("page_123").await.unwrap();
2685        assert!(
2686            result.is_error.unwrap_or(false),
2687            "should fail with connection error, not transport block"
2688        );
2689    }
2690
2691    // --- GetPageParams ---
2692
2693    #[test]
2694    fn test_get_page_params() {
2695        let json = r#"{"page_id": "page_abc"}"#;
2696        let params: GetPageParams = serde_json::from_str(json).unwrap();
2697        assert_eq!(params.page_id, "page_abc");
2698    }
2699
2700    #[test]
2701    fn test_get_page_params_missing_fails() {
2702        let json = r#"{}"#;
2703        let result = serde_json::from_str::<GetPageParams>(json);
2704        assert!(result.is_err());
2705    }
2706
2707    // --- ListMemoriesParams ---
2708
2709    #[test]
2710    fn test_list_memories_params_empty() {
2711        let json = r#"{}"#;
2712        let params: ListMemoriesParams = serde_json::from_str(json).unwrap();
2713        assert!(params.memory_type.is_none());
2714        assert!(params.domain.is_none());
2715        assert!(params.limit.is_none());
2716    }
2717
2718    #[test]
2719    fn test_list_memories_params_full() {
2720        let json = r#"{"memory_type": "decision", "domain": "origin", "limit": 50}"#;
2721        let params: ListMemoriesParams = serde_json::from_str(json).unwrap();
2722        assert_eq!(params.memory_type.as_deref(), Some("decision"));
2723        assert_eq!(params.domain.as_deref(), Some("origin"));
2724        assert_eq!(params.limit, Some(50));
2725    }
2726
2727    #[test]
2728    fn test_list_memories_params_limit_as_string() {
2729        // MCP clients sometimes serialize numeric params as strings.
2730        let json = r#"{"limit": "25"}"#;
2731        let params: ListMemoriesParams = serde_json::from_str(json).unwrap();
2732        assert_eq!(params.limit, Some(25));
2733    }
2734
2735    #[test]
2736    fn test_list_memories_request_body_shape() {
2737        let params = ListMemoriesParams {
2738            memory_type: Some("fact".into()),
2739            domain: None,
2740            limit: Some(10),
2741        };
2742        let req = ListMemoriesRequest {
2743            memory_type: params.memory_type,
2744            domain: params.domain,
2745            limit: params.limit.unwrap_or(100),
2746        };
2747        let json = serde_json::to_value(&req).unwrap();
2748        assert_eq!(json["memory_type"], "fact");
2749        assert!(json["domain"].is_null());
2750        assert_eq!(json["limit"], 10);
2751    }
2752
2753    #[test]
2754    fn test_list_memories_request_default_limit() {
2755        let params = ListMemoriesParams {
2756            memory_type: None,
2757            domain: None,
2758            limit: None,
2759        };
2760        let req = ListMemoriesRequest {
2761            memory_type: params.memory_type,
2762            domain: params.domain,
2763            limit: params.limit.unwrap_or(100),
2764        };
2765        assert_eq!(req.limit, 100);
2766    }
2767
2768    // --- UpdatePageParams ---
2769
2770    #[test]
2771    fn test_update_page_params_minimal() {
2772        let json =
2773            r#"{"page_id": "page_abc", "content": "fresh body", "source_memory_ids": ["mem_1"]}"#;
2774        let params: UpdatePageParams = serde_json::from_str(json).unwrap();
2775        assert_eq!(params.page_id, "page_abc");
2776        assert_eq!(params.content, "fresh body");
2777        assert_eq!(params.source_memory_ids, vec!["mem_1"]);
2778        assert!(params.summary.is_none());
2779    }
2780
2781    #[test]
2782    fn test_update_page_params_with_summary() {
2783        let json = r#"{
2784            "page_id": "page_abc",
2785            "content": "body",
2786            "source_memory_ids": ["mem_1", "mem_2"],
2787            "summary": "Refreshed claim."
2788        }"#;
2789        let params: UpdatePageParams = serde_json::from_str(json).unwrap();
2790        assert_eq!(params.summary.as_deref(), Some("Refreshed claim."));
2791        assert_eq!(params.source_memory_ids.len(), 2);
2792    }
2793
2794    #[test]
2795    fn test_update_page_params_missing_required_fails() {
2796        // Missing source_memory_ids is a hard fail — refresh without sources
2797        // would orphan the page from its provenance trail.
2798        let json = r#"{"page_id": "page_abc", "content": "body"}"#;
2799        let result = serde_json::from_str::<UpdatePageParams>(json);
2800        assert!(result.is_err());
2801    }
2802
2803    #[test]
2804    fn test_update_page_request_body_shape() {
2805        let params = UpdatePageParams {
2806            page_id: "page_abc".into(),
2807            content: "Body".into(),
2808            source_memory_ids: vec!["mem_1".into()],
2809            summary: Some("S".into()),
2810        };
2811        let req = origin_types::requests::RefreshPageRequest {
2812            content: params.content,
2813            source_memory_ids: params.source_memory_ids,
2814            summary: params.summary,
2815        };
2816        let json = serde_json::to_value(&req).unwrap();
2817        assert_eq!(json["content"], "Body");
2818        assert_eq!(json["source_memory_ids"], serde_json::json!(["mem_1"]));
2819        assert_eq!(json["summary"], "S");
2820        // page_id stays in the URL, never the body.
2821        assert!(json.get("page_id").is_none());
2822    }
2823
2824    // --- Tool registration ---
2825
2826    #[test]
2827    fn new_crud_tools_are_registered() {
2828        let descriptions = tool_descriptions();
2829        for name in [
2830            "create_entity",
2831            "create_relation",
2832            "create_page",
2833            "update_page",
2834            "delete_page",
2835            "get_page",
2836            "get_page_links",
2837            "list_memories",
2838            "search_pages",
2839            "list_pages_recent",
2840        ] {
2841            assert!(
2842                descriptions.contains_key(name),
2843                "tool `{name}` must be registered, got: {:?}",
2844                descriptions.keys().collect::<Vec<_>>()
2845            );
2846        }
2847    }
2848
2849    #[test]
2850    fn capture_memory_type_schema_lists_every_canonical_type() {
2851        let params_schema = serde_json::to_string(&schemars::schema_for!(CaptureParams))
2852            .expect("CaptureParams schema serializes");
2853        for ty in origin_types::MemoryType::all_values() {
2854            assert!(
2855                params_schema.contains(ty),
2856                "CaptureParams.memory_type schema must list canonical type \"{ty}\", got: {params_schema}"
2857            );
2858        }
2859    }
2860
2861    #[test]
2862    fn recall_memory_type_schema_lists_every_canonical_type() {
2863        let params_schema = serde_json::to_string(&schemars::schema_for!(RecallParams))
2864            .expect("RecallParams schema serializes");
2865        for ty in origin_types::MemoryType::all_values() {
2866            assert!(
2867                params_schema.contains(ty),
2868                "RecallParams.memory_type schema must list canonical type \"{ty}\", got: {params_schema}"
2869            );
2870        }
2871    }
2872
2873    #[test]
2874    fn create_entity_schema_documents_name_and_type() {
2875        let schema = serde_json::to_string(&schemars::schema_for!(CreateEntityParams))
2876            .expect("CreateEntityParams schema serializes");
2877        assert!(
2878            schema.contains("Canonical entity name"),
2879            "schema must describe `name` field"
2880        );
2881        assert!(
2882            schema.contains("Entity category"),
2883            "schema must describe `entity_type` field"
2884        );
2885    }
2886
2887    #[test]
2888    fn create_page_schema_documents_traceability() {
2889        let schema = serde_json::to_string(&schemars::schema_for!(CreatePageParams))
2890            .expect("CreatePageParams schema serializes");
2891        assert!(
2892            schema.contains("traceability"),
2893            "schema must spell out why source_memory_ids matter"
2894        );
2895    }
2896
2897    #[test]
2898    fn delete_page_tool_is_marked_destructive() {
2899        let server = make_server(TransportMode::Stdio, "test", None);
2900        let tool = server
2901            .tool_router
2902            .list_all()
2903            .into_iter()
2904            .find(|t| t.name == "delete_page")
2905            .expect("delete_page registered");
2906        let ann = tool.annotations.as_ref().expect("annotations present");
2907        assert_eq!(
2908            ann.destructive_hint,
2909            Some(true),
2910            "delete_page must declare destructive_hint=true"
2911        );
2912    }
2913
2914    // --- SearchPagesParams ---
2915
2916    #[test]
2917    fn test_search_pages_params_minimal() {
2918        let json = r#"{"query": "mutex deadlock"}"#;
2919        let params: SearchPagesParams = serde_json::from_str(json).unwrap();
2920        assert_eq!(params.query, "mutex deadlock");
2921        assert!(params.limit.is_none());
2922    }
2923
2924    #[test]
2925    fn test_search_pages_params_full() {
2926        let json = r#"{"query": "distill architecture", "limit": 5}"#;
2927        let params: SearchPagesParams = serde_json::from_str(json).unwrap();
2928        assert_eq!(params.query, "distill architecture");
2929        assert_eq!(params.limit, Some(5));
2930    }
2931
2932    #[test]
2933    fn test_search_pages_params_missing_query_fails() {
2934        let json = r#"{"limit": 10}"#;
2935        let result = serde_json::from_str::<SearchPagesParams>(json);
2936        assert!(result.is_err());
2937    }
2938
2939    #[test]
2940    fn test_search_pages_params_limit_as_string() {
2941        let json = r#"{"query": "x", "limit": "3"}"#;
2942        let params: SearchPagesParams = serde_json::from_str(json).unwrap();
2943        assert_eq!(params.limit, Some(3));
2944    }
2945
2946    #[test]
2947    fn test_search_pages_request_body_shape() {
2948        let params = SearchPagesParams {
2949            query: "mutex".into(),
2950            limit: Some(7),
2951        };
2952        let req = SearchPagesRequest {
2953            query: params.query,
2954            limit: params.limit,
2955        };
2956        let json = serde_json::to_value(&req).unwrap();
2957        assert_eq!(json["query"], "mutex");
2958        assert_eq!(json["limit"], 7);
2959    }
2960
2961    // --- ListPagesRecentParams ---
2962
2963    #[test]
2964    fn test_list_pages_recent_params_empty() {
2965        let json = r#"{}"#;
2966        let params: ListPagesRecentParams = serde_json::from_str(json).unwrap();
2967        assert!(params.limit.is_none());
2968        assert!(params.since_ms.is_none());
2969    }
2970
2971    #[test]
2972    fn test_list_pages_recent_params_full() {
2973        let json = r#"{"limit": 20, "since_ms": 1715000000000}"#;
2974        let params: ListPagesRecentParams = serde_json::from_str(json).unwrap();
2975        assert_eq!(params.limit, Some(20));
2976        assert_eq!(params.since_ms, Some(1715000000000));
2977    }
2978
2979    #[test]
2980    fn test_list_pages_recent_params_string_numbers() {
2981        let json = r#"{"limit": "15", "since_ms": "1715000000000"}"#;
2982        let params: ListPagesRecentParams = serde_json::from_str(json).unwrap();
2983        assert_eq!(params.limit, Some(15));
2984        assert_eq!(params.since_ms, Some(1715000000000));
2985    }
2986
2987    #[test]
2988    fn list_pages_recent_url_construction() {
2989        // Exercises the actual builder used by `list_pages_recent_impl` so the
2990        // test cannot drift from production behavior.
2991        assert_eq!(build_recent_pages_path(None, None), "/api/pages/recent");
2992        assert_eq!(
2993            build_recent_pages_path(Some(5), None),
2994            "/api/pages/recent?limit=5"
2995        );
2996        assert_eq!(
2997            build_recent_pages_path(None, Some(123)),
2998            "/api/pages/recent?since_ms=123"
2999        );
3000        assert_eq!(
3001            build_recent_pages_path(Some(10), Some(456)),
3002            "/api/pages/recent?limit=10&since_ms=456"
3003        );
3004        // Negative since_ms (i64 — sentinel like "-1" must still serialize).
3005        assert_eq!(
3006            build_recent_pages_path(None, Some(-1)),
3007            "/api/pages/recent?since_ms=-1"
3008        );
3009    }
3010
3011    #[test]
3012    fn search_pages_and_list_pages_recent_are_read_only() {
3013        let server = make_server(TransportMode::Stdio, "test", None);
3014        for name in ["search_pages", "list_pages_recent"] {
3015            let tool = server
3016                .tool_router
3017                .list_all()
3018                .into_iter()
3019                .find(|t| t.name == name)
3020                .unwrap_or_else(|| panic!("`{name}` registered"));
3021            let ann = tool.annotations.as_ref().expect("annotations present");
3022            assert_eq!(
3023                ann.read_only_hint,
3024                Some(true),
3025                "`{name}` must declare read_only_hint=true"
3026            );
3027        }
3028    }
3029}