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