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