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    #[serde(default, alias = "domain")]
95    pub space: Option<String>,
96    #[schemars(
97        description = "Person, project, or tool name to anchor to (e.g. 'Alice', 'Origin', 'PostgreSQL'). Helps build the knowledge graph."
98    )]
99    pub entity: Option<String>,
100    #[schemars(
101        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."
102    )]
103    pub confidence: Option<f32>,
104    #[schemars(
105        description = "source_id of a memory this replaces. Use when correcting or updating an existing memory — get the ID from recall first."
106    )]
107    pub supersedes: Option<String>,
108    #[schemars(
109        description = "Pre-extracted structured fields as a JSON object. Auto-extracted by backend; only supply if you have high-quality structured data already."
110    )]
111    pub structured_fields: Option<serde_json::Map<String, serde_json::Value>>,
112    #[schemars(
113        description = "A question this memory answers, for search matching. Auto-generated by backend; only supply to override."
114    )]
115    pub retrieval_cue: Option<String>,
116}
117
118#[derive(Debug, Deserialize, schemars::JsonSchema)]
119pub struct RecallParams {
120    #[schemars(
121        description = "Natural language search. Be specific: 'Alice database preference' finds more than 'database stuff'."
122    )]
123    pub query: String,
124    #[schemars(
125        description = "Max results, default 10. Use 3-5 for quick lookups, 10-20 for exploration."
126    )]
127    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
128    pub limit: Option<usize>,
129    #[schemars(description = origin_types::MEMORY_TYPE_FILTER_DESCRIPTION)]
130    pub memory_type: Option<String>,
131    #[schemars(description = "Filter by topic scope.")]
132    #[serde(default, alias = "domain")]
133    pub space: Option<String>,
134}
135
136#[derive(Debug, Deserialize, schemars::JsonSchema)]
137pub struct ContextParams {
138    #[schemars(
139        description = "Topic or conversation summary to focus context retrieval. Omit at session start for general orientation; provide when shifting topics."
140    )]
141    pub topic: Option<String>,
142    #[schemars(
143        description = "Max context chunks, default 20. Increase for complex topics, decrease for quick check-ins."
144    )]
145    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
146    pub limit: Option<usize>,
147    #[schemars(
148        description = "Scope context to a space (e.g. 'work', 'personal'). Auto-detected from conversation if omitted."
149    )]
150    #[serde(default, alias = "domain")]
151    pub space: Option<String>,
152}
153
154#[derive(Debug, Deserialize, schemars::JsonSchema)]
155pub struct ForgetParams {
156    #[schemars(
157        description = "The source_id of the memory to delete. Get this from recall results first."
158    )]
159    pub memory_id: String,
160}
161
162#[derive(Debug, Deserialize, schemars::JsonSchema)]
163pub struct DistillParams {
164    #[schemars(
165        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 space value (e.g. `work`, `personal`) to scope to that space. 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."
166    )]
167    #[serde(default, alias = "page_id")]
168    pub target: Option<String>,
169
170    #[schemars(
171        description = "When true, clears the user_edited flag on the target page before recompile. Use for /distill rebuild <page> to explicitly wipe user prose and regenerate from sources. Only valid when target is a single page id; the daemon ignores it otherwise. Requires daemon LLM."
172    )]
173    #[serde(default)]
174    pub force: Option<bool>,
175}
176
177#[derive(Debug, Deserialize, schemars::JsonSchema)]
178pub struct ListPendingParams {
179    #[schemars(
180        description = "Max results, default 20. Increase for full audit, decrease for quick check-in."
181    )]
182    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
183    pub limit: Option<usize>,
184}
185
186#[derive(Debug, Deserialize, schemars::JsonSchema)]
187pub struct ConfirmMemoryParams {
188    #[schemars(
189        description = "The source_id of the memory to confirm. Get this from list_pending or recall results."
190    )]
191    pub memory_id: String,
192}
193
194// --- Review proposal params ---
195
196#[derive(Debug, Deserialize, schemars::JsonSchema)]
197pub struct ListRefinementsParams {
198    #[schemars(
199        description = "Optional action filter. One of: entity_merge, relation_conflict, detect_contradiction, suggest_entity, dedup_merge."
200    )]
201    #[serde(default)]
202    pub action: Option<String>,
203    #[schemars(description = "Max number of proposals to return. Default 50, max 500.")]
204    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
205    pub limit: Option<usize>,
206}
207
208#[derive(Debug, Deserialize, schemars::JsonSchema)]
209pub struct RejectRefinementParams {
210    #[schemars(description = "The review proposal id to dismiss.")]
211    pub id: String,
212}
213
214#[derive(Debug, Deserialize, schemars::JsonSchema)]
215pub struct AcceptRefinementParams {
216    #[schemars(description = "The review proposal id (e.g. \"merge_abc123_def456\").")]
217    pub id: String,
218}
219
220// --- Knowledge graph CRUD params ---
221
222#[derive(Debug, Deserialize, schemars::JsonSchema)]
223pub struct CreateEntityParams {
224    #[schemars(
225        description = "Canonical entity name (e.g. 'Alice', 'Origin', 'PostgreSQL'). Use the exact, full name — aliases resolve to this canonical form."
226    )]
227    pub name: String,
228    #[schemars(
229        description = "Entity category: 'person', 'project', 'tool', 'place', 'organization', etc. Free-form string; choose the noun that best describes what it is."
230    )]
231    pub entity_type: String,
232    #[schemars(description = "Topic scope (e.g. 'work', 'origin'). Optional.")]
233    #[serde(default, alias = "domain")]
234    pub space: Option<String>,
235    #[schemars(
236        description = "0.0-1.0 confidence in the entity assertion. Leave unset for caller-default."
237    )]
238    pub confidence: Option<f32>,
239}
240
241#[derive(Debug, Deserialize, schemars::JsonSchema)]
242pub struct CreateRelationParams {
243    #[schemars(
244        description = "Canonical name of the source entity (e.g. 'Alice'). Must exist or will be created on the daemon side."
245    )]
246    pub from_entity: String,
247    #[schemars(
248        description = "Canonical name of the target entity (e.g. 'Origin'). Must exist or will be created on the daemon side."
249    )]
250    pub to_entity: String,
251    #[schemars(
252        description = "Verb describing the directed relation (e.g. 'works_on', 'prefers', 'uses', 'depends_on'). Snake_case, present-tense."
253    )]
254    pub relation_type: String,
255}
256
257#[derive(Debug, Deserialize, schemars::JsonSchema)]
258pub struct CreateObservationParams {
259    pub entity_id: String,
260    pub content: String,
261    #[serde(default)]
262    pub source_agent: Option<String>,
263    #[serde(default)]
264    pub confidence: Option<f32>,
265}
266
267#[derive(Debug, Deserialize, schemars::JsonSchema)]
268pub struct ConfirmEntityParams {
269    pub entity_id: String,
270    #[serde(default = "default_confirmed")]
271    pub confirmed: bool,
272}
273
274fn default_confirmed() -> bool {
275    true
276}
277
278#[derive(Debug, Deserialize, schemars::JsonSchema)]
279pub struct UpdateObservationParams {
280    pub observation_id: String,
281    pub content: String,
282}
283
284#[derive(Debug, Deserialize, schemars::JsonSchema)]
285pub struct ConfirmObservationParams {
286    pub observation_id: String,
287    #[serde(default = "default_confirmed")]
288    pub confirmed: bool,
289}
290
291#[derive(Debug, Deserialize, schemars::JsonSchema)]
292pub struct DeleteObservationParams {
293    pub observation_id: String,
294}
295
296#[derive(Debug, Deserialize, schemars::JsonSchema)]
297pub struct CreatePageParams {
298    #[schemars(
299        description = "Short noun phrase that names the page (e.g. 'Origin daemon architecture')."
300    )]
301    pub title: String,
302    #[schemars(
303        description = "Markdown body — 3-7 paragraphs of wiki prose with [[wikilinks]]. Cite source ids inline as (source: mem_XXX)."
304    )]
305    pub content: String,
306    #[schemars(description = "Optional one-sentence summary — the durable claim.")]
307    pub summary: Option<String>,
308    #[schemars(
309        description = "Optional entity_id (e.g. 'ent_abc') to anchor the page to a knowledge-graph entity."
310    )]
311    pub entity_id: Option<String>,
312    #[schemars(description = "Topic scope (e.g. 'origin', 'work'). Optional.")]
313    #[serde(default, alias = "domain")]
314    pub space: Option<String>,
315    #[schemars(
316        description = "Memory source_ids the page is distilled from. Required for traceability."
317    )]
318    #[serde(default)]
319    pub source_memory_ids: Vec<String>,
320}
321
322#[derive(Debug, Deserialize, schemars::JsonSchema)]
323pub struct DeletePageParams {
324    #[schemars(
325        description = "Page id (e.g. 'page_abc' or legacy 'concept_abc'). Get it from get_page or distill output."
326    )]
327    pub page_id: String,
328}
329
330#[derive(Debug, Deserialize, schemars::JsonSchema)]
331pub struct UpdatePageParams {
332    #[schemars(
333        description = "Page id (e.g. 'page_abc' or legacy 'concept_abc'). Get it from the `stale_pages` block in distill output."
334    )]
335    pub page_id: String,
336    #[schemars(
337        description = "Refreshed markdown body — same wiki-prose style as create_page. Replaces the existing content."
338    )]
339    pub content: String,
340    #[schemars(
341        description = "Full source_memory_ids list for the refreshed page — typically the stale page's existing list (carry through from distill output)."
342    )]
343    pub source_memory_ids: Vec<String>,
344    #[schemars(
345        description = "Optional one-sentence summary. Omit to keep the existing summary; pass empty string to clear it."
346    )]
347    pub summary: Option<String>,
348}
349
350#[derive(Debug, Deserialize, schemars::JsonSchema)]
351pub struct GetPageParams {
352    #[schemars(
353        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."
354    )]
355    pub page_id: String,
356}
357
358#[derive(Debug, Deserialize, schemars::JsonSchema)]
359pub struct GetPageLinksParams {
360    #[schemars(
361        description = "Page id (e.g. 'page_abc'). Returns inbound + outbound wikilink graph for that page."
362    )]
363    pub page_id: String,
364}
365
366#[derive(Debug, Deserialize, schemars::JsonSchema)]
367pub struct GetPageSourcesParams {
368    #[schemars(
369        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."
370    )]
371    pub page_id: String,
372}
373
374#[derive(Debug, Deserialize, schemars::JsonSchema)]
375pub struct GetMemoryRevisionsParams {
376    #[schemars(
377        description = "Memory source id (e.g. 'mem_abc' or 'merged_<uuid>'). Returns the full supersede chain ordered by depth (0 = current)."
378    )]
379    pub memory_id: String,
380}
381
382#[derive(Debug, Deserialize, schemars::JsonSchema)]
383pub struct GetPageRevisionsParams {
384    #[schemars(
385        description = "Page id (e.g. 'page_abc'). Returns the version changelog ordered newest-first."
386    )]
387    pub page_id: String,
388}
389
390#[derive(Debug, Deserialize, schemars::JsonSchema)]
391pub struct ListMemoriesParams {
392    #[schemars(
393        description = "Filter by memory type (e.g. 'fact', 'preference', 'decision'). Optional."
394    )]
395    pub memory_type: Option<String>,
396    #[schemars(description = "Filter by topic/space. Optional.")]
397    #[serde(default, alias = "domain")]
398    pub space: Option<String>,
399    #[schemars(
400        description = "Max results, default 100. Increase for bulk listings, decrease for quick scans."
401    )]
402    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
403    pub limit: Option<usize>,
404}
405
406#[derive(Debug, Deserialize, schemars::JsonSchema)]
407pub struct SearchPagesParams {
408    #[schemars(
409        description = "Natural-language search over page title + body content (e.g. 'mutex deadlock', 'distillation architecture')."
410    )]
411    pub query: String,
412    #[schemars(
413        description = "Max results, default 20. Use 1 to resolve a title to its id before calling get_page; higher for broader search."
414    )]
415    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
416    pub limit: Option<usize>,
417    #[schemars(
418        description = "Optional page type filter (e.g. 'recap', 'decision'). Narrows results to one type. Omit to search all types."
419    )]
420    #[serde(default)]
421    pub page_type: Option<String>,
422}
423
424#[derive(Debug, Deserialize, schemars::JsonSchema)]
425pub struct ListPagesRecentParams {
426    #[schemars(
427        description = "Max results, default 10. Use higher (up to ~50) for a wider sweep of recent activity."
428    )]
429    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
430    pub limit: Option<usize>,
431    #[schemars(
432        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."
433    )]
434    #[serde(default, deserialize_with = "deserialize_optional_i64_lenient")]
435    pub since_ms: Option<i64>,
436}
437
438// --- Curation read params ---
439
440#[derive(Debug, Deserialize, schemars::JsonSchema)]
441pub struct ListNurtureParams {
442    /// Maximum cards to return. Default 50. Clamped to 1..=500.
443    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
444    pub limit: Option<usize>,
445    /// Restrict to a single space.
446    #[serde(default, alias = "domain")]
447    pub space: Option<String>,
448}
449
450#[derive(Debug, Deserialize, schemars::JsonSchema)]
451pub struct ListEntitySuggestionsParams {}
452
453#[derive(Debug, Deserialize, schemars::JsonSchema)]
454pub struct ListSpacesParams {}
455
456#[derive(Debug, Deserialize, schemars::JsonSchema)]
457pub struct AcceptRevisionRequest {
458    /// The source_id of the memory whose pending revision should be accepted.
459    pub target_source_id: String,
460}
461
462#[derive(Debug, Deserialize, schemars::JsonSchema)]
463pub struct DismissRevisionRequest {
464    /// The source_id of the memory whose pending revision should be dismissed.
465    pub target_source_id: String,
466}
467
468#[derive(Debug, Deserialize, schemars::JsonSchema)]
469pub struct DismissContradictionRequest {
470    /// The source_id of the memory whose contradiction flags should be dismissed.
471    pub source_id: String,
472}
473
474#[derive(Debug, Deserialize, schemars::JsonSchema)]
475pub struct ListPendingImportsParams {}
476
477#[derive(Debug, Deserialize, schemars::JsonSchema)]
478pub struct ListRejectionsParams {
479    /// Maximum records to return. Default 50. Clamped to 1..=500.
480    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
481    pub limit: Option<usize>,
482    /// Filter by rejection reason code (e.g. "duplicate", "low_quality").
483    #[serde(default)]
484    pub reason: Option<String>,
485}
486
487#[derive(Debug, Deserialize, schemars::JsonSchema)]
488pub struct ListPendingRevisionsParams {
489    /// Maximum rows to return. Server defaults to 50, clamps to 500.
490    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
491    pub limit: Option<usize>,
492}
493
494#[derive(Debug, Deserialize, schemars::JsonSchema)]
495pub struct ListOrphanLinksParams {
496    /// Minimum reference count a label must have to appear. Default 1. Daemon clamps via `.max(1)`.
497    #[serde(default, deserialize_with = "deserialize_optional_i64_lenient")]
498    pub min_count: Option<i64>,
499}
500
501// ===== Internal Implementations =====
502
503fn format_capture_success(resp: &StoreMemoryResponse) -> String {
504    let mut msg = format!("Stored {}", resp.source_id);
505    if !resp.warnings.is_empty() {
506        msg.push_str("\nWarnings:");
507        for warning in &resp.warnings {
508            msg.push_str(&format!("\n  - {}", warning));
509        }
510    }
511    if !resp.auto_superseded.is_empty() {
512        msg.push_str("\n\nAuto-superseded (trust-tier + high-similarity, no action needed):");
513        for target_id in &resp.auto_superseded {
514            msg.push_str(&format!("\n  - {target_id}"));
515        }
516    }
517    if !resp.triggered_revisions.is_empty() {
518        msg.push_str("\n\nTriggered revisions (protected memories now flagged):");
519        for target_id in &resp.triggered_revisions {
520            msg.push_str(&format!("\n  - {target_id}"));
521        }
522        msg.push_str(
523            "\n\nAction: accept (accept_revision) | dismiss (dismiss_revision) | leave (decide later)",
524        );
525    }
526    msg
527}
528
529fn daemon_setup_hint() -> &'static str {
530    "Install the local Origin runtime and run `origin setup`.
531
532Setup choices:
533- Local Memory: store, search, and recall now. No model download or API key.
534- On-device Model: private local extraction and distill cycles after model download.
535- Anthropic Key: richer extraction and distill cycles using your API key.
536
537Install:
538  curl -fsSL https://raw.githubusercontent.com/7xuanlu/origin/main/install.sh | bash
539  export PATH=\"$HOME/.origin/bin:$PATH\"
540  origin setup
541  origin install
542  origin status"
543}
544
545/// Convert a backend error into a tool-level error result (isError: true)
546/// with an actionable message. This keeps the MCP transport healthy
547/// (no protocol-level McpError) while telling the caller what happened.
548fn tool_error(e: OriginError, verb: &str) -> CallToolResult {
549    let msg = match &e {
550        OriginError::Unreachable(_) => format!(
551            "Origin daemon is not reachable (retried 3x over ~6s). \
552             The {verb} was NOT completed.\n\n{}",
553            daemon_setup_hint()
554        ),
555        OriginError::Api { status, body } => format!(
556            "Origin daemon returned HTTP {status}: {body}. The {verb} may not have completed."
557        ),
558        OriginError::Deserialize(detail) => format!(
559            "Failed to parse daemon response: {detail}. \
560             This may indicate a version mismatch between origin-mcp and the daemon."
561        ),
562    };
563    CallToolResult::error(vec![Content::text(msg)])
564}
565
566fn format_doctor_message(status: &serde_json::Value) -> String {
567    let mode = status
568        .get("mode")
569        .and_then(|v| v.as_str())
570        .unwrap_or("unknown");
571    let setup_completed = status
572        .get("setup_completed")
573        .and_then(|v| v.as_bool())
574        .unwrap_or(false);
575    let anthropic_key_configured = status
576        .get("anthropic_key_configured")
577        .and_then(|v| v.as_bool())
578        .unwrap_or(false);
579    let local_model_selected = status.get("local_model_selected").and_then(|v| v.as_str());
580    let local_model_loaded = status.get("local_model_loaded").and_then(|v| v.as_str());
581    let local_model_cached = status
582        .get("local_model_cached")
583        .and_then(|v| v.as_bool())
584        .unwrap_or(false);
585
586    let mode_label = match mode {
587        "basic-memory" => "Local Memory",
588        "local-model" => "On-device Model",
589        "anthropic-key" => "Anthropic Key",
590        other => other,
591    };
592    let local_model_line = match local_model_selected {
593        Some(id) => {
594            let cache_status = if local_model_cached {
595                "downloaded"
596            } else {
597                "not downloaded"
598            };
599            let loaded_status = if Some(id) == local_model_loaded {
600                ", loaded"
601            } else {
602                ""
603            };
604            format!("{id} ({cache_status}{loaded_status})")
605        }
606        None => "not selected".to_string(),
607    };
608    let refinement_line = if anthropic_key_configured || local_model_loaded.is_some() {
609        "enabled (richer extraction and page synthesis are active)"
610    } else if setup_completed {
611        "off (local memory stores, searches, and recalls now. Choose an on-device model or Anthropic key for richer extraction.)"
612    } else {
613        "not configured"
614    };
615
616    let mut msg = format!(
617        "Origin daemon: running\n\
618         Setup: {}\n\
619         Mode: {mode_label}\n\
620         Anthropic key: {}\n\
621         On-device model: {local_model_line}\n\
622         Distill cycles: {refinement_line}",
623        if setup_completed {
624            "completed"
625        } else {
626            "not completed"
627        },
628        if anthropic_key_configured {
629            "configured"
630        } else {
631            "not configured"
632        }
633    );
634
635    if !setup_completed {
636        msg.push_str(
637            "\n\nRun `origin setup` to choose Local Memory, On-device Model, or Anthropic Key.",
638        );
639    } else if !anthropic_key_configured && local_model_loaded.is_none() {
640        msg.push_str(
641            "\n\nLocal memory works now: capture, recall, and context are available. \
642             To enable richer extraction and distill cycles, run `origin model install` \
643             or `origin key set anthropic`.",
644        );
645    }
646
647    msg
648}
649
650impl OriginMcpServer {
651    /// Resolve the source_agent for a write operation.
652    /// Priority: explicit param > MCP client name (from initialize) > configured agent_name.
653    fn resolve_source_agent(&self, param_agent: Option<String>) -> Option<String> {
654        // 1. Explicit param from tool call
655        if let Some(ref agent) = param_agent {
656            if !agent.is_empty() {
657                return param_agent;
658            }
659        }
660        // 2. Client name captured from MCP initialize handshake
661        if let Ok(guard) = self.client_name.lock() {
662            if let Some(ref name) = *guard {
663                return Some(name.clone());
664            }
665        }
666        // 3. Configured --agent-name flag
667        Some(self.agent_name.clone())
668    }
669
670    /// Resolve a local user_id for logging or future use.
671    /// This value is intentionally not sent on the wire (D4).
672    fn resolve_user_id(&self, param_user_id: Option<String>) -> Option<String> {
673        if self.transport == TransportMode::Http {
674            self.user_id.clone().or(param_user_id)
675        } else {
676            param_user_id
677        }
678    }
679
680    pub async fn capture_impl(&self, params: CaptureParams) -> Result<CallToolResult, McpError> {
681        // Tool was renamed `remember -> capture` in v0.4. The HTTP request
682        // body shape (StoreMemoryRequest) is unchanged; only the MCP-facing
683        // tool name shifted.
684        let source_agent = self.resolve_source_agent(None);
685        if let Some(uid) = self.resolve_user_id(None) {
686            tracing::debug!(user_id = %uid, "capture invoked");
687        }
688
689        let req = StoreMemoryRequest {
690            content: params.content,
691            memory_type: params.memory_type,
692            space: params.space,
693            source_agent,
694            title: None,
695            confidence: params.confidence,
696            supersedes: params.supersedes,
697            entity: params.entity,
698            entity_id: None,
699            structured_fields: params.structured_fields.map(serde_json::Value::Object),
700            retrieval_cue: params.retrieval_cue,
701        };
702
703        let resp: StoreMemoryResponse = match self.client.post("/api/memory/store", &req).await {
704            Ok(r) => r,
705            Err(e) => return Ok(tool_error(e, "memory store")),
706        };
707
708        Ok(CallToolResult::success(vec![Content::text(
709            format_capture_success(&resp),
710        )]))
711    }
712
713    pub async fn recall_impl(&self, params: RecallParams) -> Result<CallToolResult, McpError> {
714        let req = SearchMemoryRequest {
715            query: params.query,
716            limit: params.limit.unwrap_or(10),
717            memory_type: params.memory_type,
718            space: params.space,
719            source_agent: self.resolve_source_agent(None),
720        };
721
722        let resp: SearchMemoryResponse = match self.client.post("/api/memory/search", &req).await {
723            Ok(r) => r,
724            Err(e) => return Ok(tool_error(e, "search")),
725        };
726
727        let json = serde_json::to_string_pretty(&resp.results)
728            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
729
730        Ok(CallToolResult::success(vec![Content::text(format!(
731            "{} results ({:.1}ms)\n{}",
732            resp.results.len(),
733            resp.took_ms,
734            json
735        ))]))
736    }
737
738    pub async fn context_impl(&self, params: ContextParams) -> Result<CallToolResult, McpError> {
739        #[allow(deprecated)]
740        let req = ChatContextRequest {
741            query: None,
742            conversation_id: params.topic,
743            max_chunks: params.limit.unwrap_or(20),
744            relevance_threshold: None,
745            include_goals: true,
746            space: params.space,
747        };
748
749        // Extract only the `context` string field from the response.
750        //
751        // The full ChatContextResponse embeds Vec<SearchResult> which may
752        // contain fields added after the published origin-types version.
753        // Since context_impl only uses `resp.context`, we parse the raw
754        // JSON and pull that field directly — this makes the tool forward-
755        // compatible with any new fields the daemon might add.
756        let raw: serde_json::Value = match self.client.post("/api/chat-context", &req).await {
757            Ok(r) => r,
758            Err(e) => return Ok(tool_error(e, "context load")),
759        };
760
761        let context = raw
762            .get("context")
763            .and_then(|v| v.as_str())
764            .unwrap_or_default()
765            .to_string();
766
767        if context.is_empty() {
768            Ok(CallToolResult::success(vec![Content::text(
769                "No relevant context found".to_string(),
770            )]))
771        } else {
772            Ok(CallToolResult::success(vec![Content::text(context)]))
773        }
774    }
775
776    pub async fn doctor_impl(&self) -> Result<CallToolResult, McpError> {
777        let status: serde_json::Value = match self.client.get("/api/setup/status").await {
778            Ok(r) => r,
779            Err(OriginError::Api { status: 404, .. }) => {
780                return Ok(CallToolResult::error(vec![Content::text(
781                    "Origin daemon is running, but it does not expose /api/setup/status. \
782                     Update Origin, then run `origin doctor`."
783                        .to_string(),
784                )]));
785            }
786            Err(e) => return Ok(tool_error(e, "status check")),
787        };
788
789        Ok(CallToolResult::success(vec![Content::text(
790            format_doctor_message(&status),
791        )]))
792    }
793
794    pub async fn forget_impl(&self, memory_id: &str) -> Result<CallToolResult, McpError> {
795        if self.transport == TransportMode::Http {
796            return Ok(CallToolResult::error(vec![Content::text(
797                "Delete operations are not available over remote connections. \
798                 Use local MCP on the machine running Origin to delete memories."
799                    .to_string(),
800            )]));
801        }
802
803        let resp: DeleteResponse = match self
804            .client
805            .delete(&format!("/api/memory/delete/{}", memory_id))
806            .await
807        {
808            Ok(r) => r,
809            Err(e) => return Ok(tool_error(e, "delete")),
810        };
811
812        Ok(CallToolResult::success(vec![Content::text(
813            if resp.deleted {
814                "Memory deleted"
815            } else {
816                "Memory not found"
817            }
818            .to_string(),
819        )]))
820    }
821
822    pub async fn distill_impl(&self, params: DistillParams) -> Result<CallToolResult, McpError> {
823        let mut body = serde_json::Map::new();
824        if let Some(t) = params.target.as_deref().filter(|t| !t.is_empty()) {
825            body.insert("target".into(), serde_json::Value::String(t.to_string()));
826        }
827        if params.force.unwrap_or(false) {
828            body.insert("force".into(), serde_json::Value::Bool(true));
829        }
830        let body = serde_json::Value::Object(body);
831        match self
832            .client
833            .post::<serde_json::Value, serde_json::Value>("/api/distill", &body)
834            .await
835        {
836            Ok(resp) => {
837                if let Some(unresolved) = resp.get("unresolved").and_then(|v| v.as_str()) {
838                    let hint = resp
839                        .get("hint")
840                        .and_then(|v| v.as_str())
841                        .unwrap_or("no matching target");
842                    return Ok(CallToolResult::success(vec![Content::text(format!(
843                        "Could not resolve target `{}`. {}",
844                        unresolved, hint
845                    ))]));
846                }
847                // Return the daemon's structured response verbatim. The caller
848                // (agent in Claude Code, Cursor, etc.) reads `pending` from the
849                // payload, synthesizes each cluster in-session, and POSTs the
850                // resulting pages back to /api/pages. The MCP tool stays as a
851                // thin wrapper; the synthesis lives where the LLM is.
852                let pretty =
853                    serde_json::to_string_pretty(&resp).unwrap_or_else(|_| resp.to_string());
854                Ok(CallToolResult::success(vec![Content::text(pretty)]))
855            }
856            Err(e) => Ok(tool_error(e, "distill")),
857        }
858    }
859
860    pub async fn list_pending_impl(
861        &self,
862        params: ListPendingParams,
863    ) -> Result<CallToolResult, McpError> {
864        let limit = params.limit.unwrap_or(20).min(100);
865        let req = ListMemoriesRequest {
866            memory_type: None,
867            space: None,
868            confirmed: Some(false),
869            limit,
870        };
871        let resp: ListMemoriesResponse = match self.client.post("/api/memory/list", &req).await {
872            Ok(r) => r,
873            Err(e) => return Ok(tool_error(e, "list_pending")),
874        };
875        let body = serde_json::to_string_pretty(&resp.memories)
876            .unwrap_or_else(|e| format!("serialization error: {e}"));
877        Ok(CallToolResult::success(vec![Content::text(body)]))
878    }
879
880    pub async fn confirm_memory_impl(&self, memory_id: &str) -> Result<CallToolResult, McpError> {
881        if self.transport == TransportMode::Http {
882            return Ok(CallToolResult::error(vec![Content::text(
883                "Confirm operations are not available over remote connections. \
884                 Use local MCP on the machine running Origin for review."
885                    .to_string(),
886            )]));
887        }
888        let path = format!("/api/memory/confirm/{}", memory_id);
889        match self
890            .client
891            .post::<serde_json::Value, serde_json::Value>(&path, &serde_json::json!({}))
892            .await
893        {
894            Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!(
895                "Memory {} confirmed.",
896                memory_id
897            ))])),
898            Err(e) => Ok(tool_error(e, "confirm_memory")),
899        }
900    }
901
902    pub async fn create_entity_impl(
903        &self,
904        params: CreateEntityParams,
905    ) -> Result<CallToolResult, McpError> {
906        let source_agent = self.resolve_source_agent(None);
907        let req = CreateEntityRequest {
908            name: params.name,
909            entity_type: params.entity_type,
910            space: params.space,
911            source_agent,
912            confidence: params.confidence,
913        };
914        let resp: CreateEntityResponse = match self.client.post("/api/memory/entities", &req).await
915        {
916            Ok(r) => r,
917            Err(e) => return Ok(tool_error(e, "create_entity")),
918        };
919        let mut text = format!("Created entity {}", resp.id);
920        for w in &resp.warnings {
921            text.push_str(&format!("\nwarning: {w}"));
922        }
923        Ok(CallToolResult::success(vec![Content::text(text)]))
924    }
925
926    pub async fn create_relation_impl(
927        &self,
928        params: CreateRelationParams,
929    ) -> Result<CallToolResult, McpError> {
930        let source_agent = self.resolve_source_agent(None);
931        let req = CreateRelationRequest {
932            from_entity: params.from_entity,
933            to_entity: params.to_entity,
934            relation_type: params.relation_type,
935            source_agent,
936            confidence: None,
937            explanation: None,
938            source_memory_id: None,
939        };
940        let resp: CreateRelationResponse =
941            match self.client.post("/api/memory/relations", &req).await {
942                Ok(r) => r,
943                Err(e) => return Ok(tool_error(e, "create_relation")),
944            };
945        let mut text = format!("Created relation {}", resp.id);
946        for w in &resp.warnings {
947            text.push_str(&format!("\nwarning: {w}"));
948        }
949        Ok(CallToolResult::success(vec![Content::text(text)]))
950    }
951
952    pub async fn create_observation_impl(
953        &self,
954        params: CreateObservationParams,
955    ) -> Result<CallToolResult, McpError> {
956        let req = origin_types::requests::AddObservationRequest {
957            entity_id: params.entity_id,
958            content: params.content,
959            source_agent: params.source_agent,
960            confidence: params.confidence,
961        };
962        let resp: origin_types::responses::AddObservationResponse =
963            match self.client.post("/api/memory/observations", &req).await {
964                Ok(r) => r,
965                Err(e) => return Ok(tool_error(e, "create_observation")),
966            };
967        let mut text = format!("Created observation {}", resp.id);
968        for w in &resp.warnings {
969            text.push_str(&format!("\nwarning: {w}"));
970        }
971        Ok(CallToolResult::success(vec![Content::text(text)]))
972    }
973
974    pub async fn confirm_entity_impl(
975        &self,
976        params: ConfirmEntityParams,
977    ) -> Result<CallToolResult, McpError> {
978        if self.transport == TransportMode::Http {
979            return Ok(CallToolResult::error(vec![Content::text(
980                "Confirm operations are not available over remote connections. \
981                 Use local MCP on the machine running Origin to confirm entities."
982                    .to_string(),
983            )]));
984        }
985        let req = origin_types::requests::ConfirmEntityRequest {
986            confirmed: params.confirmed,
987        };
988        let path = format!("/api/memory/entities/{}/confirm", params.entity_id);
989        let _: origin_types::responses::SuccessResponse = match self.client.put(&path, &req).await {
990            Ok(r) => r,
991            Err(e) => return Ok(tool_error(e, "confirm_entity")),
992        };
993        Ok(CallToolResult::success(vec![Content::text(format!(
994            "Entity {} {}",
995            params.entity_id,
996            if params.confirmed {
997                "confirmed"
998            } else {
999                "unconfirmed"
1000            }
1001        ))]))
1002    }
1003
1004    pub async fn update_observation_impl(
1005        &self,
1006        params: UpdateObservationParams,
1007    ) -> Result<CallToolResult, McpError> {
1008        if self.transport == TransportMode::Http {
1009            return Ok(CallToolResult::error(vec![Content::text(
1010                "Update operations are not available over remote connections. \
1011                 Use local MCP on the machine running Origin to update observations."
1012                    .to_string(),
1013            )]));
1014        }
1015        let req = origin_types::requests::UpdateObservationRequest {
1016            content: params.content,
1017        };
1018        let path = format!("/api/memory/observations/{}", params.observation_id);
1019        let _: origin_types::responses::SuccessResponse = match self.client.put(&path, &req).await {
1020            Ok(r) => r,
1021            Err(e) => return Ok(tool_error(e, "update_observation")),
1022        };
1023        Ok(CallToolResult::success(vec![Content::text(format!(
1024            "Updated observation {}",
1025            params.observation_id
1026        ))]))
1027    }
1028
1029    pub async fn confirm_observation_impl(
1030        &self,
1031        params: ConfirmObservationParams,
1032    ) -> Result<CallToolResult, McpError> {
1033        if self.transport == TransportMode::Http {
1034            return Ok(CallToolResult::error(vec![Content::text(
1035                "Confirm operations are not available over remote connections. \
1036                 Use local MCP on the machine running Origin to confirm observations."
1037                    .to_string(),
1038            )]));
1039        }
1040        let req = origin_types::requests::ConfirmObservationRequest {
1041            confirmed: params.confirmed,
1042        };
1043        let path = format!("/api/memory/observations/{}/confirm", params.observation_id);
1044        let _: origin_types::responses::SuccessResponse = match self.client.put(&path, &req).await {
1045            Ok(r) => r,
1046            Err(e) => return Ok(tool_error(e, "confirm_observation")),
1047        };
1048        Ok(CallToolResult::success(vec![Content::text(format!(
1049            "Observation {} {}",
1050            params.observation_id,
1051            if params.confirmed {
1052                "confirmed"
1053            } else {
1054                "unconfirmed"
1055            }
1056        ))]))
1057    }
1058
1059    pub async fn delete_observation_impl(
1060        &self,
1061        params: DeleteObservationParams,
1062    ) -> Result<CallToolResult, McpError> {
1063        if self.transport == TransportMode::Http {
1064            return Ok(CallToolResult::error(vec![Content::text(
1065                "Delete operations are not available over remote connections. \
1066                 Use local MCP on the machine running Origin to delete observations."
1067                    .to_string(),
1068            )]));
1069        }
1070        let path = format!("/api/memory/observations/{}", params.observation_id);
1071        let _: origin_types::responses::SuccessResponse = match self.client.delete(&path).await {
1072            Ok(r) => r,
1073            Err(e) => return Ok(tool_error(e, "delete_observation")),
1074        };
1075        Ok(CallToolResult::success(vec![Content::text(format!(
1076            "Observation {} deleted",
1077            params.observation_id
1078        ))]))
1079    }
1080
1081    pub async fn create_page_impl(
1082        &self,
1083        params: CreatePageParams,
1084    ) -> Result<CallToolResult, McpError> {
1085        let req = CreateConceptRequest {
1086            title: params.title,
1087            content: params.content,
1088            summary: params.summary,
1089            entity_id: params.entity_id,
1090            space: params.space,
1091            source_memory_ids: params.source_memory_ids,
1092        };
1093        let resp: CreatePageResponse = match self.client.post("/api/pages", &req).await {
1094            Ok(r) => r,
1095            Err(e) => return Ok(tool_error(e, "create_page")),
1096        };
1097        let mut text = format!("Created page {}", resp.id);
1098        for w in &resp.warnings {
1099            text.push_str(&format!("\nwarning: {w}"));
1100        }
1101        Ok(CallToolResult::success(vec![Content::text(text)]))
1102    }
1103
1104    pub async fn update_page_impl(
1105        &self,
1106        params: UpdatePageParams,
1107    ) -> Result<CallToolResult, McpError> {
1108        if self.transport == TransportMode::Http {
1109            return Ok(CallToolResult::error(vec![Content::text(
1110                "Update operations are not available over remote connections. \
1111                 Use local MCP on the machine running Origin to update pages."
1112                    .to_string(),
1113            )]));
1114        }
1115        let req = origin_types::requests::RefreshPageRequest {
1116            content: params.content,
1117            source_memory_ids: params.source_memory_ids,
1118            summary: params.summary,
1119        };
1120        let path = format!("/api/pages/{}", params.page_id);
1121        // Typed end-to-end: a wire-shape drift on the daemon side fails at
1122        // deserialize instead of silently returning the no-op "Refreshed"
1123        // line. Same discipline as PR #77's search_pages / list_pages_recent.
1124        let _: origin_types::responses::SuccessResponse = match self.client.put(&path, &req).await {
1125            Ok(r) => r,
1126            Err(e) => return Ok(tool_error(e, "update_page")),
1127        };
1128        Ok(CallToolResult::success(vec![Content::text(format!(
1129            "Refreshed page {}",
1130            params.page_id
1131        ))]))
1132    }
1133
1134    pub async fn delete_page_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
1135        if self.transport == TransportMode::Http {
1136            return Ok(CallToolResult::error(vec![Content::text(
1137                "Delete operations are not available over remote connections. \
1138                 Use local MCP on the machine running Origin to delete pages."
1139                    .to_string(),
1140            )]));
1141        }
1142
1143        let path = format!("/api/pages/{}", page_id);
1144        let resp: serde_json::Value = match self.client.delete(&path).await {
1145            Ok(r) => r,
1146            Err(e) => return Ok(tool_error(e, "delete_page")),
1147        };
1148        let status = resp
1149            .get("status")
1150            .and_then(|v| v.as_str())
1151            .unwrap_or("deleted");
1152        Ok(CallToolResult::success(vec![Content::text(format!(
1153            "Page {} {}",
1154            page_id, status
1155        ))]))
1156    }
1157
1158    pub async fn get_page_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
1159        let path = format!("/api/pages/{}", page_id);
1160        let resp: serde_json::Value = match self.client.get(&path).await {
1161            Ok(r) => r,
1162            Err(e) => return Ok(tool_error(e, "get_page")),
1163        };
1164        let pretty = serde_json::to_string_pretty(&resp).unwrap_or_else(|_| resp.to_string());
1165        Ok(CallToolResult::success(vec![Content::text(pretty)]))
1166    }
1167
1168    pub async fn get_page_links_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
1169        let path = format!("/api/pages/{}/links", page_id);
1170        // Typed end-to-end via PageLinksResponse — keeps wire shape pinned.
1171        let resp: origin_types::responses::PageLinksResponse = match self.client.get(&path).await {
1172            Ok(r) => r,
1173            Err(e) => return Ok(tool_error(e, "get_page_links")),
1174        };
1175        let pretty = serde_json::to_string_pretty(&resp).unwrap_or_else(|_| String::new());
1176        Ok(CallToolResult::success(vec![Content::text(pretty)]))
1177    }
1178
1179    pub async fn get_page_sources_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
1180        let path = format!("/api/pages/{}/sources", page_id);
1181        // Daemon returns Vec<PageSourceWithMemory> directly (no envelope key).
1182        let resp: Vec<PageSourceWithMemory> = match self.client.get(&path).await {
1183            Ok(r) => r,
1184            Err(e) => return Ok(tool_error(e, "get_page_sources")),
1185        };
1186        let pretty = serde_json::to_string_pretty(&resp)
1187            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1188        Ok(CallToolResult::success(vec![Content::text(format!(
1189            "{} sources\n{}",
1190            resp.len(),
1191            pretty
1192        ))]))
1193    }
1194
1195    pub async fn get_memory_revisions_impl(
1196        &self,
1197        memory_id: &str,
1198    ) -> Result<CallToolResult, McpError> {
1199        let path = format!("/api/memory/{}/revisions", memory_id);
1200        let resp: ListMemoryRevisionsResponse = match self.client.get(&path).await {
1201            Ok(r) => r,
1202            Err(e) => return Ok(tool_error(e, "get_memory_revisions")),
1203        };
1204        let pretty = serde_json::to_string_pretty(&resp)
1205            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1206        Ok(CallToolResult::success(vec![Content::text(format!(
1207            "chain depth {}\n{}",
1208            resp.chain_depth, pretty
1209        ))]))
1210    }
1211
1212    pub async fn get_page_revisions_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
1213        let path = format!("/api/pages/{}/revisions", page_id);
1214        let resp: ListPageRevisionsResponse = match self.client.get(&path).await {
1215            Ok(r) => r,
1216            Err(e) => return Ok(tool_error(e, "get_page_revisions")),
1217        };
1218        let pretty = serde_json::to_string_pretty(&resp)
1219            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1220        Ok(CallToolResult::success(vec![Content::text(format!(
1221            "version {} ({} entries)\n{}",
1222            resp.current_version,
1223            resp.entries.len(),
1224            pretty
1225        ))]))
1226    }
1227
1228    pub async fn list_memories_impl(
1229        &self,
1230        params: ListMemoriesParams,
1231    ) -> Result<CallToolResult, McpError> {
1232        let req = ListMemoriesRequest {
1233            memory_type: params.memory_type,
1234            space: params.space,
1235            limit: params.limit.unwrap_or(100),
1236            confirmed: None,
1237        };
1238        let resp: ListMemoriesResponse = match self.client.post("/api/memory/list", &req).await {
1239            Ok(r) => r,
1240            Err(e) => return Ok(tool_error(e, "list_memories")),
1241        };
1242        let pretty = serde_json::to_string_pretty(&resp.memories)
1243            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1244        Ok(CallToolResult::success(vec![Content::text(format!(
1245            "{} memories\n{}",
1246            resp.memories.len(),
1247            pretty
1248        ))]))
1249    }
1250
1251    pub async fn search_pages_impl(
1252        &self,
1253        params: SearchPagesParams,
1254    ) -> Result<CallToolResult, McpError> {
1255        let req = SearchPagesRequest {
1256            query: params.query,
1257            limit: params.limit,
1258            page_type: params.page_type,
1259        };
1260        let resp: SearchPagesResponse = match self.client.post("/api/pages/search", &req).await {
1261            Ok(r) => r,
1262            Err(e) => return Ok(tool_error(e, "search_pages")),
1263        };
1264        let pretty = serde_json::to_string_pretty(&resp.pages)
1265            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1266        Ok(CallToolResult::success(vec![Content::text(format!(
1267            "{} pages\n{}",
1268            resp.pages.len(),
1269            pretty
1270        ))]))
1271    }
1272
1273    pub async fn list_pages_recent_impl(
1274        &self,
1275        params: ListPagesRecentParams,
1276    ) -> Result<CallToolResult, McpError> {
1277        let path = build_recent_pages_path(params.limit, params.since_ms);
1278        let resp: Vec<RecentActivityItem> = match self.client.get(&path).await {
1279            Ok(r) => r,
1280            Err(e) => return Ok(tool_error(e, "list_pages_recent")),
1281        };
1282        let pretty = serde_json::to_string_pretty(&resp)
1283            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1284        Ok(CallToolResult::success(vec![Content::text(format!(
1285            "{} recent pages\n{}",
1286            resp.len(),
1287            pretty
1288        ))]))
1289    }
1290
1291    pub async fn list_spaces_impl(
1292        &self,
1293        _params: ListSpacesParams,
1294    ) -> Result<CallToolResult, McpError> {
1295        let resp: Vec<Space> = match self.client.get("/api/spaces").await {
1296            Ok(r) => r,
1297            Err(e) => return Ok(tool_error(e, "list_spaces")),
1298        };
1299        let pretty = serde_json::to_string_pretty(&resp)
1300            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1301        Ok(CallToolResult::success(vec![Content::text(format!(
1302            "{} spaces\n{}",
1303            resp.len(),
1304            pretty
1305        ))]))
1306    }
1307
1308    pub async fn list_refinements_impl(
1309        &self,
1310        params: ListRefinementsParams,
1311    ) -> Result<CallToolResult, McpError> {
1312        let mut path = String::from("/api/refinery/queue");
1313        let mut q: Vec<String> = Vec::new();
1314        if let Some(a) = params.action.as_deref() {
1315            q.push(format!("action={}", url_encode_simple(a)));
1316        }
1317        if let Some(l) = params.limit {
1318            q.push(format!("limit={l}"));
1319        }
1320        if !q.is_empty() {
1321            path.push('?');
1322            path.push_str(&q.join("&"));
1323        }
1324
1325        let resp: ListRefinementsResponse = match self.client.get(&path).await {
1326            Ok(v) => v,
1327            Err(e) => return Ok(tool_error(e, "list_refinements")),
1328        };
1329
1330        let pretty = serde_json::to_string_pretty(&resp.proposals)
1331            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1332        Ok(CallToolResult::success(vec![Content::text(format!(
1333            "{} pending review proposals\n{}",
1334            resp.proposals.len(),
1335            pretty
1336        ))]))
1337    }
1338
1339    pub async fn reject_refinement_impl(
1340        &self,
1341        params: RejectRefinementParams,
1342    ) -> Result<CallToolResult, McpError> {
1343        if self.transport == TransportMode::Http {
1344            return Ok(CallToolResult::error(vec![Content::text(
1345                "Review proposal operations are not available over remote connections. \
1346                 Use local MCP on the machine running Origin to reject proposals."
1347                    .to_string(),
1348            )]));
1349        }
1350        let path = format!(
1351            "/api/refinery/queue/{}/reject",
1352            url_encode_simple(&params.id)
1353        );
1354        let resp: RejectRefinementResponse =
1355            match self.client.post(&path, &serde_json::json!({})).await {
1356                Ok(v) => v,
1357                Err(e) => return Ok(tool_error(e, "reject_refinement")),
1358            };
1359
1360        Ok(CallToolResult::success(vec![Content::text(format!(
1361            "Review proposal {} dismissed.",
1362            resp.id
1363        ))]))
1364    }
1365
1366    pub async fn accept_refinement_impl(
1367        &self,
1368        params: AcceptRefinementParams,
1369    ) -> Result<CallToolResult, McpError> {
1370        if self.transport == TransportMode::Http {
1371            return Ok(CallToolResult::error(vec![Content::text(
1372                "Review proposal operations are not available over remote connections. \
1373                 Use local MCP on the machine running Origin to accept proposals."
1374                    .to_string(),
1375            )]));
1376        }
1377        let path = format!(
1378            "/api/refinery/queue/{}/accept",
1379            url_encode_simple(&params.id)
1380        );
1381        let resp: AcceptRefinementResponse =
1382            match self.client.post(&path, &serde_json::json!({})).await {
1383                Ok(v) => v,
1384                Err(e) => return Ok(tool_error(e, "accept_refinement")),
1385            };
1386
1387        Ok(CallToolResult::success(vec![Content::text(format!(
1388            "Review proposal {} accepted (action={}).",
1389            resp.id, resp.action_applied
1390        ))]))
1391    }
1392
1393    pub async fn list_nurture_impl(
1394        &self,
1395        params: ListNurtureParams,
1396    ) -> Result<CallToolResult, McpError> {
1397        let mut path = String::from("/api/memory/nurture");
1398        let mut q: Vec<String> = Vec::new();
1399        if let Some(l) = params.limit {
1400            q.push(format!("limit={}", l.clamp(1, 500)));
1401        }
1402        if let Some(s) = params.space.as_deref().filter(|s| !s.is_empty()) {
1403            q.push(format!("space={}", url_encode_simple(s)));
1404        }
1405        if !q.is_empty() {
1406            path.push('?');
1407            path.push_str(&q.join("&"));
1408        }
1409
1410        let resp: origin_types::responses::NurtureCardsResponse = match self.client.get(&path).await
1411        {
1412            Ok(v) => v,
1413            Err(e) => return Ok(tool_error(e, "list_nurture")),
1414        };
1415
1416        let pretty = serde_json::to_string_pretty(&resp.cards)
1417            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1418        Ok(CallToolResult::success(vec![Content::text(format!(
1419            "{} nurture cards\n{}",
1420            resp.cards.len(),
1421            pretty
1422        ))]))
1423    }
1424
1425    pub async fn list_entity_suggestions_impl(
1426        &self,
1427        _params: ListEntitySuggestionsParams,
1428    ) -> Result<CallToolResult, McpError> {
1429        let resp: Vec<origin_types::entities::EntitySuggestion> =
1430            match self.client.get("/api/memory/entity-suggestions").await {
1431                Ok(v) => v,
1432                Err(e) => return Ok(tool_error(e, "list_entity_suggestions")),
1433            };
1434        let pretty = serde_json::to_string_pretty(&resp)
1435            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1436        Ok(CallToolResult::success(vec![Content::text(format!(
1437            "{} entity suggestion(s)\n{}",
1438            resp.len(),
1439            pretty
1440        ))]))
1441    }
1442
1443    pub async fn accept_revision_impl(
1444        &self,
1445        req: AcceptRevisionRequest,
1446    ) -> Result<CallToolResult, McpError> {
1447        if self.transport == TransportMode::Http {
1448            return Ok(CallToolResult::error(vec![Content::text(
1449                "Revision operations are not available over remote connections. \
1450                 Use local MCP on the machine running Origin to accept memory revisions."
1451                    .to_string(),
1452            )]));
1453        }
1454        let path = format!("/api/memory/revision/{}/accept", req.target_source_id);
1455        let response = match self
1456            .client
1457            .post_empty::<RevisionAcceptResponse>(&path)
1458            .await
1459        {
1460            Ok(r) => r,
1461            Err(e) => return Ok(tool_error(e, "accept_revision")),
1462        };
1463        let pretty = serde_json::to_string_pretty(&response)
1464            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1465        Ok(CallToolResult::success(vec![Content::text(pretty)]))
1466    }
1467
1468    pub async fn dismiss_revision_impl(
1469        &self,
1470        req: DismissRevisionRequest,
1471    ) -> Result<CallToolResult, McpError> {
1472        if self.transport == TransportMode::Http {
1473            return Ok(CallToolResult::error(vec![Content::text(
1474                "Revision operations are not available over remote connections. \
1475                 Use local MCP on the machine running Origin to dismiss memory revisions."
1476                    .to_string(),
1477            )]));
1478        }
1479        let path = format!("/api/memory/revision/{}/dismiss", req.target_source_id);
1480        let response = match self
1481            .client
1482            .post_empty::<RevisionDismissResponse>(&path)
1483            .await
1484        {
1485            Ok(r) => r,
1486            Err(e) => return Ok(tool_error(e, "dismiss_revision")),
1487        };
1488        let pretty = serde_json::to_string_pretty(&response)
1489            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1490        Ok(CallToolResult::success(vec![Content::text(pretty)]))
1491    }
1492
1493    pub async fn dismiss_contradiction_impl(
1494        &self,
1495        req: DismissContradictionRequest,
1496    ) -> Result<CallToolResult, McpError> {
1497        if self.transport == TransportMode::Http {
1498            return Ok(CallToolResult::error(vec![Content::text(
1499                "Contradiction operations are not available over remote connections. \
1500                 Use local MCP on the machine running Origin to dismiss contradictions."
1501                    .to_string(),
1502            )]));
1503        }
1504        let path = format!("/api/memory/contradiction/{}/dismiss", req.source_id);
1505        let response = match self
1506            .client
1507            .post_empty::<ContradictionDismissResponse>(&path)
1508            .await
1509        {
1510            Ok(r) => r,
1511            Err(e) => return Ok(tool_error(e, "dismiss_contradiction")),
1512        };
1513        let pretty = serde_json::to_string_pretty(&response)
1514            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1515        Ok(CallToolResult::success(vec![Content::text(pretty)]))
1516    }
1517
1518    pub async fn list_pending_imports_impl(
1519        &self,
1520        _params: ListPendingImportsParams,
1521    ) -> Result<CallToolResult, McpError> {
1522        let resp: Vec<origin_types::import::PendingImport> =
1523            match self.client.get("/api/import/state").await {
1524                Ok(v) => v,
1525                Err(e) => return Ok(tool_error(e, "list_pending_imports")),
1526            };
1527        let pretty = serde_json::to_string_pretty(&resp)
1528            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1529        Ok(CallToolResult::success(vec![Content::text(format!(
1530            "{} pending import(s)\n{}",
1531            resp.len(),
1532            pretty
1533        ))]))
1534    }
1535
1536    pub async fn list_rejections_impl(
1537        &self,
1538        params: ListRejectionsParams,
1539    ) -> Result<CallToolResult, McpError> {
1540        let mut path = String::from("/api/memory/rejections");
1541        let mut q: Vec<String> = Vec::new();
1542        if let Some(l) = params.limit {
1543            q.push(format!("limit={}", l.clamp(1, 500)));
1544        }
1545        if let Some(r) = params.reason.as_deref().filter(|s| !s.is_empty()) {
1546            q.push(format!("reason={}", url_encode_simple(r)));
1547        }
1548        if !q.is_empty() {
1549            path.push('?');
1550            path.push_str(&q.join("&"));
1551        }
1552
1553        let resp: Vec<origin_types::memory::RejectionRecord> = match self.client.get(&path).await {
1554            Ok(v) => v,
1555            Err(e) => return Ok(tool_error(e, "list_rejections")),
1556        };
1557
1558        let pretty = serde_json::to_string_pretty(&resp)
1559            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1560        Ok(CallToolResult::success(vec![Content::text(format!(
1561            "{} rejection(s)\n{}",
1562            resp.len(),
1563            pretty
1564        ))]))
1565    }
1566
1567    pub async fn list_pending_revisions_impl(
1568        &self,
1569        params: ListPendingRevisionsParams,
1570    ) -> Result<CallToolResult, McpError> {
1571        let path = match params.limit {
1572            Some(l) => format!("/api/memory/pending-revisions?limit={}", l.clamp(1, 500)),
1573            None => "/api/memory/pending-revisions".to_string(),
1574        };
1575        let resp: Vec<origin_types::responses::PendingRevisionItem> =
1576            match self.client.get(&path).await {
1577                Ok(v) => v,
1578                Err(e) => return Ok(tool_error(e, "list_pending_revisions")),
1579            };
1580        let pretty = serde_json::to_string_pretty(&resp)
1581            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1582        Ok(CallToolResult::success(vec![Content::text(format!(
1583            "{} pending revision(s)\n{}",
1584            resp.len(),
1585            pretty
1586        ))]))
1587    }
1588
1589    pub async fn list_orphan_links_impl(
1590        &self,
1591        params: ListOrphanLinksParams,
1592    ) -> Result<CallToolResult, McpError> {
1593        let path = match params.min_count {
1594            Some(n) => format!("/api/pages/orphan-links?min_count={}", n.max(1)),
1595            None => "/api/pages/orphan-links".to_string(),
1596        };
1597        let resp: origin_types::responses::OrphanLinksResponse = match self.client.get(&path).await
1598        {
1599            Ok(v) => v,
1600            Err(e) => return Ok(tool_error(e, "list_orphan_links")),
1601        };
1602        let pretty = serde_json::to_string_pretty(&resp)
1603            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1604        Ok(CallToolResult::success(vec![Content::text(format!(
1605            "{} orphan link(s)\n{}",
1606            resp.orphan_labels.len(),
1607            pretty
1608        ))]))
1609    }
1610}
1611
1612/// Build the `/api/pages/recent` URL with optional `limit` + `since_ms` query
1613/// params. Pure function so the test can exercise the actual builder rather
1614/// than a duplicate.
1615fn build_recent_pages_path(limit: Option<usize>, since_ms: Option<i64>) -> String {
1616    let mut path = String::from("/api/pages/recent");
1617    let mut q: Vec<String> = Vec::new();
1618    if let Some(l) = limit {
1619        q.push(format!("limit={}", l));
1620    }
1621    if let Some(s) = since_ms {
1622        q.push(format!("since_ms={}", s));
1623    }
1624    if !q.is_empty() {
1625        path.push('?');
1626        path.push_str(&q.join("&"));
1627    }
1628    path
1629}
1630
1631/// Percent-encode a string for use in URL query parameter values.
1632/// Encodes all characters except unreserved ones (A-Z, a-z, 0-9, `-`, `_`, `.`, `~`).
1633fn url_encode_simple(s: &str) -> String {
1634    s.chars()
1635        .flat_map(|c| match c {
1636            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
1637                vec![c]
1638            }
1639            _ => format!("%{:02X}", c as u32).chars().collect(),
1640        })
1641        .collect()
1642}
1643
1644// ===== Tool Registrations =====
1645
1646#[tool_router]
1647impl OriginMcpServer {
1648    pub fn new(
1649        client: OriginClient,
1650        transport: TransportMode,
1651        agent_name: String,
1652        user_id: Option<String>,
1653    ) -> Self {
1654        Self {
1655            tool_router: Self::tool_router(),
1656            client,
1657            transport,
1658            agent_name,
1659            client_name: std::sync::Arc::new(std::sync::Mutex::new(None)),
1660            user_id,
1661        }
1662    }
1663
1664    // --- Primary Tools ---
1665
1666    #[tool(
1667        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.",
1668        annotations(
1669            title = "Capture",
1670            read_only_hint = false,
1671            destructive_hint = false,
1672            idempotent_hint = false,
1673            open_world_hint = false
1674        )
1675    )]
1676    async fn capture(
1677        &self,
1678        Parameters(params): Parameters<CaptureParams>,
1679    ) -> Result<CallToolResult, McpError> {
1680        self.capture_impl(params).await
1681    }
1682
1683    #[tool(
1684        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, space) 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.",
1685        annotations(title = "Recall", read_only_hint = true, open_world_hint = false)
1686    )]
1687    async fn recall(
1688        &self,
1689        Parameters(params): Parameters<RecallParams>,
1690    ) -> Result<CallToolResult, McpError> {
1691        self.recall_impl(params).await
1692    }
1693
1694    #[tool(
1695        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.",
1696        annotations(title = "Context", read_only_hint = true, open_world_hint = false)
1697    )]
1698    async fn context(
1699        &self,
1700        Parameters(params): Parameters<ContextParams>,
1701    ) -> Result<CallToolResult, McpError> {
1702        self.context_impl(params).await
1703    }
1704
1705    #[tool(
1706        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 distill cycles are off. Reports daemon reachability, setup mode, Local Memory, On-device Model, Anthropic key state, and on-device model state.",
1707        annotations(title = "Doctor", read_only_hint = true, open_world_hint = false)
1708    )]
1709    async fn doctor(&self) -> Result<CallToolResult, McpError> {
1710        self.doctor_impl().await
1711    }
1712
1713    #[tool(
1714        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.",
1715        annotations(
1716            title = "Forget",
1717            read_only_hint = false,
1718            destructive_hint = true,
1719            idempotent_hint = true,
1720            open_world_hint = false
1721        )
1722    )]
1723    async fn forget(
1724        &self,
1725        Parameters(params): Parameters<ForgetParams>,
1726    ) -> Result<CallToolResult, McpError> {
1727        self.forget_impl(&params.memory_id).await
1728    }
1729
1730    #[tool(
1731        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 space value (e.g. `work`, `personal`) scopes to that space. 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.",
1732        annotations(
1733            title = "Distill",
1734            read_only_hint = false,
1735            destructive_hint = false,
1736            idempotent_hint = true,
1737            open_world_hint = false
1738        )
1739    )]
1740    async fn distill(
1741        &self,
1742        Parameters(params): Parameters<DistillParams>,
1743    ) -> Result<CallToolResult, McpError> {
1744        self.distill_impl(params).await
1745    }
1746
1747    #[tool(
1748        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.",
1749        annotations(title = "List pending", read_only_hint = true, open_world_hint = false)
1750    )]
1751    async fn list_pending(
1752        &self,
1753        Parameters(params): Parameters<ListPendingParams>,
1754    ) -> Result<CallToolResult, McpError> {
1755        self.list_pending_impl(params).await
1756    }
1757
1758    #[tool(
1759        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`.",
1760        annotations(
1761            title = "Confirm memory",
1762            read_only_hint = false,
1763            destructive_hint = false,
1764            idempotent_hint = true,
1765            open_world_hint = false
1766        )
1767    )]
1768    async fn confirm_memory(
1769        &self,
1770        Parameters(params): Parameters<ConfirmMemoryParams>,
1771    ) -> Result<CallToolResult, McpError> {
1772        self.confirm_memory_impl(&params.memory_id).await
1773    }
1774
1775    // --- Knowledge graph CRUD ---
1776
1777    #[tool(
1778        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 a model or Anthropic key is configured — call this explicitly when distill cycles are off or you need the id back synchronously.",
1779        annotations(
1780            title = "Create entity",
1781            read_only_hint = false,
1782            destructive_hint = false,
1783            idempotent_hint = false,
1784            open_world_hint = false
1785        )
1786    )]
1787    async fn create_entity(
1788        &self,
1789        Parameters(params): Parameters<CreateEntityParams>,
1790    ) -> Result<CallToolResult, McpError> {
1791        self.create_entity_impl(params).await
1792    }
1793
1794    #[tool(
1795        description = "Create a directed relation between two entities in the knowledge graph. Use sparingly — most relations come out of the daemon's enrichment when a model or Anthropic key is configured. Call this explicitly to record a relation the user articulated that the daemon couldn't infer, or when distill cycles are off.",
1796        annotations(
1797            title = "Create relation",
1798            read_only_hint = false,
1799            destructive_hint = false,
1800            idempotent_hint = false,
1801            open_world_hint = false
1802        )
1803    )]
1804    async fn create_relation(
1805        &self,
1806        Parameters(params): Parameters<CreateRelationParams>,
1807    ) -> Result<CallToolResult, McpError> {
1808        self.create_relation_impl(params).await
1809    }
1810
1811    #[tool(
1812        description = "Attach a factual observation to an existing entity in the knowledge graph. Use sparingly — most observations come from daemon extraction. Call explicitly when the user articulates a fact about a person/project/tool that the daemon couldn't infer, or when distill cycles are off. Requires the entity_id; resolve via search_entities first if you only have the name. Returns 422 if entity does not exist.",
1813        annotations(
1814            title = "Create observation",
1815            read_only_hint = false,
1816            destructive_hint = false,
1817            idempotent_hint = false,
1818            open_world_hint = false
1819        )
1820    )]
1821    async fn create_observation(
1822        &self,
1823        Parameters(params): Parameters<CreateObservationParams>,
1824    ) -> Result<CallToolResult, McpError> {
1825        self.create_observation_impl(params).await
1826    }
1827
1828    #[tool(
1829        description = "Confirm (or unconfirm) an entity in the knowledge graph — flips its stability flag from tentative to durable. Call when the user explicitly affirms or revokes an extracted entity (\"yes that's right\", \"no that's wrong\"), or when you have high confidence after seeing the entity reused across multiple contexts. Unconfirmed entities may be pruned by distill cycles; confirmed ones persist. Defaults confirmed=true if omitted. Do NOT call for every extracted entity — most should stay unconfirmed and let distill cycles decide. Not available over remote HTTP MCP transport (local stdio only).",
1830        annotations(
1831            title = "Confirm entity",
1832            read_only_hint = false,
1833            destructive_hint = false,
1834            idempotent_hint = true,
1835            open_world_hint = false
1836        )
1837    )]
1838    async fn confirm_entity(
1839        &self,
1840        Parameters(params): Parameters<ConfirmEntityParams>,
1841    ) -> Result<CallToolResult, McpError> {
1842        self.confirm_entity_impl(params).await
1843    }
1844
1845    #[tool(
1846        description = "Update the content of an existing observation. Use when the user corrects a fact (\"actually X not Y\") or when you find that a prior observation needs refinement based on new context. Only the content text changes — the entity attachment stays the same. To move an observation to a different entity, delete and recreate. Prefer this over delete+recreate when the entity attachment is correct, so history is preserved. Not available over remote HTTP MCP transport (local stdio only).",
1847        annotations(
1848            title = "Update observation",
1849            read_only_hint = false,
1850            destructive_hint = false,
1851            idempotent_hint = true,
1852            open_world_hint = false
1853        )
1854    )]
1855    async fn update_observation(
1856        &self,
1857        Parameters(params): Parameters<UpdateObservationParams>,
1858    ) -> Result<CallToolResult, McpError> {
1859        self.update_observation_impl(params).await
1860    }
1861
1862    #[tool(
1863        description = "Confirm (or unconfirm) an observation — flips its stability flag from tentative to durable. Call when the user explicitly affirms a specific fact attached to an entity (\"yes Alice does prefer tabs\"), or when you observe the same fact restated across multiple sources. Unconfirmed observations may be pruned by distill cycles; confirmed ones persist. Defaults confirmed=true if omitted. Do NOT call for every observation you create — let distill cycles promote them when warranted. Not available over remote HTTP MCP transport (local stdio only).",
1864        annotations(
1865            title = "Confirm observation",
1866            read_only_hint = false,
1867            destructive_hint = false,
1868            idempotent_hint = true,
1869            open_world_hint = false
1870        )
1871    )]
1872    async fn confirm_observation(
1873        &self,
1874        Parameters(params): Parameters<ConfirmObservationParams>,
1875    ) -> Result<CallToolResult, McpError> {
1876        self.confirm_observation_impl(params).await
1877    }
1878
1879    #[tool(
1880        description = "Delete an observation by ID. Destructive and cannot be undone — for corrections, prefer update_observation. Not available over remote HTTP MCP transport (local stdio only).",
1881        annotations(
1882            title = "Delete observation",
1883            read_only_hint = false,
1884            destructive_hint = true,
1885            idempotent_hint = true,
1886            open_world_hint = false
1887        )
1888    )]
1889    async fn delete_observation(
1890        &self,
1891        Parameters(params): Parameters<DeleteObservationParams>,
1892    ) -> Result<CallToolResult, McpError> {
1893        self.delete_observation_impl(params).await
1894    }
1895
1896    #[tool(
1897        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.",
1898        annotations(
1899            title = "Create page",
1900            read_only_hint = false,
1901            destructive_hint = false,
1902            idempotent_hint = false,
1903            open_world_hint = false
1904        )
1905    )]
1906    async fn create_page(
1907        &self,
1908        Parameters(params): Parameters<CreatePageParams>,
1909    ) -> Result<CallToolResult, McpError> {
1910        self.create_page_impl(params).await
1911    }
1912
1913    #[tool(
1914        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). Not available over remote HTTP MCP transport (local stdio only).",
1915        annotations(
1916            title = "Refresh page",
1917            read_only_hint = false,
1918            destructive_hint = false,
1919            idempotent_hint = false,
1920            open_world_hint = false
1921        )
1922    )]
1923    async fn update_page(
1924        &self,
1925        Parameters(params): Parameters<UpdatePageParams>,
1926    ) -> Result<CallToolResult, McpError> {
1927        self.update_page_impl(params).await
1928    }
1929
1930    #[tool(
1931        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.",
1932        annotations(
1933            title = "Delete page",
1934            read_only_hint = false,
1935            destructive_hint = true,
1936            idempotent_hint = true,
1937            open_world_hint = false
1938        )
1939    )]
1940    async fn delete_page(
1941        &self,
1942        Parameters(params): Parameters<DeletePageParams>,
1943    ) -> Result<CallToolResult, McpError> {
1944        self.delete_page_impl(&params.page_id).await
1945    }
1946
1947    #[tool(
1948        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.",
1949        annotations(title = "Get page", read_only_hint = true, open_world_hint = false)
1950    )]
1951    async fn get_page(
1952        &self,
1953        Parameters(params): Parameters<GetPageParams>,
1954    ) -> Result<CallToolResult, McpError> {
1955        self.get_page_impl(&params.page_id).await
1956    }
1957
1958    #[tool(
1959        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.",
1960        annotations(
1961            title = "Get page links",
1962            read_only_hint = true,
1963            destructive_hint = false,
1964            idempotent_hint = true,
1965            open_world_hint = false
1966        )
1967    )]
1968    async fn get_page_links(
1969        &self,
1970        Parameters(params): Parameters<GetPageLinksParams>,
1971    ) -> Result<CallToolResult, McpError> {
1972        self.get_page_links_impl(&params.page_id).await
1973    }
1974
1975    #[tool(
1976        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 space. 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.",
1977        annotations(
1978            title = "Get page sources",
1979            read_only_hint = true,
1980            destructive_hint = false,
1981            idempotent_hint = true,
1982            open_world_hint = false
1983        )
1984    )]
1985    async fn get_page_sources(
1986        &self,
1987        Parameters(params): Parameters<GetPageSourcesParams>,
1988    ) -> Result<CallToolResult, McpError> {
1989        self.get_page_sources_impl(&params.page_id).await
1990    }
1991
1992    #[tool(
1993        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.",
1994        annotations(
1995            title = "Get memory revisions",
1996            read_only_hint = true,
1997            destructive_hint = false,
1998            idempotent_hint = true,
1999            open_world_hint = false
2000        )
2001    )]
2002    async fn get_memory_revisions(
2003        &self,
2004        Parameters(params): Parameters<GetMemoryRevisionsParams>,
2005    ) -> Result<CallToolResult, McpError> {
2006        self.get_memory_revisions_impl(&params.memory_id).await
2007    }
2008
2009    #[tool(
2010        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.",
2011        annotations(
2012            title = "Get page revisions",
2013            read_only_hint = true,
2014            destructive_hint = false,
2015            idempotent_hint = true,
2016            open_world_hint = false
2017        )
2018    )]
2019    async fn get_page_revisions(
2020        &self,
2021        Parameters(params): Parameters<GetPageRevisionsParams>,
2022    ) -> Result<CallToolResult, McpError> {
2023        self.get_page_revisions_impl(&params.page_id).await
2024    }
2025
2026    #[tool(
2027        description = "List memories filtered by type and/or space. 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.",
2028        annotations(
2029            title = "List memories",
2030            read_only_hint = true,
2031            open_world_hint = false
2032        )
2033    )]
2034    async fn list_memories(
2035        &self,
2036        Parameters(params): Parameters<ListMemoriesParams>,
2037    ) -> Result<CallToolResult, McpError> {
2038        self.list_memories_impl(params).await
2039    }
2040
2041    #[tool(
2042        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.",
2043        annotations(title = "Search pages", read_only_hint = true, open_world_hint = false)
2044    )]
2045    async fn search_pages(
2046        &self,
2047        Parameters(params): Parameters<SearchPagesParams>,
2048    ) -> Result<CallToolResult, McpError> {
2049        self.search_pages_impl(params).await
2050    }
2051
2052    #[tool(
2053        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.",
2054        annotations(title = "Recent pages", read_only_hint = true, open_world_hint = false)
2055    )]
2056    async fn list_pages_recent(
2057        &self,
2058        Parameters(params): Parameters<ListPagesRecentParams>,
2059    ) -> Result<CallToolResult, McpError> {
2060        self.list_pages_recent_impl(params).await
2061    }
2062
2063    #[tool(
2064        description = "List all spaces in this Origin instance. Use when the user asks 'what spaces exist', 'list my topics', or to discover space names before passing one as a filter to search_memory / list_nurture. Returns each space's name, description, memory_count, entity_count, and timestamps.",
2065        annotations(title = "List spaces", read_only_hint = true, open_world_hint = false)
2066    )]
2067    async fn list_spaces(
2068        &self,
2069        Parameters(params): Parameters<ListSpacesParams>,
2070    ) -> Result<CallToolResult, McpError> {
2071        self.list_spaces_impl(params).await
2072    }
2073
2074    // --- Review proposal tools ---
2075
2076    #[tool(
2077        description = "List pending review proposals from Origin's daemon-side queue. Use when the user wants to audit what the daemon has queued for review — phrases like 'pending proposals', 'what's queued', 'check review queue'. 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.",
2078        annotations(
2079            title = "List review proposals",
2080            read_only_hint = true,
2081            open_world_hint = false
2082        )
2083    )]
2084    async fn list_refinements(
2085        &self,
2086        Parameters(params): Parameters<ListRefinementsParams>,
2087    ) -> Result<CallToolResult, McpError> {
2088        self.list_refinements_impl(params).await
2089    }
2090
2091    #[tool(
2092        description = "Reject (dismiss) a review proposal by id. Use when reviewing the daemon 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). Not available over remote HTTP MCP transport (local stdio only).",
2093        annotations(
2094            title = "Reject review proposal",
2095            read_only_hint = false,
2096            destructive_hint = false,
2097            idempotent_hint = true,
2098            open_world_hint = false
2099        )
2100    )]
2101    async fn reject_refinement(
2102        &self,
2103        Parameters(params): Parameters<RejectRefinementParams>,
2104    ) -> Result<CallToolResult, McpError> {
2105        self.reject_refinement_impl(params).await
2106    }
2107
2108    #[tool(
2109        description = "Apply a review queue proposal using sensible defaults. \
2110            entity_merge: existing entity wins as canonical. \
2111            relation_conflict: new relation supersedes. \
2112            detect_contradiction: previously-stored memory flagged for revision. \
2113            Returns 422 for suggest_entity (no producer) and dedup_merge (deprecated). \
2114            Not available over remote HTTP MCP transport (local stdio only).",
2115        annotations(
2116            title = "Accept review proposal",
2117            read_only_hint = false,
2118            destructive_hint = false,
2119            idempotent_hint = true,
2120            open_world_hint = false
2121        )
2122    )]
2123    async fn accept_refinement(
2124        &self,
2125        Parameters(params): Parameters<AcceptRefinementParams>,
2126    ) -> Result<CallToolResult, McpError> {
2127        self.accept_refinement_impl(params).await
2128    }
2129
2130    // --- Curation read tools ---
2131
2132    #[tool(
2133        description = "List nurture cards: memories flagged for human attention because they are unconfirmed, low-confidence, or have been queued for review by the daemon. Use when the user wants to audit what needs review: phrases like 'what needs my attention', 'unconfirmed memories', 'nurture queue'. Returns memory items with metadata. Optional `limit` caps results (default 50, max 500). Optional `space` restricts to one topic space. Distinct from `list_pending` (which lists all unconfirmed captures) and `list_refinements` (which lists daemon-generated merge/conflict proposals).",
2134        annotations(
2135            title = "List nurture cards",
2136            read_only_hint = true,
2137            idempotent_hint = true,
2138            open_world_hint = false
2139        )
2140    )]
2141    async fn list_nurture(
2142        &self,
2143        Parameters(params): Parameters<ListNurtureParams>,
2144    ) -> Result<CallToolResult, McpError> {
2145        self.list_nurture_impl(params).await
2146    }
2147
2148    #[tool(
2149        description = "List entity-suggestion proposals from the daemon review queue \
2150                       (action='suggest_entity'). Use when the user asks 'what entities \
2151                       does the daemon want to create' or wants to triage merge-vs-create \
2152                       decisions. Returns id, proposed entity_name, source_ids, confidence. \
2153                       Pair with PR2's approve/dismiss verbs once they land.",
2154        annotations(
2155            title = "List entity suggestions",
2156            read_only_hint = true,
2157            idempotent_hint = true,
2158            open_world_hint = false
2159        )
2160    )]
2161    async fn list_entity_suggestions(
2162        &self,
2163        Parameters(params): Parameters<ListEntitySuggestionsParams>,
2164    ) -> Result<CallToolResult, McpError> {
2165        self.list_entity_suggestions_impl(params).await
2166    }
2167
2168    #[tool(
2169        description = "Accept a pending memory revision. Replaces the target memory's content \
2170                       with the proposed revision content and removes the revision row from the \
2171                       pending list. Returns the consumed revision id. Returns an error if no \
2172                       pending revision exists for that target. Not available over remote HTTP MCP transport (local stdio only).",
2173        annotations(
2174            title = "Accept revision",
2175            read_only_hint = false,
2176            destructive_hint = false,
2177            idempotent_hint = false,
2178            open_world_hint = false
2179        )
2180    )]
2181    async fn accept_revision(
2182        &self,
2183        Parameters(req): Parameters<AcceptRevisionRequest>,
2184    ) -> Result<CallToolResult, McpError> {
2185        self.accept_revision_impl(req).await
2186    }
2187
2188    #[tool(
2189        description = "Dismiss a pending memory revision. Deletes the revision row; the original \
2190                       memory is unchanged. Returns an error if no pending revision exists for \
2191                       that target. Not available over remote HTTP MCP transport (local stdio only).",
2192        annotations(
2193            title = "Dismiss revision",
2194            read_only_hint = false,
2195            destructive_hint = false,
2196            idempotent_hint = false,
2197            open_world_hint = false
2198        )
2199    )]
2200    async fn dismiss_revision(
2201        &self,
2202        Parameters(req): Parameters<DismissRevisionRequest>,
2203    ) -> Result<CallToolResult, McpError> {
2204        self.dismiss_revision_impl(req).await
2205    }
2206
2207    #[tool(
2208        description = "Dismiss all awaiting-review contradiction flags for a memory. Idempotent. \
2209                       Returns wrote:true even if no rows matched. Not available over remote HTTP MCP transport (local stdio only).",
2210        annotations(
2211            title = "Dismiss contradiction",
2212            read_only_hint = false,
2213            destructive_hint = false,
2214            idempotent_hint = true,
2215            open_world_hint = false
2216        )
2217    )]
2218    async fn dismiss_contradiction(
2219        &self,
2220        Parameters(req): Parameters<DismissContradictionRequest>,
2221    ) -> Result<CallToolResult, McpError> {
2222        self.dismiss_contradiction_impl(req).await
2223    }
2224
2225    #[tool(
2226        description = "List in-flight chat-history imports awaiting processing or completion. \
2227                       Use when the user asks 'what imports are running', 'is my Claude.ai \
2228                       export done', or to surface import progress. Returns id, vendor, \
2229                       stage, source path, processed/total conversation counts.",
2230        annotations(
2231            title = "List pending imports",
2232            read_only_hint = true,
2233            idempotent_hint = true,
2234            open_world_hint = false
2235        )
2236    )]
2237    async fn list_pending_imports(
2238        &self,
2239        Parameters(params): Parameters<ListPendingImportsParams>,
2240    ) -> Result<CallToolResult, McpError> {
2241        self.list_pending_imports_impl(params).await
2242    }
2243
2244    #[tool(
2245        description = "List quality-gate rejections: memories the daemon discarded before storing, due to low quality, duplication, or other filters. Use when the user asks 'what did Origin reject', 'what was filtered out', or to diagnose why captures are not appearing. Returns rejection records with reason code, detail, and similarity info. Optional `limit` caps results (default 50, max 500). Optional `reason` filters by rejection reason code (e.g. 'duplicate', 'low_quality').",
2246        annotations(
2247            title = "List rejections",
2248            read_only_hint = true,
2249            idempotent_hint = true,
2250            open_world_hint = false
2251        )
2252    )]
2253    async fn list_rejections(
2254        &self,
2255        Parameters(params): Parameters<ListRejectionsParams>,
2256    ) -> Result<CallToolResult, McpError> {
2257        self.list_rejections_impl(params).await
2258    }
2259
2260    #[tool(
2261        description = "List memories awaiting human accept/dismiss because a newer version \
2262                       was proposed (Protected tier supersede). Use when the user asks \
2263                       'what revisions are pending', 'show me memories awaiting approval'. \
2264                       Each item carries target_source_id (the memory being revised: pass \
2265                       THIS to accept_pending_revision in PR2) and revision_content for \
2266                       display. Optional `limit` caps results (default 50, max 500).",
2267        annotations(
2268            title = "List pending revisions",
2269            read_only_hint = true,
2270            idempotent_hint = true,
2271            open_world_hint = false
2272        )
2273    )]
2274    async fn list_pending_revisions(
2275        &self,
2276        Parameters(params): Parameters<ListPendingRevisionsParams>,
2277    ) -> Result<CallToolResult, McpError> {
2278        self.list_pending_revisions_impl(params).await
2279    }
2280
2281    #[tool(
2282        description = "List wiki-link labels that appear in page bodies but have no matching \
2283                       page title. Use when the user asks 'what links are broken', 'orphan links', \
2284                       or wants to find knowledge gaps. Returns label names and reference counts. \
2285                       Optional `min_count` filters to labels referenced at least N times \
2286                       (default 1, minimum 1).",
2287        annotations(
2288            title = "List orphan links",
2289            read_only_hint = true,
2290            idempotent_hint = true,
2291            open_world_hint = false
2292        )
2293    )]
2294    async fn list_orphan_links(
2295        &self,
2296        Parameters(params): Parameters<ListOrphanLinksParams>,
2297    ) -> Result<CallToolResult, McpError> {
2298        self.list_orphan_links_impl(params).await
2299    }
2300}
2301
2302// ===== ServerHandler =====
2303
2304#[tool_handler]
2305impl ServerHandler for OriginMcpServer {
2306    async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
2307        // Capture client name from MCP initialize handshake
2308        if let Some(client_info) = context.peer.peer_info() {
2309            let name = &client_info.client_info.name;
2310            if !name.is_empty() {
2311                if let Ok(mut guard) = self.client_name.lock() {
2312                    tracing::info!("MCP client identified: {}", name);
2313                    *guard = Some(name.clone());
2314                }
2315            }
2316        }
2317    }
2318
2319    fn get_info(&self) -> InitializeResult {
2320        InitializeResult::new(
2321            ServerCapabilities::builder()
2322                .enable_tools()
2323                .build(),
2324        )
2325        .with_server_info(
2326            Implementation::new("origin-mcp", env!("CARGO_PKG_VERSION"))
2327        )
2328        .with_instructions(
2329            "Origin is your personal memory layer — a local knowledge base that persists across sessions and tools.\n\
2330             Think of yourself as a curator, not a logger. Store insights, not conversation artifacts.\n\n\
2331             Origin is cumulative: each memory you store can be recalled, linked, and distilled into knowledge over time. \
2332             It's also shared across all the user's tools: what you write, other agents (Claude Desktop, Claude Code, \
2333             ChatGPT, Cursor, etc.) will read later. Write for any future reader, not just this conversation.\n\n\
2334             FIRST THING EVERY SESSION: Call context to load the user's identity, preferences, goals, and\n\
2335             topic-relevant memories. This is how you know who you're talking to. Use the result to model how the \
2336             user thinks — their preferences, corrections, and past decisions tell you how they want to be helped, \
2337             not just what they already know.\n\n\
2338             STORE PROACTIVELY — don't wait for the user to ask.\n\
2339             - The user states a preference (\"I use X because...\", \"I prefer Y over Z\")\n\
2340             - The user makes a decision (\"going with approach A\", \"switching to B\")\n\
2341             - The user corrects you or prior info (\"actually, it's C, not D\") — store the correction so it sticks\n\
2342             - The user shares a durable fact about themselves, their work, or people/projects/tools they care about — \
2343               anchor it to the entity\n\n\
2344             If the user asks explicitly (\"remember this\", \"save this\", \"don't forget\"), that's a floor — you \
2345             should have already stored it.\n\n\
2346             WHEN NOT TO STORE:\n\
2347             - Conversation filler (\"ok\", \"thanks\", \"let's move on\")\n\
2348             - Things the user can trivially re-derive (file paths, recent git history)\n\
2349             - Anything already stored — recall first if unsure\n\
2350             - Tool output or command results (file contents, git history, build logs) — these are derivable\n\
2351             - General world facts or documentation that aren't personal to this user (e.g., \"Rust has a borrow \
2352               checker\", \"PostgreSQL supports JSONB\") — those are not memory material.\n\
2353             - Your own inferences about the user that they didn't express. Store what they said; infer from that \
2354               when responding.\n\n\
2355             CONTENT QUALITY — this is where you make the biggest difference:\n\
2356             - Specific beats vague: \"prefers Rust for CLI tools because of compile-time safety\" > \"likes Rust\"\n\
2357             - Include the WHY: the backend can classify \"dark mode\" as a preference, but only you know\n\
2358               \"switched to dark mode because of migraines from bright screens\"\n\
2359             - Name the entities: mention people, projects, tools by name — this powers the knowledge graph\n\
2360             - Atomic: one idea per memory — \"prefers TDD\" and \"uses pytest\" should be two memories, not one\n\
2361             - Declarative, not narrative: \"User prefers X because Y\" — not \"User said today they prefer X\". \
2362               Memories outlive the conversation that produced them.\n\n\
2363             MEMORY TYPES — omit and trust the backend.\n\n\
2364             By default, do NOT set memory_type. The backend auto-classifies into identity / preference / \
2365             decision / lesson / gotcha / fact with more context than you have. Agents that over-specify \
2366             types tend to pick wrong.\n\n\
2367             Opt-in specification:\n\
2368             - \"profile\"   — you're sure it's about the user (identity / preference)\n\
2369             - \"knowledge\" — you're sure it's about the world (decision / lesson / gotcha / fact)\n\
2370             - Precise type — only if you're confident and the distinction matters.\n\n\
2371             EXCEPTION — decisions carry structured fields (alternatives considered, reversibility, domain) \
2372             that power the Decision Log view. Set memory_type=\"decision\" explicitly ONLY when the user \
2373             articulated alternatives weighed AND the reasoning for the choice. A bare \"I'm switching to Cursor\" \
2374             is just a preference change — omit the type. \"Switching to Cursor over VSCode because of better \
2375             Claude integration, and we can always go back\" — that's a decision.\n\n\
2376             RECALL vs CONTEXT:\n\
2377             - context: broad orientation, session start, topic shifts, \"catch me up\"\n\
2378             - recall: specific lookup (\"what's Alice's role?\", \"database preferences\", \"our auth decision\")\n\n\
2379             The backend handles classification, entity extraction, structured fields, quality scoring,\n\
2380             and dedup — you don't need to replicate that logic. Focus on what only you know:\n\
2381             the conversational context, why something matters, and what the user actually cares about."
2382        )
2383    }
2384}
2385
2386#[cfg(test)]
2387mod tests {
2388    use super::*;
2389    use crate::client::OriginClient;
2390    use crate::types::{
2391        ChatContextRequest, ChatContextResponse, SearchMemoryRequest, SearchResult,
2392        StoreMemoryRequest, StoreMemoryResponse,
2393    };
2394
2395    fn make_server(
2396        transport: TransportMode,
2397        agent_name: &str,
2398        user_id: Option<&str>,
2399    ) -> OriginMcpServer {
2400        let client = OriginClient::new("http://127.0.0.1:19999".into());
2401        OriginMcpServer::new(
2402            client,
2403            transport,
2404            agent_name.into(),
2405            user_id.map(String::from),
2406        )
2407    }
2408
2409    // ===== Transport resolution (existing) =====
2410
2411    #[test]
2412    fn test_http_mode_prefers_param_over_agent_name() {
2413        let server = make_server(TransportMode::Http, "claude.ai", None);
2414        // Explicit param has highest priority
2415        let result = server.resolve_source_agent(Some("user-provided".into()));
2416        assert_eq!(result, Some("user-provided".into()));
2417    }
2418
2419    #[test]
2420    fn test_http_mode_sets_source_agent_when_none() {
2421        let server = make_server(TransportMode::Http, "chatgpt", None);
2422        let result = server.resolve_source_agent(None);
2423        assert_eq!(result, Some("chatgpt".into()));
2424    }
2425
2426    #[test]
2427    fn test_stdio_mode_passes_through_source_agent() {
2428        let server = make_server(TransportMode::Stdio, "ignored", None);
2429        let result = server.resolve_source_agent(Some("user-provided".into()));
2430        assert_eq!(result, Some("user-provided".into()));
2431    }
2432
2433    #[test]
2434    fn test_stdio_mode_falls_back_to_agent_name() {
2435        let server = make_server(TransportMode::Stdio, "fallback", None);
2436        // No param, no client_name → falls back to configured agent_name
2437        let result = server.resolve_source_agent(None);
2438        assert_eq!(result, Some("fallback".into()));
2439    }
2440
2441    #[test]
2442    fn test_http_mode_resolves_configured_user_id_for_local_use() {
2443        let server = make_server(TransportMode::Http, "agent", Some("lucian"));
2444        let result = server.resolve_user_id(None);
2445        assert_eq!(result, Some("lucian".into()));
2446    }
2447
2448    #[test]
2449    fn test_transport_mode_equality() {
2450        assert_eq!(TransportMode::Stdio, TransportMode::Stdio);
2451        assert_eq!(TransportMode::Http, TransportMode::Http);
2452        assert_ne!(TransportMode::Stdio, TransportMode::Http);
2453    }
2454
2455    // ===== Param deserialization: CaptureParams =====
2456
2457    #[test]
2458    fn test_capture_params_minimal() {
2459        let json = r#"{"content": "Lucian prefers dark mode"}"#;
2460        let params: CaptureParams = serde_json::from_str(json).unwrap();
2461        assert_eq!(params.content, "Lucian prefers dark mode");
2462        assert!(params.memory_type.is_none());
2463        assert!(params.space.is_none());
2464        assert!(params.entity.is_none());
2465        assert!(params.confidence.is_none());
2466        assert!(params.supersedes.is_none());
2467    }
2468
2469    #[test]
2470    fn test_capture_params_full() {
2471        let json = r#"{
2472            "content": "We chose PostgreSQL over MongoDB",
2473            "memory_type": "decision",
2474            "space": "origin",
2475            "entity": "PostgreSQL",
2476            "confidence": 0.95,
2477            "supersedes": "mem_abc123"
2478        }"#;
2479        let params: CaptureParams = serde_json::from_str(json).unwrap();
2480        assert_eq!(params.content, "We chose PostgreSQL over MongoDB");
2481        assert_eq!(params.memory_type.as_deref(), Some("decision"));
2482        assert_eq!(params.space.as_deref(), Some("origin"));
2483        assert_eq!(params.entity.as_deref(), Some("PostgreSQL"));
2484        assert_eq!(params.confidence, Some(0.95));
2485        assert_eq!(params.supersedes.as_deref(), Some("mem_abc123"));
2486    }
2487
2488    #[test]
2489    fn test_capture_params_missing_content_fails() {
2490        let json = r#"{"memory_type": "fact"}"#;
2491        let result = serde_json::from_str::<CaptureParams>(json);
2492        assert!(result.is_err());
2493    }
2494
2495    // ===== Param deserialization: RecallParams =====
2496
2497    #[test]
2498    fn test_recall_params_minimal() {
2499        let json = r#"{"query": "what does Alice work on?"}"#;
2500        let params: RecallParams = serde_json::from_str(json).unwrap();
2501        assert_eq!(params.query, "what does Alice work on?");
2502        assert!(params.limit.is_none());
2503    }
2504
2505    #[test]
2506    fn test_recall_params_full() {
2507        let json = r#"{
2508            "query": "database preferences",
2509            "limit": 5,
2510            "memory_type": "decision",
2511            "space": "origin"
2512        }"#;
2513        let params: RecallParams = serde_json::from_str(json).unwrap();
2514        assert_eq!(params.query, "database preferences");
2515        assert_eq!(params.limit, Some(5));
2516        assert_eq!(params.memory_type.as_deref(), Some("decision"));
2517        assert_eq!(params.space.as_deref(), Some("origin"));
2518    }
2519
2520    #[test]
2521    fn test_recall_params_limit_as_string() {
2522        let json = r#"{"query": "test", "limit": "10"}"#;
2523        let params: RecallParams = serde_json::from_str(json).unwrap();
2524        assert_eq!(params.limit, Some(10));
2525    }
2526
2527    #[test]
2528    fn test_recall_params_missing_query_fails() {
2529        let json = r#"{"limit": 5}"#;
2530        let result = serde_json::from_str::<RecallParams>(json);
2531        assert!(result.is_err());
2532    }
2533
2534    // ===== Param deserialization: ContextParams =====
2535
2536    #[test]
2537    fn test_context_params_empty() {
2538        let json = r#"{}"#;
2539        let params: ContextParams = serde_json::from_str(json).unwrap();
2540        assert!(params.topic.is_none());
2541        assert!(params.limit.is_none());
2542        assert!(params.space.is_none());
2543    }
2544
2545    #[test]
2546    fn test_context_params_full() {
2547        let json = r#"{"topic": "project Origin architecture", "limit": 30, "space": "work"}"#;
2548        let params: ContextParams = serde_json::from_str(json).unwrap();
2549        assert_eq!(params.topic.as_deref(), Some("project Origin architecture"));
2550        assert_eq!(params.limit, Some(30));
2551        assert_eq!(params.space.as_deref(), Some("work"));
2552    }
2553
2554    #[test]
2555    fn test_context_params_limit_as_string() {
2556        let json = r#"{"limit": "20"}"#;
2557        let params: ContextParams = serde_json::from_str(json).unwrap();
2558        assert_eq!(params.limit, Some(20));
2559    }
2560
2561    #[test]
2562    fn legacy_domain_alias_still_deserializes() {
2563        // Cached MCP clients (pre-0.7.0 schema) send `"domain"` instead of `"space"`.
2564        // The serde alias must accept legacy JSON so they don't break for the one-release window.
2565        let json = r#"{"topic": "project work", "domain": "work"}"#;
2566        let params: ContextParams =
2567            serde_json::from_str(json).expect("legacy 'domain' key must deserialize");
2568        assert_eq!(
2569            params.space.as_deref(),
2570            Some("work"),
2571            "alias must map domain → space"
2572        );
2573    }
2574
2575    #[test]
2576    fn store_memory_request_serialization_excludes_user_id() {
2577        let req = StoreMemoryRequest {
2578            content: "test content".into(),
2579            memory_type: None,
2580            space: None,
2581            source_agent: Some("test-agent".into()),
2582            title: None,
2583            confidence: None,
2584            supersedes: None,
2585            entity: None,
2586            entity_id: None,
2587            structured_fields: None,
2588            retrieval_cue: None,
2589        };
2590        let json = serde_json::to_value(&req).unwrap();
2591        let obj = json.as_object().unwrap();
2592        assert!(
2593            !obj.contains_key("user_id"),
2594            "user_id must not be on the wire; got: {:?}",
2595            obj.keys().collect::<Vec<_>>()
2596        );
2597    }
2598
2599    #[test]
2600    fn capture_success_message_is_terse() {
2601        let resp = StoreMemoryResponse {
2602            source_id: "mem_abc".into(),
2603            chunks_created: 3,
2604            memory_type: "fact".into(),
2605            entity_id: Some("ent_xyz".into()),
2606            quality: Some("high".into()),
2607            warnings: vec![],
2608            extraction_method: "llm".into(),
2609            enrichment: String::new(),
2610            hint: String::new(),
2611            triggered_revisions: vec![],
2612            auto_superseded: vec![],
2613        };
2614        let msg = format_capture_success(&resp);
2615        assert_eq!(msg, "Stored mem_abc");
2616        assert!(!msg.contains("chunks"));
2617        assert!(!msg.contains("quality"));
2618        assert!(!msg.contains("entity"));
2619    }
2620
2621    #[test]
2622    fn capture_success_message_surfaces_warnings() {
2623        let resp = StoreMemoryResponse {
2624            source_id: "mem_abc".into(),
2625            chunks_created: 1,
2626            memory_type: "decision".into(),
2627            entity_id: None,
2628            quality: None,
2629            warnings: vec!["decision memory missing required 'claim' field".into()],
2630            extraction_method: "agent".into(),
2631            enrichment: String::new(),
2632            hint: String::new(),
2633            triggered_revisions: vec![],
2634            auto_superseded: vec![],
2635        };
2636        let msg = format_capture_success(&resp);
2637        assert!(msg.starts_with("Stored mem_abc"));
2638        assert!(msg.contains("Warnings:"));
2639        assert!(msg.contains("decision memory missing required 'claim' field"));
2640    }
2641
2642    #[test]
2643    fn format_capture_success_surfaces_triggered_revisions() {
2644        let resp = StoreMemoryResponse {
2645            source_id: "mem_new".into(),
2646            chunks_created: 1,
2647            memory_type: "fact".into(),
2648            entity_id: None,
2649            quality: None,
2650            warnings: vec![],
2651            extraction_method: "agent".into(),
2652            enrichment: String::new(),
2653            hint: String::new(),
2654            triggered_revisions: vec!["mem_protected_target".to_string()],
2655            auto_superseded: vec![],
2656        };
2657        let out = format_capture_success(&resp);
2658        assert!(out.contains("Triggered revisions"));
2659        assert!(out.contains("mem_protected_target"));
2660        assert!(out.contains("accept_revision"));
2661        assert!(out.contains("dismiss_revision"));
2662    }
2663
2664    #[test]
2665    fn format_capture_success_omits_section_when_empty() {
2666        let resp = StoreMemoryResponse {
2667            source_id: "mem_new".into(),
2668            chunks_created: 1,
2669            memory_type: "fact".into(),
2670            entity_id: None,
2671            quality: None,
2672            warnings: vec![],
2673            extraction_method: "agent".into(),
2674            enrichment: String::new(),
2675            hint: String::new(),
2676            triggered_revisions: vec![],
2677            auto_superseded: vec![],
2678        };
2679        let out = format_capture_success(&resp);
2680        assert!(!out.contains("Triggered revisions"));
2681    }
2682
2683    #[test]
2684    fn format_capture_success_surfaces_auto_superseded() {
2685        let resp = StoreMemoryResponse {
2686            source_id: "mem_new".into(),
2687            chunks_created: 1,
2688            memory_type: "fact".into(),
2689            entity_id: None,
2690            quality: None,
2691            warnings: vec![],
2692            extraction_method: "agent".into(),
2693            enrichment: String::new(),
2694            hint: String::new(),
2695            triggered_revisions: vec![],
2696            auto_superseded: vec!["mem_old_xyz".to_string()],
2697        };
2698        let out = format_capture_success(&resp);
2699        assert!(out.contains("Auto-superseded"));
2700        assert!(out.contains("mem_old_xyz"));
2701        assert!(out.contains("no action needed"));
2702    }
2703
2704    #[test]
2705    fn format_capture_success_omits_auto_superseded_when_empty() {
2706        let resp = StoreMemoryResponse {
2707            source_id: "mem_new".into(),
2708            chunks_created: 1,
2709            memory_type: "fact".into(),
2710            entity_id: None,
2711            quality: None,
2712            warnings: vec![],
2713            extraction_method: "agent".into(),
2714            enrichment: String::new(),
2715            hint: String::new(),
2716            triggered_revisions: vec![],
2717            auto_superseded: vec![],
2718        };
2719        let out = format_capture_success(&resp);
2720        assert!(!out.contains("Auto-superseded"));
2721    }
2722
2723    #[test]
2724    fn doctor_local_memory_message_sets_expectations() {
2725        let msg = format_doctor_message(&serde_json::json!({
2726            "setup_completed": true,
2727            "mode": "basic-memory",
2728            "anthropic_key_configured": false,
2729            "local_model_selected": null,
2730            "local_model_loaded": null,
2731            "local_model_cached": false
2732        }));
2733
2734        assert!(msg.contains("Mode: Local Memory"));
2735        assert!(msg.contains("On-device model: not selected"));
2736        assert!(msg.contains("Distill cycles: off"));
2737        assert!(msg.contains("Local memory works now: capture, recall, and context are available"));
2738        assert!(msg.contains("origin model install"));
2739        assert!(msg.contains("origin key set anthropic"));
2740    }
2741
2742    #[test]
2743    fn doctor_on_device_model_message_shows_loaded_model() {
2744        let msg = format_doctor_message(&serde_json::json!({
2745            "setup_completed": true,
2746            "mode": "local-model",
2747            "anthropic_key_configured": false,
2748            "local_model_selected": "qwen3-1.7b",
2749            "local_model_loaded": "qwen3-1.7b",
2750            "local_model_cached": true
2751        }));
2752
2753        assert!(msg.contains("Mode: On-device Model"), "{msg}");
2754        assert!(
2755            msg.contains("On-device model: qwen3-1.7b (downloaded, loaded)"),
2756            "{msg}"
2757        );
2758        assert!(msg.contains("Distill cycles: enabled"), "{msg}");
2759        assert!(!msg.contains("Local memory works now"));
2760    }
2761
2762    #[test]
2763    fn doctor_unconfigured_message_names_three_setup_paths() {
2764        let msg = format_doctor_message(&serde_json::json!({
2765            "setup_completed": false,
2766            "mode": "unknown",
2767            "anthropic_key_configured": false,
2768            "local_model_selected": null,
2769            "local_model_loaded": null,
2770            "local_model_cached": false
2771        }));
2772
2773        assert!(msg.contains("Setup: not completed"));
2774        assert!(msg.contains("Run `origin setup`"));
2775        assert!(msg.contains("Local Memory, On-device Model, or Anthropic Key"));
2776    }
2777
2778    #[test]
2779    fn search_memory_request_serialization_excludes_entity() {
2780        let req = SearchMemoryRequest {
2781            query: "test".into(),
2782            limit: 10,
2783            memory_type: None,
2784            space: None,
2785            source_agent: None,
2786        };
2787        let json = serde_json::to_value(&req).unwrap();
2788        let obj = json.as_object().unwrap();
2789        assert!(
2790            !obj.contains_key("entity"),
2791            "entity must not be on the wire; got keys: {:?}",
2792            obj.keys().collect::<Vec<_>>()
2793        );
2794    }
2795
2796    #[test]
2797    fn chat_context_request_serialization_includes_domain() {
2798        #[allow(deprecated)]
2799        let req = ChatContextRequest {
2800            query: None,
2801            conversation_id: Some("topic".into()),
2802            max_chunks: 20,
2803            relevance_threshold: None,
2804            include_goals: true,
2805            space: Some("work".into()),
2806        };
2807        let json = serde_json::to_value(&req).unwrap();
2808        assert_eq!(json["space"], serde_json::json!("work"));
2809        assert_eq!(json["conversation_id"], serde_json::json!("topic"));
2810    }
2811
2812    #[test]
2813    fn chat_context_response_deserializes_with_profile_and_knowledge() {
2814        let json = r#"{
2815            "context": "user is Lucian, prefers Rust",
2816            "profile": {
2817                "narrative": "n",
2818                "identity": ["rust"],
2819                "preferences": [],
2820                "goals": []
2821            },
2822            "knowledge": {
2823                "pages": [],
2824                "decisions": [],
2825                "relevant_memories": [],
2826                "graph_context": []
2827            },
2828            "took_ms": 42.0,
2829            "token_estimates": {
2830                "tier1_identity": 10,
2831                "tier2_project": 20,
2832                "tier3_relevant": 30,
2833                "total": 60
2834            }
2835        }"#;
2836        let parsed: ChatContextResponse = serde_json::from_str(json).unwrap();
2837        assert_eq!(parsed.context, "user is Lucian, prefers Rust");
2838        assert_eq!(parsed.profile.identity, vec!["rust"]);
2839        assert_eq!(parsed.token_estimates.total, 60);
2840    }
2841
2842    #[test]
2843    fn capture_params_structured_fields_schema_is_object() {
2844        use schemars::schema_for;
2845
2846        let schema = schema_for!(CaptureParams);
2847        let json = serde_json::to_value(&schema).unwrap();
2848        let sf_schema = json
2849            .pointer("/properties/structured_fields")
2850            .expect("structured_fields property in schema");
2851        let type_val = sf_schema
2852            .pointer("/type")
2853            .unwrap_or(&serde_json::Value::Null);
2854        let type_str = match type_val {
2855            serde_json::Value::String(s) => s.clone(),
2856            serde_json::Value::Array(arr) => arr
2857                .iter()
2858                .filter_map(|v| v.as_str())
2859                .collect::<Vec<_>>()
2860                .join(","),
2861            other => panic!(
2862                "structured_fields schema lacks type constraint; got: {:?}",
2863                other
2864            ),
2865        };
2866        assert!(
2867            type_str.contains("object"),
2868            "expected object type, got: {}",
2869            type_str
2870        );
2871    }
2872
2873    // ===== Param deserialization: ForgetParams =====
2874
2875    #[test]
2876    fn test_forget_params() {
2877        let json = r#"{"memory_id": "mem_abc123"}"#;
2878        let params: ForgetParams = serde_json::from_str(json).unwrap();
2879        assert_eq!(params.memory_id, "mem_abc123");
2880    }
2881
2882    #[test]
2883    fn test_forget_params_missing_id_fails() {
2884        let json = r#"{}"#;
2885        let result = serde_json::from_str::<ForgetParams>(json);
2886        assert!(result.is_err());
2887    }
2888
2889    // ===== Request serialization: StoreMemoryRequest =====
2890
2891    #[test]
2892    fn test_store_request_includes_new_fields() {
2893        let req = StoreMemoryRequest {
2894            content: "test".into(),
2895            memory_type: Some("decision".into()),
2896            space: None,
2897            source_agent: Some("claude".into()),
2898            title: None,
2899            confidence: Some(0.9),
2900            supersedes: Some("old_id".into()),
2901            entity: Some("PostgreSQL".into()),
2902            entity_id: None,
2903            structured_fields: None,
2904            retrieval_cue: None,
2905        };
2906        let json = serde_json::to_value(&req).unwrap();
2907        assert_eq!(json["entity"], "PostgreSQL");
2908        assert_eq!(json["supersedes"], "old_id");
2909        assert!(json["confidence"].as_f64().unwrap() > 0.89);
2910        assert_eq!(json["source_agent"], "claude");
2911        assert!(json.get("user_id").is_none());
2912    }
2913
2914    #[test]
2915    fn test_store_request_minimal() {
2916        let req = StoreMemoryRequest {
2917            content: "hello".into(),
2918            memory_type: Some("fact".into()),
2919            space: None,
2920            source_agent: None,
2921            title: None,
2922            confidence: None,
2923            supersedes: None,
2924            entity: None,
2925            entity_id: None,
2926            structured_fields: None,
2927            retrieval_cue: None,
2928        };
2929        let json = serde_json::to_value(&req).unwrap();
2930        assert_eq!(json["content"], "hello");
2931        assert_eq!(json["memory_type"], "fact");
2932        assert!(json.get("user_id").is_none());
2933    }
2934
2935    // ===== Response deserialization: StoreMemoryResponse =====
2936
2937    #[test]
2938    fn test_store_response_with_new_fields() {
2939        let json = r#"{
2940            "source_id": "mem_xyz",
2941            "chunks_created": 2,
2942            "memory_type": "fact",
2943            "entity_id": "ent_abc",
2944            "quality": "high",
2945            "warnings": ["decision memory missing claim"],
2946            "extraction_method": "agent"
2947        }"#;
2948        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
2949        assert_eq!(resp.source_id, "mem_xyz");
2950        assert_eq!(resp.chunks_created, 2);
2951        assert_eq!(resp.memory_type, "fact");
2952        assert_eq!(resp.entity_id.as_deref(), Some("ent_abc"));
2953        assert_eq!(resp.quality.as_deref(), Some("high"));
2954        assert_eq!(resp.warnings, vec!["decision memory missing claim"]);
2955        assert_eq!(resp.extraction_method, "agent");
2956    }
2957
2958    #[test]
2959    fn test_store_response_backward_compat_no_new_fields() {
2960        // Old backend response without warnings/extraction_method
2961        let json = r#"{
2962            "source_id": "mem_old",
2963            "chunks_created": 1,
2964            "memory_type": "fact"
2965        }"#;
2966        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
2967        assert_eq!(resp.source_id, "mem_old");
2968        assert_eq!(resp.chunks_created, 1);
2969        assert_eq!(resp.memory_type, "fact");
2970        assert!(resp.entity_id.is_none());
2971        assert!(resp.quality.is_none());
2972        assert!(resp.warnings.is_empty());
2973        assert_eq!(resp.extraction_method, "unknown");
2974    }
2975
2976    #[test]
2977    fn test_store_response_with_warnings_and_extraction_method() {
2978        let json = r#"{
2979            "source_id": "mem_xyz",
2980            "chunks_created": 1,
2981            "memory_type": "decision",
2982            "warnings": ["decision memory missing required 'claim' field"],
2983            "extraction_method": "llm"
2984        }"#;
2985        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
2986        assert_eq!(resp.memory_type, "decision");
2987        assert_eq!(
2988            resp.warnings,
2989            vec!["decision memory missing required 'claim' field"]
2990        );
2991        assert_eq!(resp.extraction_method, "llm");
2992    }
2993
2994    // ===== Response deserialization: SearchResult =====
2995
2996    #[test]
2997    fn test_search_result_with_new_fields() {
2998        let json = r#"{
2999            "id": "1",
3000            "content": "We chose Postgres",
3001            "source": "memory",
3002            "source_id": "mem_1",
3003            "title": "DB decision",
3004            "url": null,
3005            "chunk_index": 0,
3006            "last_modified": 1711000000,
3007            "score": 0.95,
3008            "chunk_type": "memory",
3009            "language": "en",
3010            "semantic_unit": "sentence",
3011            "memory_type": "decision",
3012            "space": "origin",
3013            "source_agent": "claude",
3014            "confidence": 0.9,
3015            "confirmed": true,
3016            "stability": "standard",
3017            "supersedes": "mem_0",
3018            "summary": "DB choice",
3019            "entity_id": "ent_pg",
3020            "entity_name": "PostgreSQL",
3021            "quality": "high",
3022            "is_archived": false,
3023            "is_recap": false,
3024            "source_text": "We chose Postgres",
3025            "raw_score": 0.42
3026        }"#;
3027        let result: SearchResult = serde_json::from_str(json).unwrap();
3028        assert_eq!(result.chunk_type.as_deref(), Some("memory"));
3029        assert_eq!(result.language.as_deref(), Some("en"));
3030        assert_eq!(result.semantic_unit.as_deref(), Some("sentence"));
3031        assert_eq!(result.stability.as_deref(), Some("standard"));
3032        assert_eq!(result.supersedes.as_deref(), Some("mem_0"));
3033        assert_eq!(result.summary.as_deref(), Some("DB choice"));
3034        assert_eq!(result.entity_id.as_deref(), Some("ent_pg"));
3035        assert_eq!(result.entity_name.as_deref(), Some("PostgreSQL"));
3036        assert_eq!(result.quality.as_deref(), Some("high"));
3037        assert!(!result.is_archived);
3038        assert!(!result.is_recap);
3039        assert_eq!(result.source_text.as_deref(), Some("We chose Postgres"));
3040        assert!((result.raw_score - 0.42).abs() < f32::EPSILON);
3041    }
3042
3043    #[test]
3044    fn test_search_result_backward_compat_no_new_fields() {
3045        // Old backend response without entity/quality/archive/recap
3046        let json = r#"{
3047            "id": "1",
3048            "content": "test",
3049            "source": "memory",
3050            "source_id": "mem_1",
3051            "title": "test",
3052            "url": null,
3053            "chunk_index": 0,
3054            "last_modified": 1711000000,
3055            "score": 0.8,
3056            "memory_type": "fact",
3057            "space": null,
3058            "source_agent": null,
3059            "confidence": null,
3060            "confirmed": null
3061        }"#;
3062        let result: SearchResult = serde_json::from_str(json).unwrap();
3063        assert!(result.entity_id.is_none());
3064        assert!(result.entity_name.is_none());
3065        assert!(result.quality.is_none());
3066        assert!(!result.is_archived);
3067        assert!(!result.is_recap);
3068        assert!(result.structured_fields.is_none());
3069        assert!(result.retrieval_cue.is_none());
3070        assert_eq!(result.raw_score, 0.0);
3071    }
3072
3073    #[test]
3074    fn test_search_result_with_structured_fields_and_retrieval_cue() {
3075        let json = r#"{
3076            "id": "1",
3077            "content": "Lucian prefers dark mode",
3078            "source": "memory",
3079            "source_id": "mem_1",
3080            "title": "Dark mode preference",
3081            "url": null,
3082            "chunk_index": 0,
3083            "last_modified": 1711000000,
3084            "score": 0.92,
3085            "memory_type": "preference",
3086            "space": null,
3087            "source_agent": null,
3088            "confidence": null,
3089            "confirmed": null,
3090            "structured_fields": "{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}",
3091            "retrieval_cue": "What UI theme does Lucian prefer?"
3092        }"#;
3093        let result: SearchResult = serde_json::from_str(json).unwrap();
3094        assert_eq!(
3095            result.structured_fields.as_deref(),
3096            Some("{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}")
3097        );
3098        assert_eq!(
3099            result.retrieval_cue.as_deref(),
3100            Some("What UI theme does Lucian prefer?")
3101        );
3102        assert!(!result.is_archived);
3103        assert!(!result.is_recap);
3104        assert_eq!(result.raw_score, 0.0);
3105    }
3106
3107    #[test]
3108    fn test_search_result_knowledge_graph_source() {
3109        // Entity-boosted observation results from knowledge graph
3110        let json = r#"{
3111            "id": "obs_1",
3112            "content": "Prefers Rust over Go",
3113            "source": "knowledge_graph",
3114            "source_id": "ent_lucian",
3115            "title": "Lucian",
3116            "url": null,
3117            "chunk_index": 0,
3118            "last_modified": 1711000000,
3119            "score": 1.14,
3120            "memory_type": null,
3121            "space": null,
3122            "source_agent": null,
3123            "confidence": null,
3124            "confirmed": null,
3125            "entity_id": "ent_lucian",
3126            "entity_name": "Lucian"
3127        }"#;
3128        let result: SearchResult = serde_json::from_str(json).unwrap();
3129        assert_eq!(result.source, "knowledge_graph");
3130        assert_eq!(result.entity_id.as_deref(), Some("ent_lucian"));
3131        assert_eq!(result.entity_name.as_deref(), Some("Lucian"));
3132        assert!(!result.is_archived);
3133        assert!(!result.is_recap);
3134        assert_eq!(result.raw_score, 0.0);
3135    }
3136
3137    // ===== Transport security: forget blocks on HTTP =====
3138
3139    #[tokio::test]
3140    async fn test_forget_blocked_on_http_transport() {
3141        let server = make_server(TransportMode::Http, "agent", None);
3142        let result = server.forget_impl("mem_123").await.unwrap();
3143        // Should return error content, not an Err
3144        let content = &result.content[0];
3145        match content.raw {
3146            rmcp::model::RawContent::Text(ref tc) => {
3147                assert!(tc.text.contains("not available over remote connections"));
3148            }
3149            _ => panic!("expected text content"),
3150        }
3151    }
3152
3153    #[tokio::test]
3154    async fn test_forget_allowed_on_stdio_transport() {
3155        // This will fail with connection error (no server), which proves
3156        // the transport check passed and it tried to make the HTTP call.
3157        // The error comes back as CallToolResult with is_error: true
3158        // (tool-level failure), not McpError (protocol-level).
3159        let server = make_server(TransportMode::Stdio, "agent", None);
3160        let result = server.forget_impl("mem_123").await.unwrap();
3161        assert!(
3162            result.is_error.unwrap_or(false),
3163            "should fail with connection error, not transport block"
3164        );
3165    }
3166
3167    // ===== Transport security: revision wrappers block on HTTP =====
3168
3169    #[tokio::test]
3170    async fn test_accept_revision_blocked_on_http_transport() {
3171        let server = make_server(TransportMode::Http, "agent", None);
3172        let req = AcceptRevisionRequest {
3173            target_source_id: "mem_x".into(),
3174        };
3175        let result = server.accept_revision_impl(req).await.unwrap();
3176        let content = &result.content[0];
3177        match content.raw {
3178            rmcp::model::RawContent::Text(ref tc) => {
3179                assert!(tc.text.contains("not available over remote connections"));
3180            }
3181            _ => panic!("expected text content"),
3182        }
3183    }
3184
3185    #[tokio::test]
3186    async fn test_accept_revision_allowed_on_stdio_transport() {
3187        let server = make_server(TransportMode::Stdio, "agent", None);
3188        let req = AcceptRevisionRequest {
3189            target_source_id: "mem_x".into(),
3190        };
3191        let result = server.accept_revision_impl(req).await.unwrap();
3192        assert!(
3193            result.is_error.unwrap_or(false),
3194            "should fail with connection error, not transport block"
3195        );
3196    }
3197
3198    #[tokio::test]
3199    async fn test_dismiss_revision_blocked_on_http_transport() {
3200        let server = make_server(TransportMode::Http, "agent", None);
3201        let req = DismissRevisionRequest {
3202            target_source_id: "mem_x".into(),
3203        };
3204        let result = server.dismiss_revision_impl(req).await.unwrap();
3205        let content = &result.content[0];
3206        match content.raw {
3207            rmcp::model::RawContent::Text(ref tc) => {
3208                assert!(tc.text.contains("not available over remote connections"));
3209            }
3210            _ => panic!("expected text content"),
3211        }
3212    }
3213
3214    #[tokio::test]
3215    async fn test_dismiss_revision_allowed_on_stdio_transport() {
3216        let server = make_server(TransportMode::Stdio, "agent", None);
3217        let req = DismissRevisionRequest {
3218            target_source_id: "mem_x".into(),
3219        };
3220        let result = server.dismiss_revision_impl(req).await.unwrap();
3221        assert!(
3222            result.is_error.unwrap_or(false),
3223            "should fail with connection error, not transport block"
3224        );
3225    }
3226
3227    #[tokio::test]
3228    async fn test_dismiss_contradiction_blocked_on_http_transport() {
3229        let server = make_server(TransportMode::Http, "agent", None);
3230        let req = DismissContradictionRequest {
3231            source_id: "mem_x".into(),
3232        };
3233        let result = server.dismiss_contradiction_impl(req).await.unwrap();
3234        let content = &result.content[0];
3235        match content.raw {
3236            rmcp::model::RawContent::Text(ref tc) => {
3237                assert!(tc.text.contains("not available over remote connections"));
3238            }
3239            _ => panic!("expected text content"),
3240        }
3241    }
3242
3243    #[tokio::test]
3244    async fn test_dismiss_contradiction_allowed_on_stdio_transport() {
3245        let server = make_server(TransportMode::Stdio, "agent", None);
3246        let req = DismissContradictionRequest {
3247            source_id: "mem_x".into(),
3248        };
3249        let result = server.dismiss_contradiction_impl(req).await.unwrap();
3250        assert!(
3251            result.is_error.unwrap_or(false),
3252            "should fail with connection error, not transport block"
3253        );
3254    }
3255
3256    #[tokio::test]
3257    async fn test_confirm_entity_blocked_on_http_transport() {
3258        let server = make_server(TransportMode::Http, "agent", None);
3259        let params = ConfirmEntityParams {
3260            entity_id: "ent_x".into(),
3261            confirmed: true,
3262        };
3263        let result = server.confirm_entity_impl(params).await.unwrap();
3264        let content = &result.content[0];
3265        match content.raw {
3266            rmcp::model::RawContent::Text(ref tc) => {
3267                assert!(tc.text.contains("not available over remote connections"));
3268            }
3269            _ => panic!("expected text content"),
3270        }
3271    }
3272
3273    #[tokio::test]
3274    async fn test_confirm_entity_allowed_on_stdio_transport() {
3275        let server = make_server(TransportMode::Stdio, "agent", None);
3276        let params = ConfirmEntityParams {
3277            entity_id: "ent_x".into(),
3278            confirmed: true,
3279        };
3280        let result = server.confirm_entity_impl(params).await.unwrap();
3281        assert!(
3282            result.is_error.unwrap_or(false),
3283            "should fail with connection error, not transport block"
3284        );
3285    }
3286
3287    #[tokio::test]
3288    async fn test_confirm_observation_blocked_on_http_transport() {
3289        let server = make_server(TransportMode::Http, "agent", None);
3290        let params = ConfirmObservationParams {
3291            observation_id: "obs_x".into(),
3292            confirmed: true,
3293        };
3294        let result = server.confirm_observation_impl(params).await.unwrap();
3295        let content = &result.content[0];
3296        match content.raw {
3297            rmcp::model::RawContent::Text(ref tc) => {
3298                assert!(tc.text.contains("not available over remote connections"));
3299            }
3300            _ => panic!("expected text content"),
3301        }
3302    }
3303
3304    #[tokio::test]
3305    async fn test_confirm_observation_allowed_on_stdio_transport() {
3306        let server = make_server(TransportMode::Stdio, "agent", None);
3307        let params = ConfirmObservationParams {
3308            observation_id: "obs_x".into(),
3309            confirmed: true,
3310        };
3311        let result = server.confirm_observation_impl(params).await.unwrap();
3312        assert!(
3313            result.is_error.unwrap_or(false),
3314            "should fail with connection error, not transport block"
3315        );
3316    }
3317
3318    #[tokio::test]
3319    async fn test_update_observation_blocked_on_http_transport() {
3320        let server = make_server(TransportMode::Http, "agent", None);
3321        let params = UpdateObservationParams {
3322            observation_id: "obs_x".into(),
3323            content: "new content".into(),
3324        };
3325        let result = server.update_observation_impl(params).await.unwrap();
3326        let content = &result.content[0];
3327        match content.raw {
3328            rmcp::model::RawContent::Text(ref tc) => {
3329                assert!(tc.text.contains("not available over remote connections"));
3330            }
3331            _ => panic!("expected text content"),
3332        }
3333    }
3334
3335    #[tokio::test]
3336    async fn test_update_observation_allowed_on_stdio_transport() {
3337        let server = make_server(TransportMode::Stdio, "agent", None);
3338        let params = UpdateObservationParams {
3339            observation_id: "obs_x".into(),
3340            content: "new content".into(),
3341        };
3342        let result = server.update_observation_impl(params).await.unwrap();
3343        assert!(
3344            result.is_error.unwrap_or(false),
3345            "should fail with connection error, not transport block"
3346        );
3347    }
3348
3349    #[tokio::test]
3350    async fn test_update_page_blocked_on_http_transport() {
3351        let server = make_server(TransportMode::Http, "agent", None);
3352        let params = UpdatePageParams {
3353            page_id: "page_x".into(),
3354            content: "body".into(),
3355            source_memory_ids: vec!["mem_a".into()],
3356            summary: None,
3357        };
3358        let result = server.update_page_impl(params).await.unwrap();
3359        let content = &result.content[0];
3360        match content.raw {
3361            rmcp::model::RawContent::Text(ref tc) => {
3362                assert!(tc.text.contains("not available over remote connections"));
3363            }
3364            _ => panic!("expected text content"),
3365        }
3366    }
3367
3368    #[tokio::test]
3369    async fn test_update_page_allowed_on_stdio_transport() {
3370        let server = make_server(TransportMode::Stdio, "agent", None);
3371        let params = UpdatePageParams {
3372            page_id: "page_x".into(),
3373            content: "body".into(),
3374            source_memory_ids: vec!["mem_a".into()],
3375            summary: None,
3376        };
3377        let result = server.update_page_impl(params).await.unwrap();
3378        assert!(
3379            result.is_error.unwrap_or(false),
3380            "should fail with connection error, not transport block"
3381        );
3382    }
3383
3384    // ===== Refinement queue guards =====
3385
3386    #[tokio::test]
3387    async fn test_reject_refinement_blocked_on_http_transport() {
3388        let server = make_server(TransportMode::Http, "agent", None);
3389        let params = RejectRefinementParams {
3390            id: "merge_abc_def".into(),
3391        };
3392        let result = server.reject_refinement_impl(params).await.unwrap();
3393        let content = &result.content[0];
3394        match content.raw {
3395            rmcp::model::RawContent::Text(ref tc) => {
3396                assert!(tc.text.contains("not available over remote connections"));
3397            }
3398            _ => panic!("expected text content"),
3399        }
3400    }
3401
3402    #[tokio::test]
3403    async fn test_reject_refinement_allowed_on_stdio_transport() {
3404        let server = make_server(TransportMode::Stdio, "agent", None);
3405        let params = RejectRefinementParams {
3406            id: "merge_abc_def".into(),
3407        };
3408        let result = server.reject_refinement_impl(params).await.unwrap();
3409        assert!(
3410            result.is_error.unwrap_or(false),
3411            "should fail with connection error, not transport block"
3412        );
3413    }
3414
3415    #[tokio::test]
3416    async fn test_accept_refinement_blocked_on_http_transport() {
3417        let server = make_server(TransportMode::Http, "agent", None);
3418        let params = AcceptRefinementParams {
3419            id: "merge_abc_def".into(),
3420        };
3421        let result = server.accept_refinement_impl(params).await.unwrap();
3422        let content = &result.content[0];
3423        match content.raw {
3424            rmcp::model::RawContent::Text(ref tc) => {
3425                assert!(tc.text.contains("not available over remote connections"));
3426            }
3427            _ => panic!("expected text content"),
3428        }
3429    }
3430
3431    #[tokio::test]
3432    async fn test_accept_refinement_allowed_on_stdio_transport() {
3433        let server = make_server(TransportMode::Stdio, "agent", None);
3434        let params = AcceptRefinementParams {
3435            id: "merge_abc_def".into(),
3436        };
3437        let result = server.accept_refinement_impl(params).await.unwrap();
3438        assert!(
3439            result.is_error.unwrap_or(false),
3440            "should fail with connection error, not transport block"
3441        );
3442    }
3443
3444    // ===== Context default limit =====
3445
3446    #[test]
3447    fn test_context_request_default_limit() {
3448        let params = ContextParams {
3449            topic: Some("test".into()),
3450            limit: None,
3451            space: None,
3452        };
3453        #[allow(deprecated)]
3454        let req = ChatContextRequest {
3455            query: None,
3456            conversation_id: params.topic,
3457            max_chunks: params.limit.unwrap_or(20),
3458            relevance_threshold: None,
3459            include_goals: true,
3460            space: params.space,
3461        };
3462        assert_eq!(req.max_chunks, 20);
3463    }
3464
3465    #[test]
3466    fn test_context_request_custom_limit() {
3467        let params = ContextParams {
3468            topic: None,
3469            limit: Some(5),
3470            space: Some("work".into()),
3471        };
3472        #[allow(deprecated)]
3473        let req = ChatContextRequest {
3474            query: None,
3475            conversation_id: params.topic,
3476            max_chunks: params.limit.unwrap_or(20),
3477            relevance_threshold: None,
3478            include_goals: true,
3479            space: params.space,
3480        };
3481        assert_eq!(req.max_chunks, 5);
3482        assert_eq!(req.space.as_deref(), Some("work"));
3483    }
3484
3485    #[test]
3486    fn test_context_maps_topic_to_conversation_id() {
3487        let params = ContextParams {
3488            topic: Some("project Origin".into()),
3489            limit: None,
3490            space: None,
3491        };
3492        #[allow(deprecated)]
3493        let req = ChatContextRequest {
3494            query: None,
3495            conversation_id: params.topic.clone(),
3496            max_chunks: params.limit.unwrap_or(20),
3497            relevance_threshold: None,
3498            include_goals: true,
3499            space: params.space,
3500        };
3501        assert_eq!(req.conversation_id.as_deref(), Some("project Origin"));
3502    }
3503
3504    // ===== Remember request construction =====
3505
3506    #[test]
3507    fn test_capture_constructs_store_request_with_entity() {
3508        let server = make_server(TransportMode::Stdio, "claude", None);
3509        let params = CaptureParams {
3510            content: "Alice manages the frontend team".into(),
3511            memory_type: Some("fact".into()),
3512            space: Some("work".into()),
3513            entity: Some("Alice".into()),
3514            confidence: Some(0.9),
3515            supersedes: None,
3516            structured_fields: None,
3517            retrieval_cue: None,
3518        };
3519
3520        // Replicate capture_impl's request construction
3521        let source_agent = server.resolve_source_agent(None);
3522
3523        let req = StoreMemoryRequest {
3524            content: params.content,
3525            memory_type: params.memory_type,
3526            space: params.space,
3527            source_agent,
3528            title: None,
3529            confidence: params.confidence,
3530            supersedes: params.supersedes,
3531            entity: params.entity,
3532            entity_id: None,
3533            structured_fields: params.structured_fields.map(serde_json::Value::Object),
3534            retrieval_cue: params.retrieval_cue,
3535        };
3536
3537        let json = serde_json::to_value(&req).unwrap();
3538        assert_eq!(json["content"], "Alice manages the frontend team");
3539        assert_eq!(json["memory_type"], "fact");
3540        assert_eq!(json["space"], "work");
3541        assert_eq!(json["entity"], "Alice");
3542        assert!(json["confidence"].as_f64().unwrap() > 0.89);
3543        // stdio mode: no param, no client_name → falls back to agent_name "claude"
3544        assert_eq!(json["source_agent"], "claude");
3545    }
3546
3547    #[test]
3548    fn test_remember_http_mode_injects_agent() {
3549        let server = make_server(TransportMode::Http, "claude.ai", Some("lucian"));
3550        let source_agent = server.resolve_source_agent(None);
3551
3552        assert_eq!(source_agent, Some("claude.ai".into()));
3553    }
3554
3555    // ===== Recall request construction =====
3556
3557    #[test]
3558    fn test_recall_constructs_search_request() {
3559        let params = RecallParams {
3560            query: "database choices".into(),
3561            limit: Some(5),
3562            memory_type: Some("decision".into()),
3563            space: None,
3564        };
3565
3566        let req = SearchMemoryRequest {
3567            query: params.query,
3568            limit: params.limit.unwrap_or(10),
3569            memory_type: params.memory_type,
3570            space: params.space,
3571            source_agent: None,
3572        };
3573
3574        let json = serde_json::to_value(&req).unwrap();
3575        assert_eq!(json["query"], "database choices");
3576        assert_eq!(json["limit"], 5);
3577        assert_eq!(json["memory_type"], "decision");
3578        assert!(json.get("entity").is_none());
3579        assert!(json["space"].is_null());
3580        assert!(json["source_agent"].is_null());
3581    }
3582
3583    // ===== Memory type pass-through =====
3584
3585    /// CaptureParams must pass every canonical memory_type through to the
3586    /// daemon verbatim. The MCP layer is dumb wire — it doesn't validate or
3587    /// rewrite the value; the daemon owns that. Drift test sourced from
3588    /// `MemoryType::all_values()` so adding a variant extends coverage
3589    /// automatically.
3590    #[test]
3591    fn test_capture_passes_through_all_canonical_types() {
3592        for t in origin_types::MemoryType::all_values() {
3593            let params = CaptureParams {
3594                content: "test".into(),
3595                memory_type: Some((*t).to_string()),
3596                space: None,
3597                entity: None,
3598                confidence: None,
3599                supersedes: None,
3600                structured_fields: None,
3601                retrieval_cue: None,
3602            };
3603            assert_eq!(params.memory_type.as_deref(), Some(*t));
3604        }
3605    }
3606
3607    /// Legacy "goal" alias still flows through the wire untouched —
3608    /// `MemoryType::FromStr` folds it to "identity" daemon-side. The MCP
3609    /// layer must not pre-reject it (the daemon owns the fold decision).
3610    #[test]
3611    fn test_capture_passes_through_legacy_goal_alias() {
3612        let params = CaptureParams {
3613            content: "test".into(),
3614            memory_type: Some("goal".into()),
3615            space: None,
3616            entity: None,
3617            confidence: None,
3618            supersedes: None,
3619            structured_fields: None,
3620            retrieval_cue: None,
3621        };
3622        assert_eq!(params.memory_type.as_deref(), Some("goal"));
3623    }
3624
3625    // ===== Structured fields in remember params =====
3626
3627    #[test]
3628    fn test_capture_params_with_structured_fields_and_cue() {
3629        let json = r#"{
3630            "content": "Lucian prefers dark mode",
3631            "structured_fields": {"theme":"dark"},
3632            "retrieval_cue": "What theme does Lucian prefer?"
3633        }"#;
3634        let params: CaptureParams = serde_json::from_str(json).unwrap();
3635        let structured_fields = params.structured_fields.expect("structured_fields");
3636        assert_eq!(
3637            structured_fields.get("theme"),
3638            Some(&serde_json::Value::String("dark".into()))
3639        );
3640        assert_eq!(
3641            params.retrieval_cue.as_deref(),
3642            Some("What theme does Lucian prefer?")
3643        );
3644    }
3645
3646    #[test]
3647    fn test_store_request_with_structured_fields() {
3648        let req = StoreMemoryRequest {
3649            content: "test".into(),
3650            memory_type: Some("fact".into()),
3651            space: None,
3652            source_agent: None,
3653            title: None,
3654            confidence: None,
3655            supersedes: None,
3656            entity: None,
3657            entity_id: None,
3658            structured_fields: Some(serde_json::json!({"key":"val"})),
3659            retrieval_cue: Some("What is the key?".into()),
3660        };
3661        let json = serde_json::to_value(&req).unwrap();
3662        assert_eq!(json["structured_fields"], serde_json::json!({"key":"val"}));
3663        assert_eq!(json["retrieval_cue"], "What is the key?");
3664    }
3665
3666    // ===== ChatContextResponse deserialization =====
3667
3668    #[test]
3669    fn test_chat_context_response() {
3670        let json = r#"{
3671            "context": "User prefers dark mode. Works on Origin project.",
3672            "profile": {
3673                "narrative": "narrative",
3674                "identity": [],
3675                "preferences": [],
3676                "goals": []
3677            },
3678            "knowledge": {
3679                "pages": [],
3680                "decisions": [],
3681                "relevant_memories": [],
3682                "graph_context": []
3683            },
3684            "took_ms": 12.5,
3685            "token_estimates": {
3686                "tier1_identity": 1,
3687                "tier2_project": 2,
3688                "tier3_relevant": 3,
3689                "total": 6
3690            }
3691        }"#;
3692        let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
3693        assert!(!resp.context.is_empty());
3694        assert!(resp.profile.identity.is_empty());
3695        assert_eq!(resp.took_ms, 12.5);
3696        assert_eq!(resp.token_estimates.total, 6);
3697    }
3698
3699    #[test]
3700    fn test_chat_context_response_empty() {
3701        let json = r#"{
3702            "context": "",
3703            "profile": {
3704                "narrative": "",
3705                "identity": [],
3706                "preferences": [],
3707                "goals": []
3708            },
3709            "knowledge": {
3710                "pages": [],
3711                "decisions": [],
3712                "relevant_memories": [],
3713                "graph_context": []
3714            },
3715            "took_ms": 1.0,
3716            "token_estimates": {
3717                "tier1_identity": 0,
3718                "tier2_project": 0,
3719                "tier3_relevant": 0,
3720                "total": 0
3721            }
3722        }"#;
3723        let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
3724        assert!(resp.context.is_empty());
3725    }
3726
3727    // ===== with_instructions content assertions =====
3728    // These tests lock in the refined agent-facing guidance. If any
3729    // assertion fails, either the rule was intentionally changed
3730    // (update the test) or the refinement was accidentally dropped
3731    // (restore the rule).
3732
3733    fn server_instructions() -> String {
3734        let s = make_server(TransportMode::Stdio, "test", None);
3735        s.get_info()
3736            .instructions
3737            .expect("server must ship with_instructions")
3738    }
3739
3740    #[test]
3741    fn instructions_mention_cumulative_knowledge() {
3742        assert!(
3743            server_instructions().contains("cumulative"),
3744            "with_instructions must describe Origin as cumulative"
3745        );
3746    }
3747
3748    #[test]
3749    fn instructions_mention_shared_across_tools() {
3750        assert!(
3751            server_instructions().contains("shared across all"),
3752            "with_instructions must tell agents the store is shared across tools"
3753        );
3754    }
3755
3756    #[test]
3757    fn instructions_mention_how_user_thinks() {
3758        assert!(
3759            server_instructions().contains("how the user thinks"),
3760            "with_instructions must frame context as modeling how the user thinks"
3761        );
3762    }
3763
3764    #[test]
3765    fn instructions_use_proactive_framing() {
3766        assert!(
3767            server_instructions().contains("STORE PROACTIVELY"),
3768            "with_instructions must use STORE PROACTIVELY framing (not passive WHEN TO STORE)"
3769        );
3770    }
3771
3772    #[test]
3773    fn instructions_ban_tool_output_storage() {
3774        assert!(
3775            server_instructions().contains("Tool output or command results"),
3776            "with_instructions must explicitly rule out tool output as storage material"
3777        );
3778    }
3779
3780    #[test]
3781    fn instructions_ban_ghost_inferences() {
3782        assert!(
3783            server_instructions().contains("Your own inferences"),
3784            "with_instructions must rule out storing agent's own inferences user didn't express"
3785        );
3786    }
3787
3788    #[test]
3789    fn instructions_call_out_atomic_memory() {
3790        assert!(
3791            server_instructions().contains("Atomic: one idea per memory"),
3792            "with_instructions must call out the atomic-memory rule explicitly by name"
3793        );
3794    }
3795
3796    #[test]
3797    fn instructions_specify_declarative_writing() {
3798        assert!(
3799            server_instructions().contains("Declarative, not narrative"),
3800            "with_instructions must require declarative (not narrative) writing style"
3801        );
3802    }
3803
3804    #[test]
3805    fn instructions_default_to_omit_memory_type() {
3806        let i = server_instructions();
3807        assert!(
3808            i.contains("omit and trust the backend"),
3809            "with_instructions must default agents to omitting memory_type"
3810        );
3811        assert!(
3812            i.contains("do NOT set memory_type"),
3813            "with_instructions must explicitly say do NOT set memory_type by default"
3814        );
3815    }
3816
3817    #[test]
3818    fn instructions_list_every_canonical_memory_type() {
3819        let i = server_instructions();
3820        for ty in origin_types::MemoryType::all_values() {
3821            assert!(
3822                contains_word(&i, ty),
3823                "with_instructions must list canonical memory type \"{ty}\" so MCP clients see the full vocabulary",
3824            );
3825        }
3826    }
3827
3828    #[test]
3829    fn instructions_omit_legacy_goal_type() {
3830        let i = server_instructions();
3831        // "goal" (singular) is a legacy memory_type folded to Identity by
3832        // MemoryType::FromStr. The plural English noun "goals" (life goals,
3833        // profile.goals chat-context field) is a separate concern and must
3834        // NOT trigger this test — tokenizing on word boundaries lets one
3835        // through while still catching the legacy memory-type token.
3836        assert!(
3837            !contains_word(&i, "goal"),
3838            "with_instructions must not advertise legacy \"goal\" memory_type"
3839        );
3840    }
3841
3842    /// Tokenize on non-alphanumeric boundaries and check whether `needle`
3843    /// appears as a standalone token. Mirrors the helper used by the
3844    /// origin-types drift tests so "goals" (plural noun) does not false-match
3845    /// the legacy "goal" memory_type token.
3846    fn contains_word(haystack: &str, needle: &str) -> bool {
3847        haystack
3848            .split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
3849            .any(|tok| tok == needle)
3850    }
3851
3852    #[test]
3853    fn instructions_carve_out_decisions_for_decision_log() {
3854        let i = server_instructions();
3855        assert!(
3856            i.contains("Decision Log"),
3857            "with_instructions must name the Decision Log as the reason for explicit decision typing"
3858        );
3859        assert!(
3860            i.contains("memory_type=\"decision\""),
3861            "with_instructions must tell agents to set memory_type=\"decision\" explicitly for decisions"
3862        );
3863    }
3864
3865    // ===== tool-level and param-level description assertions =====
3866
3867    fn tool_descriptions() -> std::collections::HashMap<String, String> {
3868        let server = make_server(TransportMode::Stdio, "test", None);
3869        server
3870            .tool_router
3871            .list_all()
3872            .into_iter()
3873            .filter_map(|t| {
3874                let desc = t.description.as_ref()?.to_string();
3875                Some((t.name.to_string(), desc))
3876            })
3877            .collect()
3878    }
3879
3880    #[test]
3881    fn capture_description_calls_out_atomic() {
3882        let descriptions = tool_descriptions();
3883        let capture = descriptions.get("capture").expect("capture tool exists");
3884        assert!(
3885            capture.contains("Each call is one atomic idea"),
3886            "capture description must call out atomic-per-call explicitly, got: {capture}"
3887        );
3888    }
3889
3890    #[test]
3891    fn context_description_frames_modeling_user() {
3892        let descriptions = tool_descriptions();
3893        let ctx = descriptions.get("context").expect("context tool exists");
3894        assert!(
3895            ctx.contains("how the user thinks"),
3896            "context description must frame the result as modeling how the user thinks, got: {ctx}"
3897        );
3898    }
3899
3900    #[test]
3901    fn doctor_description_mentions_setup_mode() {
3902        let descriptions = tool_descriptions();
3903        let status = descriptions.get("doctor").expect("doctor tool exists");
3904        assert!(
3905            status.contains("Local Memory"),
3906            "doctor description must mention setup modes, got: {status}"
3907        );
3908        assert!(
3909            status.contains("On-device Model"),
3910            "doctor description must mention on-device setup, got: {status}"
3911        );
3912        assert!(
3913            status.contains("not part of the memory loop"),
3914            "doctor description must frame itself as diagnostic-only, got: {status}"
3915        );
3916    }
3917
3918    #[test]
3919    fn recall_memory_type_param_lists_two_level_filter() {
3920        let params_schema = serde_json::to_string(&schemars::schema_for!(RecallParams))
3921            .expect("RecallParams schema serializes");
3922        assert!(
3923            params_schema.contains("Two-level filter"),
3924            "RecallParams.memory_type must advertise the two-level filter, got schema: {params_schema}"
3925        );
3926        assert!(
3927            params_schema.contains("profile"),
3928            "RecallParams.memory_type must mention profile alias"
3929        );
3930        assert!(
3931            params_schema.contains("knowledge"),
3932            "RecallParams.memory_type must mention knowledge alias"
3933        );
3934    }
3935
3936    // ===== Knowledge graph / page CRUD =====
3937
3938    // --- CreateEntityParams ---
3939
3940    #[test]
3941    fn test_create_entity_params_minimal() {
3942        let json = r#"{"name": "Alice", "entity_type": "person"}"#;
3943        let params: CreateEntityParams = serde_json::from_str(json).unwrap();
3944        assert_eq!(params.name, "Alice");
3945        assert_eq!(params.entity_type, "person");
3946        assert!(params.space.is_none());
3947        assert!(params.confidence.is_none());
3948    }
3949
3950    #[test]
3951    fn test_create_entity_params_full() {
3952        let json = r#"{
3953            "name": "PostgreSQL",
3954            "entity_type": "tool",
3955            "space": "origin",
3956            "confidence": 0.9
3957        }"#;
3958        let params: CreateEntityParams = serde_json::from_str(json).unwrap();
3959        assert_eq!(params.name, "PostgreSQL");
3960        assert_eq!(params.entity_type, "tool");
3961        assert_eq!(params.space.as_deref(), Some("origin"));
3962        assert_eq!(params.confidence, Some(0.9));
3963    }
3964
3965    #[test]
3966    fn test_create_entity_params_missing_name_fails() {
3967        let json = r#"{"entity_type": "person"}"#;
3968        let result = serde_json::from_str::<CreateEntityParams>(json);
3969        assert!(result.is_err());
3970    }
3971
3972    #[test]
3973    fn test_create_entity_params_missing_type_fails() {
3974        let json = r#"{"name": "Alice"}"#;
3975        let result = serde_json::from_str::<CreateEntityParams>(json);
3976        assert!(result.is_err());
3977    }
3978
3979    #[test]
3980    fn test_create_entity_request_body_shape() {
3981        let server = make_server(TransportMode::Stdio, "claude", None);
3982        let params = CreateEntityParams {
3983            name: "Origin".into(),
3984            entity_type: "project".into(),
3985            space: Some("origin".into()),
3986            confidence: Some(0.95),
3987        };
3988        let source_agent = server.resolve_source_agent(None);
3989        let req = CreateEntityRequest {
3990            name: params.name,
3991            entity_type: params.entity_type,
3992            space: params.space,
3993            source_agent,
3994            confidence: params.confidence,
3995        };
3996        let json = serde_json::to_value(&req).unwrap();
3997        assert_eq!(json["name"], "Origin");
3998        assert_eq!(json["entity_type"], "project");
3999        assert_eq!(json["space"], "origin");
4000        assert_eq!(json["source_agent"], "claude");
4001        assert!(json["confidence"].as_f64().unwrap() > 0.94);
4002    }
4003
4004    // --- CreateRelationParams ---
4005
4006    #[test]
4007    fn test_create_relation_params() {
4008        let json = r#"{
4009            "from_entity": "Alice",
4010            "to_entity": "Origin",
4011            "relation_type": "works_on"
4012        }"#;
4013        let params: CreateRelationParams = serde_json::from_str(json).unwrap();
4014        assert_eq!(params.from_entity, "Alice");
4015        assert_eq!(params.to_entity, "Origin");
4016        assert_eq!(params.relation_type, "works_on");
4017    }
4018
4019    #[test]
4020    fn test_create_relation_params_missing_field_fails() {
4021        let json = r#"{"from_entity": "Alice", "to_entity": "Origin"}"#;
4022        let result = serde_json::from_str::<CreateRelationParams>(json);
4023        assert!(result.is_err());
4024    }
4025
4026    #[test]
4027    fn test_create_relation_request_body_shape() {
4028        let server = make_server(TransportMode::Stdio, "claude", None);
4029        let params = CreateRelationParams {
4030            from_entity: "Alice".into(),
4031            to_entity: "Origin".into(),
4032            relation_type: "prefers".into(),
4033        };
4034        let source_agent = server.resolve_source_agent(None);
4035        let req = CreateRelationRequest {
4036            from_entity: params.from_entity,
4037            to_entity: params.to_entity,
4038            relation_type: params.relation_type,
4039            source_agent,
4040            confidence: None,
4041            explanation: None,
4042            source_memory_id: None,
4043        };
4044        let json = serde_json::to_value(&req).unwrap();
4045        assert_eq!(json["from_entity"], "Alice");
4046        assert_eq!(json["to_entity"], "Origin");
4047        assert_eq!(json["relation_type"], "prefers");
4048        assert_eq!(json["source_agent"], "claude");
4049    }
4050
4051    // --- CreatePageParams ---
4052
4053    #[test]
4054    fn test_create_page_params_minimal() {
4055        let json = r#"{"title": "Origin daemon", "content": "Body text."}"#;
4056        let params: CreatePageParams = serde_json::from_str(json).unwrap();
4057        assert_eq!(params.title, "Origin daemon");
4058        assert_eq!(params.content, "Body text.");
4059        assert!(params.summary.is_none());
4060        assert!(params.entity_id.is_none());
4061        assert!(params.space.is_none());
4062        assert!(params.source_memory_ids.is_empty());
4063    }
4064
4065    #[test]
4066    fn test_create_page_params_full() {
4067        let json = r##"{
4068            "title": "Origin daemon",
4069            "content": "Markdown body with [[wikilinks]].",
4070            "summary": "The headless HTTP daemon at the heart of Origin.",
4071            "entity_id": "ent_origin",
4072            "space": "origin",
4073            "source_memory_ids": ["mem_1", "mem_2"]
4074        }"##;
4075        let params: CreatePageParams = serde_json::from_str(json).unwrap();
4076        assert_eq!(params.title, "Origin daemon");
4077        assert_eq!(
4078            params.summary.as_deref(),
4079            Some("The headless HTTP daemon at the heart of Origin.")
4080        );
4081        assert_eq!(params.entity_id.as_deref(), Some("ent_origin"));
4082        assert_eq!(params.space.as_deref(), Some("origin"));
4083        assert_eq!(params.source_memory_ids, vec!["mem_1", "mem_2"]);
4084    }
4085
4086    #[test]
4087    fn test_create_page_params_missing_required_fails() {
4088        let json = r#"{"title": "Only title"}"#;
4089        let result = serde_json::from_str::<CreatePageParams>(json);
4090        assert!(result.is_err());
4091    }
4092
4093    #[test]
4094    fn test_create_page_request_body_shape() {
4095        let params = CreatePageParams {
4096            title: "Page".into(),
4097            content: "Body".into(),
4098            summary: Some("S".into()),
4099            entity_id: Some("ent_1".into()),
4100            space: Some("origin".into()),
4101            source_memory_ids: vec!["mem_1".into()],
4102        };
4103        let req = CreateConceptRequest {
4104            title: params.title,
4105            content: params.content,
4106            summary: params.summary,
4107            entity_id: params.entity_id,
4108            space: params.space,
4109            source_memory_ids: params.source_memory_ids,
4110        };
4111        let json = serde_json::to_value(&req).unwrap();
4112        assert_eq!(json["title"], "Page");
4113        assert_eq!(json["content"], "Body");
4114        assert_eq!(json["summary"], "S");
4115        assert_eq!(json["entity_id"], "ent_1");
4116        assert_eq!(json["space"], "origin");
4117        assert_eq!(json["source_memory_ids"], serde_json::json!(["mem_1"]));
4118    }
4119
4120    // --- DeletePageParams ---
4121
4122    #[test]
4123    fn test_delete_page_params() {
4124        let json = r#"{"page_id": "page_abc"}"#;
4125        let params: DeletePageParams = serde_json::from_str(json).unwrap();
4126        assert_eq!(params.page_id, "page_abc");
4127    }
4128
4129    #[test]
4130    fn test_delete_page_params_missing_fails() {
4131        let json = r#"{}"#;
4132        let result = serde_json::from_str::<DeletePageParams>(json);
4133        assert!(result.is_err());
4134    }
4135
4136    #[tokio::test]
4137    async fn test_delete_page_blocked_on_http_transport() {
4138        let server = make_server(TransportMode::Http, "agent", None);
4139        let result = server.delete_page_impl("page_123").await.unwrap();
4140        let content = &result.content[0];
4141        match content.raw {
4142            rmcp::model::RawContent::Text(ref tc) => {
4143                assert!(tc.text.contains("not available over remote connections"));
4144            }
4145            _ => panic!("expected text content"),
4146        }
4147    }
4148
4149    #[tokio::test]
4150    async fn test_delete_page_allowed_on_stdio_transport() {
4151        // No daemon running → falls through to connection error (not transport block).
4152        let server = make_server(TransportMode::Stdio, "agent", None);
4153        let result = server.delete_page_impl("page_123").await.unwrap();
4154        assert!(
4155            result.is_error.unwrap_or(false),
4156            "should fail with connection error, not transport block"
4157        );
4158    }
4159
4160    #[tokio::test]
4161    async fn delete_observation_refuses_http_transport() {
4162        let server = make_server(TransportMode::Http, "agent", None);
4163        let params = DeleteObservationParams {
4164            observation_id: "obs_123".to_string(),
4165        };
4166        let result = server.delete_observation_impl(params).await.unwrap();
4167        let content = &result.content[0];
4168        match content.raw {
4169            rmcp::model::RawContent::Text(ref tc) => {
4170                assert!(tc.text.contains("not available over remote connections"));
4171            }
4172            _ => panic!("expected text content"),
4173        }
4174    }
4175
4176    // --- GetPageParams ---
4177
4178    #[test]
4179    fn test_get_page_params() {
4180        let json = r#"{"page_id": "page_abc"}"#;
4181        let params: GetPageParams = serde_json::from_str(json).unwrap();
4182        assert_eq!(params.page_id, "page_abc");
4183    }
4184
4185    #[test]
4186    fn test_get_page_params_missing_fails() {
4187        let json = r#"{}"#;
4188        let result = serde_json::from_str::<GetPageParams>(json);
4189        assert!(result.is_err());
4190    }
4191
4192    // --- ListMemoriesParams ---
4193
4194    #[test]
4195    fn test_list_memories_params_empty() {
4196        let json = r#"{}"#;
4197        let params: ListMemoriesParams = serde_json::from_str(json).unwrap();
4198        assert!(params.memory_type.is_none());
4199        assert!(params.space.is_none());
4200        assert!(params.limit.is_none());
4201    }
4202
4203    #[test]
4204    fn test_list_memories_params_full() {
4205        let json = r#"{"memory_type": "decision", "space": "origin", "limit": 50}"#;
4206        let params: ListMemoriesParams = serde_json::from_str(json).unwrap();
4207        assert_eq!(params.memory_type.as_deref(), Some("decision"));
4208        assert_eq!(params.space.as_deref(), Some("origin"));
4209        assert_eq!(params.limit, Some(50));
4210    }
4211
4212    #[test]
4213    fn test_list_memories_params_limit_as_string() {
4214        // MCP clients sometimes serialize numeric params as strings.
4215        let json = r#"{"limit": "25"}"#;
4216        let params: ListMemoriesParams = serde_json::from_str(json).unwrap();
4217        assert_eq!(params.limit, Some(25));
4218    }
4219
4220    #[test]
4221    fn test_list_memories_request_body_shape() {
4222        let params = ListMemoriesParams {
4223            memory_type: Some("fact".into()),
4224            space: None,
4225            limit: Some(10),
4226        };
4227        let req = ListMemoriesRequest {
4228            memory_type: params.memory_type,
4229            space: params.space,
4230            limit: params.limit.unwrap_or(100),
4231            confirmed: None,
4232        };
4233        let json = serde_json::to_value(&req).unwrap();
4234        assert_eq!(json["memory_type"], "fact");
4235        assert!(json["space"].is_null());
4236        assert_eq!(json["limit"], 10);
4237    }
4238
4239    #[test]
4240    fn test_list_memories_request_default_limit() {
4241        let params = ListMemoriesParams {
4242            memory_type: None,
4243            space: None,
4244            limit: None,
4245        };
4246        let req = ListMemoriesRequest {
4247            memory_type: params.memory_type,
4248            space: params.space,
4249            limit: params.limit.unwrap_or(100),
4250            confirmed: None,
4251        };
4252        assert_eq!(req.limit, 100);
4253    }
4254
4255    // --- UpdatePageParams ---
4256
4257    #[test]
4258    fn test_update_page_params_minimal() {
4259        let json =
4260            r#"{"page_id": "page_abc", "content": "fresh body", "source_memory_ids": ["mem_1"]}"#;
4261        let params: UpdatePageParams = serde_json::from_str(json).unwrap();
4262        assert_eq!(params.page_id, "page_abc");
4263        assert_eq!(params.content, "fresh body");
4264        assert_eq!(params.source_memory_ids, vec!["mem_1"]);
4265        assert!(params.summary.is_none());
4266    }
4267
4268    #[test]
4269    fn test_update_page_params_with_summary() {
4270        let json = r#"{
4271            "page_id": "page_abc",
4272            "content": "body",
4273            "source_memory_ids": ["mem_1", "mem_2"],
4274            "summary": "Refreshed claim."
4275        }"#;
4276        let params: UpdatePageParams = serde_json::from_str(json).unwrap();
4277        assert_eq!(params.summary.as_deref(), Some("Refreshed claim."));
4278        assert_eq!(params.source_memory_ids.len(), 2);
4279    }
4280
4281    #[test]
4282    fn test_update_page_params_missing_required_fails() {
4283        // Missing source_memory_ids is a hard fail — refresh without sources
4284        // would orphan the page from its provenance trail.
4285        let json = r#"{"page_id": "page_abc", "content": "body"}"#;
4286        let result = serde_json::from_str::<UpdatePageParams>(json);
4287        assert!(result.is_err());
4288    }
4289
4290    #[test]
4291    fn test_update_page_request_body_shape() {
4292        let params = UpdatePageParams {
4293            page_id: "page_abc".into(),
4294            content: "Body".into(),
4295            source_memory_ids: vec!["mem_1".into()],
4296            summary: Some("S".into()),
4297        };
4298        let req = origin_types::requests::RefreshPageRequest {
4299            content: params.content,
4300            source_memory_ids: params.source_memory_ids,
4301            summary: params.summary,
4302        };
4303        let json = serde_json::to_value(&req).unwrap();
4304        assert_eq!(json["content"], "Body");
4305        assert_eq!(json["source_memory_ids"], serde_json::json!(["mem_1"]));
4306        assert_eq!(json["summary"], "S");
4307        // page_id stays in the URL, never the body.
4308        assert!(json.get("page_id").is_none());
4309    }
4310
4311    // --- Tool registration ---
4312
4313    #[test]
4314    fn new_crud_tools_are_registered() {
4315        let descriptions = tool_descriptions();
4316        for name in [
4317            "create_entity",
4318            "create_relation",
4319            "create_observation",
4320            "confirm_entity",
4321            "update_observation",
4322            "confirm_observation",
4323            "delete_observation",
4324            "create_page",
4325            "update_page",
4326            "delete_page",
4327            "get_page",
4328            "get_page_links",
4329            "list_memories",
4330            "search_pages",
4331            "list_pages_recent",
4332            "list_spaces",
4333        ] {
4334            assert!(
4335                descriptions.contains_key(name),
4336                "tool `{name}` must be registered, got: {:?}",
4337                descriptions.keys().collect::<Vec<_>>()
4338            );
4339        }
4340    }
4341
4342    #[test]
4343    fn capture_memory_type_schema_lists_every_canonical_type() {
4344        let params_schema = serde_json::to_string(&schemars::schema_for!(CaptureParams))
4345            .expect("CaptureParams schema serializes");
4346        for ty in origin_types::MemoryType::all_values() {
4347            assert!(
4348                params_schema.contains(ty),
4349                "CaptureParams.memory_type schema must list canonical type \"{ty}\", got: {params_schema}"
4350            );
4351        }
4352    }
4353
4354    #[test]
4355    fn recall_memory_type_schema_lists_every_canonical_type() {
4356        let params_schema = serde_json::to_string(&schemars::schema_for!(RecallParams))
4357            .expect("RecallParams schema serializes");
4358        for ty in origin_types::MemoryType::all_values() {
4359            assert!(
4360                params_schema.contains(ty),
4361                "RecallParams.memory_type schema must list canonical type \"{ty}\", got: {params_schema}"
4362            );
4363        }
4364    }
4365
4366    #[test]
4367    fn create_entity_schema_documents_name_and_type() {
4368        let schema = serde_json::to_string(&schemars::schema_for!(CreateEntityParams))
4369            .expect("CreateEntityParams schema serializes");
4370        assert!(
4371            schema.contains("Canonical entity name"),
4372            "schema must describe `name` field"
4373        );
4374        assert!(
4375            schema.contains("Entity category"),
4376            "schema must describe `entity_type` field"
4377        );
4378    }
4379
4380    #[test]
4381    fn create_page_schema_documents_traceability() {
4382        let schema = serde_json::to_string(&schemars::schema_for!(CreatePageParams))
4383            .expect("CreatePageParams schema serializes");
4384        assert!(
4385            schema.contains("traceability"),
4386            "schema must spell out why source_memory_ids matter"
4387        );
4388    }
4389
4390    #[test]
4391    fn delete_page_tool_is_marked_destructive() {
4392        let server = make_server(TransportMode::Stdio, "test", None);
4393        let tool = server
4394            .tool_router
4395            .list_all()
4396            .into_iter()
4397            .find(|t| t.name == "delete_page")
4398            .expect("delete_page registered");
4399        let ann = tool.annotations.as_ref().expect("annotations present");
4400        assert_eq!(
4401            ann.destructive_hint,
4402            Some(true),
4403            "delete_page must declare destructive_hint=true"
4404        );
4405    }
4406
4407    // --- SearchPagesParams ---
4408
4409    #[test]
4410    fn test_search_pages_params_minimal() {
4411        let json = r#"{"query": "mutex deadlock"}"#;
4412        let params: SearchPagesParams = serde_json::from_str(json).unwrap();
4413        assert_eq!(params.query, "mutex deadlock");
4414        assert!(params.limit.is_none());
4415    }
4416
4417    #[test]
4418    fn test_search_pages_params_full() {
4419        let json = r#"{"query": "distill architecture", "limit": 5}"#;
4420        let params: SearchPagesParams = serde_json::from_str(json).unwrap();
4421        assert_eq!(params.query, "distill architecture");
4422        assert_eq!(params.limit, Some(5));
4423    }
4424
4425    #[test]
4426    fn test_search_pages_params_missing_query_fails() {
4427        let json = r#"{"limit": 10}"#;
4428        let result = serde_json::from_str::<SearchPagesParams>(json);
4429        assert!(result.is_err());
4430    }
4431
4432    #[test]
4433    fn test_search_pages_params_limit_as_string() {
4434        let json = r#"{"query": "x", "limit": "3"}"#;
4435        let params: SearchPagesParams = serde_json::from_str(json).unwrap();
4436        assert_eq!(params.limit, Some(3));
4437    }
4438
4439    #[test]
4440    fn test_search_pages_request_body_shape() {
4441        let params = SearchPagesParams {
4442            query: "mutex".into(),
4443            limit: Some(7),
4444            page_type: None,
4445        };
4446        let req = SearchPagesRequest {
4447            query: params.query,
4448            limit: params.limit,
4449            page_type: params.page_type,
4450        };
4451        let json = serde_json::to_value(&req).unwrap();
4452        assert_eq!(json["query"], "mutex");
4453        assert_eq!(json["limit"], 7);
4454    }
4455
4456    // --- ListPagesRecentParams ---
4457
4458    #[test]
4459    fn test_list_pages_recent_params_empty() {
4460        let json = r#"{}"#;
4461        let params: ListPagesRecentParams = serde_json::from_str(json).unwrap();
4462        assert!(params.limit.is_none());
4463        assert!(params.since_ms.is_none());
4464    }
4465
4466    #[test]
4467    fn test_list_pages_recent_params_full() {
4468        let json = r#"{"limit": 20, "since_ms": 1715000000000}"#;
4469        let params: ListPagesRecentParams = serde_json::from_str(json).unwrap();
4470        assert_eq!(params.limit, Some(20));
4471        assert_eq!(params.since_ms, Some(1715000000000));
4472    }
4473
4474    #[test]
4475    fn test_list_pages_recent_params_string_numbers() {
4476        let json = r#"{"limit": "15", "since_ms": "1715000000000"}"#;
4477        let params: ListPagesRecentParams = serde_json::from_str(json).unwrap();
4478        assert_eq!(params.limit, Some(15));
4479        assert_eq!(params.since_ms, Some(1715000000000));
4480    }
4481
4482    #[test]
4483    fn list_pages_recent_url_construction() {
4484        // Exercises the actual builder used by `list_pages_recent_impl` so the
4485        // test cannot drift from production behavior.
4486        assert_eq!(build_recent_pages_path(None, None), "/api/pages/recent");
4487        assert_eq!(
4488            build_recent_pages_path(Some(5), None),
4489            "/api/pages/recent?limit=5"
4490        );
4491        assert_eq!(
4492            build_recent_pages_path(None, Some(123)),
4493            "/api/pages/recent?since_ms=123"
4494        );
4495        assert_eq!(
4496            build_recent_pages_path(Some(10), Some(456)),
4497            "/api/pages/recent?limit=10&since_ms=456"
4498        );
4499        // Negative since_ms (i64 — sentinel like "-1" must still serialize).
4500        assert_eq!(
4501            build_recent_pages_path(None, Some(-1)),
4502            "/api/pages/recent?since_ms=-1"
4503        );
4504    }
4505
4506    #[test]
4507    fn search_pages_and_list_pages_recent_are_read_only() {
4508        let server = make_server(TransportMode::Stdio, "test", None);
4509        for name in ["search_pages", "list_pages_recent"] {
4510            let tool = server
4511                .tool_router
4512                .list_all()
4513                .into_iter()
4514                .find(|t| t.name == name)
4515                .unwrap_or_else(|| panic!("`{name}` registered"));
4516            let ann = tool.annotations.as_ref().expect("annotations present");
4517            assert_eq!(
4518                ann.read_only_hint,
4519                Some(true),
4520                "`{name}` must declare read_only_hint=true"
4521            );
4522        }
4523    }
4524
4525    #[test]
4526    fn accept_refinement_response_typed_deserialize() {
4527        let raw = r#"{"id":"ref_xyz","action_applied":"entity_merge"}"#;
4528        let parsed: AcceptRefinementResponse = serde_json::from_str(raw).unwrap();
4529        assert_eq!(parsed.id, "ref_xyz");
4530        assert_eq!(parsed.action_applied, "entity_merge");
4531    }
4532
4533    #[test]
4534    fn accept_refinement_response_rejects_extra_envelope() {
4535        // Daemon must not wrap successful response under an extra key — the
4536        // lesson_mcp_typed_deserialize guard. This test verifies a non-typed
4537        // shape fails to deserialize loud.
4538        let wrong = r#"{"data":{"id":"ref_xyz","action_applied":"entity_merge"}}"#;
4539        let result: Result<AcceptRefinementResponse, _> = serde_json::from_str(wrong);
4540        assert!(
4541            result.is_err(),
4542            "envelope-wrapped response must fail typed deserialize"
4543        );
4544    }
4545
4546    // ===== DistillParams force field =====
4547
4548    #[test]
4549    fn distill_params_deserializes_force() {
4550        let p: DistillParams =
4551            serde_json::from_str(r#"{"target":"page_xyz","force":true}"#).unwrap();
4552        assert_eq!(p.target.as_deref(), Some("page_xyz"));
4553        assert_eq!(p.force, Some(true));
4554    }
4555
4556    #[test]
4557    fn distill_params_defaults_force_to_none() {
4558        let p: DistillParams = serde_json::from_str(r#"{"target":"foo"}"#).unwrap();
4559        assert_eq!(p.force, None);
4560    }
4561}