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        };
1141        let resp: CreatePageResponse = match self.client.post("/api/pages", &req).await {
1142            Ok(r) => r,
1143            Err(e) => return Ok(tool_error(e, "create_page")),
1144        };
1145        let mut text = format!("Created page {}", resp.id);
1146        for w in &resp.warnings {
1147            text.push_str(&format!("\nwarning: {w}"));
1148        }
1149        Ok(CallToolResult::success(vec![Content::text(text)]))
1150    }
1151
1152    pub async fn update_page_impl(
1153        &self,
1154        params: UpdatePageParams,
1155    ) -> Result<CallToolResult, McpError> {
1156        if self.transport == TransportMode::Http {
1157            return Ok(CallToolResult::error(vec![Content::text(
1158                "Update operations are not available over remote connections. \
1159                 Use local MCP on the machine running Origin to update pages."
1160                    .to_string(),
1161            )]));
1162        }
1163        let req = origin_types::requests::RefreshPageRequest {
1164            content: params.content,
1165            source_memory_ids: params.source_memory_ids,
1166            summary: params.summary,
1167        };
1168        let path = format!("/api/pages/{}", params.page_id);
1169        // Typed end-to-end: a wire-shape drift on the daemon side fails at
1170        // deserialize instead of silently returning the no-op "Refreshed"
1171        // line. Same discipline as PR #77's search_pages / list_pages_recent.
1172        let _: origin_types::responses::SuccessResponse = match self.client.put(&path, &req).await {
1173            Ok(r) => r,
1174            Err(e) => return Ok(tool_error(e, "update_page")),
1175        };
1176        Ok(CallToolResult::success(vec![Content::text(format!(
1177            "Refreshed page {}",
1178            params.page_id
1179        ))]))
1180    }
1181
1182    pub async fn delete_page_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
1183        if self.transport == TransportMode::Http {
1184            return Ok(CallToolResult::error(vec![Content::text(
1185                "Delete operations are not available over remote connections. \
1186                 Use local MCP on the machine running Origin to delete pages."
1187                    .to_string(),
1188            )]));
1189        }
1190
1191        let path = format!("/api/pages/{}", page_id);
1192        let resp: serde_json::Value = match self.client.delete(&path).await {
1193            Ok(r) => r,
1194            Err(e) => return Ok(tool_error(e, "delete_page")),
1195        };
1196        let status = resp
1197            .get("status")
1198            .and_then(|v| v.as_str())
1199            .unwrap_or("deleted");
1200        Ok(CallToolResult::success(vec![Content::text(format!(
1201            "Page {} {}",
1202            page_id, status
1203        ))]))
1204    }
1205
1206    pub async fn get_page_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
1207        let path = format!("/api/pages/{}", page_id);
1208        let resp: serde_json::Value = match self.client.get(&path).await {
1209            Ok(r) => r,
1210            Err(e) => return Ok(tool_error(e, "get_page")),
1211        };
1212        let pretty = serde_json::to_string_pretty(&resp).unwrap_or_else(|_| resp.to_string());
1213        Ok(CallToolResult::success(vec![Content::text(pretty)]))
1214    }
1215
1216    pub async fn get_page_links_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
1217        let path = format!("/api/pages/{}/links", page_id);
1218        // Typed end-to-end via PageLinksResponse — keeps wire shape pinned.
1219        let resp: origin_types::responses::PageLinksResponse = match self.client.get(&path).await {
1220            Ok(r) => r,
1221            Err(e) => return Ok(tool_error(e, "get_page_links")),
1222        };
1223        let pretty = serde_json::to_string_pretty(&resp).unwrap_or_else(|_| String::new());
1224        Ok(CallToolResult::success(vec![Content::text(pretty)]))
1225    }
1226
1227    pub async fn get_page_sources_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
1228        let path = format!("/api/pages/{}/sources", page_id);
1229        // Daemon returns Vec<PageSourceWithMemory> directly (no envelope key).
1230        let resp: Vec<PageSourceWithMemory> = match self.client.get(&path).await {
1231            Ok(r) => r,
1232            Err(e) => return Ok(tool_error(e, "get_page_sources")),
1233        };
1234        let pretty = serde_json::to_string_pretty(&resp)
1235            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1236        Ok(CallToolResult::success(vec![Content::text(format!(
1237            "{} sources\n{}",
1238            resp.len(),
1239            pretty
1240        ))]))
1241    }
1242
1243    pub async fn get_memory_revisions_impl(
1244        &self,
1245        memory_id: &str,
1246    ) -> Result<CallToolResult, McpError> {
1247        let path = format!("/api/memory/{}/revisions", memory_id);
1248        let resp: ListMemoryRevisionsResponse = match self.client.get(&path).await {
1249            Ok(r) => r,
1250            Err(e) => return Ok(tool_error(e, "get_memory_revisions")),
1251        };
1252        let pretty = serde_json::to_string_pretty(&resp)
1253            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1254        Ok(CallToolResult::success(vec![Content::text(format!(
1255            "chain depth {}\n{}",
1256            resp.chain_depth, pretty
1257        ))]))
1258    }
1259
1260    pub async fn get_page_revisions_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
1261        let path = format!("/api/pages/{}/revisions", page_id);
1262        let resp: ListPageRevisionsResponse = match self.client.get(&path).await {
1263            Ok(r) => r,
1264            Err(e) => return Ok(tool_error(e, "get_page_revisions")),
1265        };
1266        let pretty = serde_json::to_string_pretty(&resp)
1267            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1268        Ok(CallToolResult::success(vec![Content::text(format!(
1269            "version {} ({} entries)\n{}",
1270            resp.current_version,
1271            resp.entries.len(),
1272            pretty
1273        ))]))
1274    }
1275
1276    pub async fn list_memories_impl(
1277        &self,
1278        params: ListMemoriesParams,
1279    ) -> Result<CallToolResult, McpError> {
1280        let space_arg = effective_space(&params.space);
1281        let req = ListMemoriesRequest {
1282            memory_type: params.memory_type,
1283            space: space_arg,
1284            limit: params.limit.unwrap_or(100),
1285            confirmed: None,
1286        };
1287        let resp: ListMemoriesResponse = match self.client.post("/api/memory/list", &req).await {
1288            Ok(r) => r,
1289            Err(e) => return Ok(tool_error(e, "list_memories")),
1290        };
1291        let pretty = serde_json::to_string_pretty(&resp.memories)
1292            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1293        Ok(CallToolResult::success(vec![Content::text(format!(
1294            "{} memories\n{}",
1295            resp.memories.len(),
1296            pretty
1297        ))]))
1298    }
1299
1300    pub async fn search_pages_impl(
1301        &self,
1302        params: SearchPagesParams,
1303    ) -> Result<CallToolResult, McpError> {
1304        let req = SearchPagesRequest {
1305            query: params.query,
1306            limit: params.limit,
1307            page_type: params.page_type,
1308        };
1309        let resp: SearchPagesResponse = match self.client.post("/api/pages/search", &req).await {
1310            Ok(r) => r,
1311            Err(e) => return Ok(tool_error(e, "search_pages")),
1312        };
1313        let pretty = serde_json::to_string_pretty(&resp.pages)
1314            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1315        Ok(CallToolResult::success(vec![Content::text(format!(
1316            "{} pages\n{}",
1317            resp.pages.len(),
1318            pretty
1319        ))]))
1320    }
1321
1322    pub async fn list_pages_recent_impl(
1323        &self,
1324        params: ListPagesRecentParams,
1325    ) -> Result<CallToolResult, McpError> {
1326        let path = build_recent_pages_path(params.limit, params.since_ms);
1327        let resp: Vec<RecentActivityItem> = match self.client.get(&path).await {
1328            Ok(r) => r,
1329            Err(e) => return Ok(tool_error(e, "list_pages_recent")),
1330        };
1331        let pretty = serde_json::to_string_pretty(&resp)
1332            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1333        Ok(CallToolResult::success(vec![Content::text(format!(
1334            "{} recent pages\n{}",
1335            resp.len(),
1336            pretty
1337        ))]))
1338    }
1339
1340    pub async fn list_spaces_impl(
1341        &self,
1342        _params: ListSpacesParams,
1343    ) -> Result<CallToolResult, McpError> {
1344        let resp: Vec<Space> = match self.client.get("/api/spaces").await {
1345            Ok(r) => r,
1346            Err(e) => return Ok(tool_error(e, "list_spaces")),
1347        };
1348        let pretty = serde_json::to_string_pretty(&resp)
1349            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1350        Ok(CallToolResult::success(vec![Content::text(format!(
1351            "{} spaces\n{}",
1352            resp.len(),
1353            pretty
1354        ))]))
1355    }
1356
1357    pub async fn list_refinements_impl(
1358        &self,
1359        params: ListRefinementsParams,
1360    ) -> Result<CallToolResult, McpError> {
1361        let mut path = String::from("/api/refinery/queue");
1362        let mut q: Vec<String> = Vec::new();
1363        if let Some(a) = params.action.as_deref() {
1364            q.push(format!("action={}", url_encode_simple(a)));
1365        }
1366        if let Some(l) = params.limit {
1367            q.push(format!("limit={l}"));
1368        }
1369        if !q.is_empty() {
1370            path.push('?');
1371            path.push_str(&q.join("&"));
1372        }
1373
1374        let resp: ListRefinementsResponse = match self.client.get(&path).await {
1375            Ok(v) => v,
1376            Err(e) => return Ok(tool_error(e, "list_refinements")),
1377        };
1378
1379        let pretty = serde_json::to_string_pretty(&resp.proposals)
1380            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1381        Ok(CallToolResult::success(vec![Content::text(format!(
1382            "{} pending review proposals\n{}",
1383            resp.proposals.len(),
1384            pretty
1385        ))]))
1386    }
1387
1388    pub async fn reject_refinement_impl(
1389        &self,
1390        params: RejectRefinementParams,
1391    ) -> Result<CallToolResult, McpError> {
1392        if self.transport == TransportMode::Http {
1393            return Ok(CallToolResult::error(vec![Content::text(
1394                "Review proposal operations are not available over remote connections. \
1395                 Use local MCP on the machine running Origin to reject proposals."
1396                    .to_string(),
1397            )]));
1398        }
1399        let path = format!(
1400            "/api/refinery/queue/{}/reject",
1401            url_encode_simple(&params.id)
1402        );
1403        let resp: RejectRefinementResponse =
1404            match self.client.post(&path, &serde_json::json!({})).await {
1405                Ok(v) => v,
1406                Err(e) => return Ok(tool_error(e, "reject_refinement")),
1407            };
1408
1409        Ok(CallToolResult::success(vec![Content::text(format!(
1410            "Review proposal {} dismissed.",
1411            resp.id
1412        ))]))
1413    }
1414
1415    pub async fn accept_refinement_impl(
1416        &self,
1417        params: AcceptRefinementParams,
1418    ) -> Result<CallToolResult, McpError> {
1419        if self.transport == TransportMode::Http {
1420            return Ok(CallToolResult::error(vec![Content::text(
1421                "Review proposal operations are not available over remote connections. \
1422                 Use local MCP on the machine running Origin to accept proposals."
1423                    .to_string(),
1424            )]));
1425        }
1426        let path = format!(
1427            "/api/refinery/queue/{}/accept",
1428            url_encode_simple(&params.id)
1429        );
1430        let resp: AcceptRefinementResponse =
1431            match self.client.post(&path, &serde_json::json!({})).await {
1432                Ok(v) => v,
1433                Err(e) => return Ok(tool_error(e, "accept_refinement")),
1434            };
1435
1436        Ok(CallToolResult::success(vec![Content::text(format!(
1437            "Review proposal {} accepted (action={}).",
1438            resp.id, resp.action_applied
1439        ))]))
1440    }
1441
1442    pub async fn list_nurture_impl(
1443        &self,
1444        params: ListNurtureParams,
1445    ) -> Result<CallToolResult, McpError> {
1446        let space_arg = effective_space(&params.space);
1447        let mut path = String::from("/api/memory/nurture");
1448        let mut q: Vec<String> = Vec::new();
1449        if let Some(l) = params.limit {
1450            q.push(format!("limit={}", l.clamp(1, 500)));
1451        }
1452        if let Some(s) = space_arg.as_deref().filter(|s| !s.is_empty()) {
1453            q.push(format!("space={}", url_encode_simple(s)));
1454        }
1455        if !q.is_empty() {
1456            path.push('?');
1457            path.push_str(&q.join("&"));
1458        }
1459
1460        let resp: origin_types::responses::NurtureCardsResponse = match self.client.get(&path).await
1461        {
1462            Ok(v) => v,
1463            Err(e) => return Ok(tool_error(e, "list_nurture")),
1464        };
1465
1466        let pretty = serde_json::to_string_pretty(&resp.cards)
1467            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1468        Ok(CallToolResult::success(vec![Content::text(format!(
1469            "{} nurture cards\n{}",
1470            resp.cards.len(),
1471            pretty
1472        ))]))
1473    }
1474
1475    pub async fn list_entity_suggestions_impl(
1476        &self,
1477        _params: ListEntitySuggestionsParams,
1478    ) -> Result<CallToolResult, McpError> {
1479        let resp: Vec<origin_types::entities::EntitySuggestion> =
1480            match self.client.get("/api/memory/entity-suggestions").await {
1481                Ok(v) => v,
1482                Err(e) => return Ok(tool_error(e, "list_entity_suggestions")),
1483            };
1484        let pretty = serde_json::to_string_pretty(&resp)
1485            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1486        Ok(CallToolResult::success(vec![Content::text(format!(
1487            "{} entity suggestion(s)\n{}",
1488            resp.len(),
1489            pretty
1490        ))]))
1491    }
1492
1493    pub async fn accept_revision_impl(
1494        &self,
1495        req: AcceptRevisionRequest,
1496    ) -> Result<CallToolResult, McpError> {
1497        if self.transport == TransportMode::Http {
1498            return Ok(CallToolResult::error(vec![Content::text(
1499                "Revision operations are not available over remote connections. \
1500                 Use local MCP on the machine running Origin to accept memory revisions."
1501                    .to_string(),
1502            )]));
1503        }
1504        let path = format!("/api/memory/revision/{}/accept", req.target_source_id);
1505        let response = match self
1506            .client
1507            .post_empty::<RevisionAcceptResponse>(&path)
1508            .await
1509        {
1510            Ok(r) => r,
1511            Err(e) => return Ok(tool_error(e, "accept_revision")),
1512        };
1513        let pretty = serde_json::to_string_pretty(&response)
1514            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1515        Ok(CallToolResult::success(vec![Content::text(pretty)]))
1516    }
1517
1518    pub async fn dismiss_revision_impl(
1519        &self,
1520        req: DismissRevisionRequest,
1521    ) -> Result<CallToolResult, McpError> {
1522        if self.transport == TransportMode::Http {
1523            return Ok(CallToolResult::error(vec![Content::text(
1524                "Revision operations are not available over remote connections. \
1525                 Use local MCP on the machine running Origin to dismiss memory revisions."
1526                    .to_string(),
1527            )]));
1528        }
1529        let path = format!("/api/memory/revision/{}/dismiss", req.target_source_id);
1530        let response = match self
1531            .client
1532            .post_empty::<RevisionDismissResponse>(&path)
1533            .await
1534        {
1535            Ok(r) => r,
1536            Err(e) => return Ok(tool_error(e, "dismiss_revision")),
1537        };
1538        let pretty = serde_json::to_string_pretty(&response)
1539            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1540        Ok(CallToolResult::success(vec![Content::text(pretty)]))
1541    }
1542
1543    pub async fn dismiss_contradiction_impl(
1544        &self,
1545        req: DismissContradictionRequest,
1546    ) -> Result<CallToolResult, McpError> {
1547        if self.transport == TransportMode::Http {
1548            return Ok(CallToolResult::error(vec![Content::text(
1549                "Contradiction operations are not available over remote connections. \
1550                 Use local MCP on the machine running Origin to dismiss contradictions."
1551                    .to_string(),
1552            )]));
1553        }
1554        let path = format!("/api/memory/contradiction/{}/dismiss", req.source_id);
1555        let response = match self
1556            .client
1557            .post_empty::<ContradictionDismissResponse>(&path)
1558            .await
1559        {
1560            Ok(r) => r,
1561            Err(e) => return Ok(tool_error(e, "dismiss_contradiction")),
1562        };
1563        let pretty = serde_json::to_string_pretty(&response)
1564            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1565        Ok(CallToolResult::success(vec![Content::text(pretty)]))
1566    }
1567
1568    pub async fn list_pending_imports_impl(
1569        &self,
1570        _params: ListPendingImportsParams,
1571    ) -> Result<CallToolResult, McpError> {
1572        let resp: Vec<origin_types::import::PendingImport> =
1573            match self.client.get("/api/import/state").await {
1574                Ok(v) => v,
1575                Err(e) => return Ok(tool_error(e, "list_pending_imports")),
1576            };
1577        let pretty = serde_json::to_string_pretty(&resp)
1578            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1579        Ok(CallToolResult::success(vec![Content::text(format!(
1580            "{} pending import(s)\n{}",
1581            resp.len(),
1582            pretty
1583        ))]))
1584    }
1585
1586    pub async fn list_rejections_impl(
1587        &self,
1588        params: ListRejectionsParams,
1589    ) -> Result<CallToolResult, McpError> {
1590        let mut path = String::from("/api/memory/rejections");
1591        let mut q: Vec<String> = Vec::new();
1592        if let Some(l) = params.limit {
1593            q.push(format!("limit={}", l.clamp(1, 500)));
1594        }
1595        if let Some(r) = params.reason.as_deref().filter(|s| !s.is_empty()) {
1596            q.push(format!("reason={}", url_encode_simple(r)));
1597        }
1598        if !q.is_empty() {
1599            path.push('?');
1600            path.push_str(&q.join("&"));
1601        }
1602
1603        let resp: Vec<origin_types::memory::RejectionRecord> = match self.client.get(&path).await {
1604            Ok(v) => v,
1605            Err(e) => return Ok(tool_error(e, "list_rejections")),
1606        };
1607
1608        let pretty = serde_json::to_string_pretty(&resp)
1609            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1610        Ok(CallToolResult::success(vec![Content::text(format!(
1611            "{} rejection(s)\n{}",
1612            resp.len(),
1613            pretty
1614        ))]))
1615    }
1616
1617    pub async fn list_pending_revisions_impl(
1618        &self,
1619        params: ListPendingRevisionsParams,
1620    ) -> Result<CallToolResult, McpError> {
1621        let path = match params.limit {
1622            Some(l) => format!("/api/memory/pending-revisions?limit={}", l.clamp(1, 500)),
1623            None => "/api/memory/pending-revisions".to_string(),
1624        };
1625        let resp: Vec<origin_types::responses::PendingRevisionItem> =
1626            match self.client.get(&path).await {
1627                Ok(v) => v,
1628                Err(e) => return Ok(tool_error(e, "list_pending_revisions")),
1629            };
1630        let pretty = serde_json::to_string_pretty(&resp)
1631            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1632        Ok(CallToolResult::success(vec![Content::text(format!(
1633            "{} pending revision(s)\n{}",
1634            resp.len(),
1635            pretty
1636        ))]))
1637    }
1638
1639    pub async fn list_orphan_links_impl(
1640        &self,
1641        params: ListOrphanLinksParams,
1642    ) -> Result<CallToolResult, McpError> {
1643        let path = match params.min_count {
1644            Some(n) => format!("/api/pages/orphan-links?min_count={}", n.max(1)),
1645            None => "/api/pages/orphan-links".to_string(),
1646        };
1647        let resp: origin_types::responses::OrphanLinksResponse = match self.client.get(&path).await
1648        {
1649            Ok(v) => v,
1650            Err(e) => return Ok(tool_error(e, "list_orphan_links")),
1651        };
1652        let pretty = serde_json::to_string_pretty(&resp)
1653            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1654        Ok(CallToolResult::success(vec![Content::text(format!(
1655            "{} orphan link(s)\n{}",
1656            resp.orphan_labels.len(),
1657            pretty
1658        ))]))
1659    }
1660}
1661
1662/// Build the `/api/pages/recent` URL with optional `limit` + `since_ms` query
1663/// params. Pure function so the test can exercise the actual builder rather
1664/// than a duplicate.
1665fn build_recent_pages_path(limit: Option<usize>, since_ms: Option<i64>) -> String {
1666    let mut path = String::from("/api/pages/recent");
1667    let mut q: Vec<String> = Vec::new();
1668    if let Some(l) = limit {
1669        q.push(format!("limit={}", l));
1670    }
1671    if let Some(s) = since_ms {
1672        q.push(format!("since_ms={}", s));
1673    }
1674    if !q.is_empty() {
1675        path.push('?');
1676        path.push_str(&q.join("&"));
1677    }
1678    path
1679}
1680
1681/// Percent-encode a string for use in URL query parameter values.
1682/// Encodes all characters except unreserved ones (A-Z, a-z, 0-9, `-`, `_`, `.`, `~`).
1683fn url_encode_simple(s: &str) -> String {
1684    s.chars()
1685        .flat_map(|c| match c {
1686            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
1687                vec![c]
1688            }
1689            _ => format!("%{:02X}", c as u32).chars().collect(),
1690        })
1691        .collect()
1692}
1693
1694// ===== Tool Registrations =====
1695
1696#[tool_router]
1697impl OriginMcpServer {
1698    pub fn new(
1699        client: OriginClient,
1700        transport: TransportMode,
1701        agent_name: String,
1702        user_id: Option<String>,
1703    ) -> Self {
1704        Self {
1705            tool_router: Self::tool_router(),
1706            client,
1707            transport,
1708            agent_name,
1709            client_name: std::sync::Arc::new(std::sync::Mutex::new(None)),
1710            user_id,
1711        }
1712    }
1713
1714    // --- Primary Tools ---
1715
1716    #[tool(
1717        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.",
1718        annotations(
1719            title = "Capture",
1720            read_only_hint = false,
1721            destructive_hint = false,
1722            idempotent_hint = false,
1723            open_world_hint = false
1724        )
1725    )]
1726    async fn capture(
1727        &self,
1728        Parameters(params): Parameters<CaptureParams>,
1729    ) -> Result<CallToolResult, McpError> {
1730        self.capture_impl(params).await
1731    }
1732
1733    #[tool(
1734        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.",
1735        annotations(title = "Recall", read_only_hint = true, open_world_hint = false)
1736    )]
1737    async fn recall(
1738        &self,
1739        Parameters(params): Parameters<RecallParams>,
1740    ) -> Result<CallToolResult, McpError> {
1741        self.recall_impl(params).await
1742    }
1743
1744    #[tool(
1745        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.",
1746        annotations(title = "Context", read_only_hint = true, open_world_hint = false)
1747    )]
1748    async fn context(
1749        &self,
1750        Parameters(params): Parameters<ContextParams>,
1751    ) -> Result<CallToolResult, McpError> {
1752        self.context_impl(params).await
1753    }
1754
1755    #[tool(
1756        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.",
1757        annotations(title = "Doctor", read_only_hint = true, open_world_hint = false)
1758    )]
1759    async fn doctor(&self) -> Result<CallToolResult, McpError> {
1760        self.doctor_impl().await
1761    }
1762
1763    #[tool(
1764        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.",
1765        annotations(
1766            title = "Forget",
1767            read_only_hint = false,
1768            destructive_hint = true,
1769            idempotent_hint = true,
1770            open_world_hint = false
1771        )
1772    )]
1773    async fn forget(
1774        &self,
1775        Parameters(params): Parameters<ForgetParams>,
1776    ) -> Result<CallToolResult, McpError> {
1777        self.forget_impl(&params.memory_id).await
1778    }
1779
1780    #[tool(
1781        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.",
1782        annotations(
1783            title = "Distill",
1784            read_only_hint = false,
1785            destructive_hint = false,
1786            idempotent_hint = true,
1787            open_world_hint = false
1788        )
1789    )]
1790    async fn distill(
1791        &self,
1792        Parameters(params): Parameters<DistillParams>,
1793    ) -> Result<CallToolResult, McpError> {
1794        self.distill_impl(params).await
1795    }
1796
1797    #[tool(
1798        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.",
1799        annotations(title = "List pending", read_only_hint = true, open_world_hint = false)
1800    )]
1801    async fn list_pending(
1802        &self,
1803        Parameters(params): Parameters<ListPendingParams>,
1804    ) -> Result<CallToolResult, McpError> {
1805        self.list_pending_impl(params).await
1806    }
1807
1808    #[tool(
1809        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`.",
1810        annotations(
1811            title = "Confirm memory",
1812            read_only_hint = false,
1813            destructive_hint = false,
1814            idempotent_hint = true,
1815            open_world_hint = false
1816        )
1817    )]
1818    async fn confirm_memory(
1819        &self,
1820        Parameters(params): Parameters<ConfirmMemoryParams>,
1821    ) -> Result<CallToolResult, McpError> {
1822        self.confirm_memory_impl(&params.memory_id).await
1823    }
1824
1825    // --- Knowledge graph CRUD ---
1826
1827    #[tool(
1828        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.",
1829        annotations(
1830            title = "Create entity",
1831            read_only_hint = false,
1832            destructive_hint = false,
1833            idempotent_hint = false,
1834            open_world_hint = false
1835        )
1836    )]
1837    async fn create_entity(
1838        &self,
1839        Parameters(params): Parameters<CreateEntityParams>,
1840    ) -> Result<CallToolResult, McpError> {
1841        self.create_entity_impl(params).await
1842    }
1843
1844    #[tool(
1845        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.",
1846        annotations(
1847            title = "Create relation",
1848            read_only_hint = false,
1849            destructive_hint = false,
1850            idempotent_hint = false,
1851            open_world_hint = false
1852        )
1853    )]
1854    async fn create_relation(
1855        &self,
1856        Parameters(params): Parameters<CreateRelationParams>,
1857    ) -> Result<CallToolResult, McpError> {
1858        self.create_relation_impl(params).await
1859    }
1860
1861    #[tool(
1862        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.",
1863        annotations(
1864            title = "Create observation",
1865            read_only_hint = false,
1866            destructive_hint = false,
1867            idempotent_hint = false,
1868            open_world_hint = false
1869        )
1870    )]
1871    async fn create_observation(
1872        &self,
1873        Parameters(params): Parameters<CreateObservationParams>,
1874    ) -> Result<CallToolResult, McpError> {
1875        self.create_observation_impl(params).await
1876    }
1877
1878    #[tool(
1879        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).",
1880        annotations(
1881            title = "Confirm entity",
1882            read_only_hint = false,
1883            destructive_hint = false,
1884            idempotent_hint = true,
1885            open_world_hint = false
1886        )
1887    )]
1888    async fn confirm_entity(
1889        &self,
1890        Parameters(params): Parameters<ConfirmEntityParams>,
1891    ) -> Result<CallToolResult, McpError> {
1892        self.confirm_entity_impl(params).await
1893    }
1894
1895    #[tool(
1896        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).",
1897        annotations(
1898            title = "Update observation",
1899            read_only_hint = false,
1900            destructive_hint = false,
1901            idempotent_hint = true,
1902            open_world_hint = false
1903        )
1904    )]
1905    async fn update_observation(
1906        &self,
1907        Parameters(params): Parameters<UpdateObservationParams>,
1908    ) -> Result<CallToolResult, McpError> {
1909        self.update_observation_impl(params).await
1910    }
1911
1912    #[tool(
1913        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).",
1914        annotations(
1915            title = "Confirm observation",
1916            read_only_hint = false,
1917            destructive_hint = false,
1918            idempotent_hint = true,
1919            open_world_hint = false
1920        )
1921    )]
1922    async fn confirm_observation(
1923        &self,
1924        Parameters(params): Parameters<ConfirmObservationParams>,
1925    ) -> Result<CallToolResult, McpError> {
1926        self.confirm_observation_impl(params).await
1927    }
1928
1929    #[tool(
1930        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).",
1931        annotations(
1932            title = "Delete observation",
1933            read_only_hint = false,
1934            destructive_hint = true,
1935            idempotent_hint = true,
1936            open_world_hint = false
1937        )
1938    )]
1939    async fn delete_observation(
1940        &self,
1941        Parameters(params): Parameters<DeleteObservationParams>,
1942    ) -> Result<CallToolResult, McpError> {
1943        self.delete_observation_impl(params).await
1944    }
1945
1946    #[tool(
1947        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.",
1948        annotations(
1949            title = "Create page",
1950            read_only_hint = false,
1951            destructive_hint = false,
1952            idempotent_hint = false,
1953            open_world_hint = false
1954        )
1955    )]
1956    async fn create_page(
1957        &self,
1958        Parameters(params): Parameters<CreatePageParams>,
1959    ) -> Result<CallToolResult, McpError> {
1960        self.create_page_impl(params).await
1961    }
1962
1963    #[tool(
1964        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).",
1965        annotations(
1966            title = "Refresh page",
1967            read_only_hint = false,
1968            destructive_hint = false,
1969            idempotent_hint = false,
1970            open_world_hint = false
1971        )
1972    )]
1973    async fn update_page(
1974        &self,
1975        Parameters(params): Parameters<UpdatePageParams>,
1976    ) -> Result<CallToolResult, McpError> {
1977        self.update_page_impl(params).await
1978    }
1979
1980    #[tool(
1981        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.",
1982        annotations(
1983            title = "Delete page",
1984            read_only_hint = false,
1985            destructive_hint = true,
1986            idempotent_hint = true,
1987            open_world_hint = false
1988        )
1989    )]
1990    async fn delete_page(
1991        &self,
1992        Parameters(params): Parameters<DeletePageParams>,
1993    ) -> Result<CallToolResult, McpError> {
1994        self.delete_page_impl(&params.page_id).await
1995    }
1996
1997    #[tool(
1998        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.",
1999        annotations(title = "Get page", read_only_hint = true, open_world_hint = false)
2000    )]
2001    async fn get_page(
2002        &self,
2003        Parameters(params): Parameters<GetPageParams>,
2004    ) -> Result<CallToolResult, McpError> {
2005        self.get_page_impl(&params.page_id).await
2006    }
2007
2008    #[tool(
2009        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.",
2010        annotations(
2011            title = "Get page links",
2012            read_only_hint = true,
2013            destructive_hint = false,
2014            idempotent_hint = true,
2015            open_world_hint = false
2016        )
2017    )]
2018    async fn get_page_links(
2019        &self,
2020        Parameters(params): Parameters<GetPageLinksParams>,
2021    ) -> Result<CallToolResult, McpError> {
2022        self.get_page_links_impl(&params.page_id).await
2023    }
2024
2025    #[tool(
2026        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.",
2027        annotations(
2028            title = "Get page sources",
2029            read_only_hint = true,
2030            destructive_hint = false,
2031            idempotent_hint = true,
2032            open_world_hint = false
2033        )
2034    )]
2035    async fn get_page_sources(
2036        &self,
2037        Parameters(params): Parameters<GetPageSourcesParams>,
2038    ) -> Result<CallToolResult, McpError> {
2039        self.get_page_sources_impl(&params.page_id).await
2040    }
2041
2042    #[tool(
2043        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.",
2044        annotations(
2045            title = "Get memory revisions",
2046            read_only_hint = true,
2047            destructive_hint = false,
2048            idempotent_hint = true,
2049            open_world_hint = false
2050        )
2051    )]
2052    async fn get_memory_revisions(
2053        &self,
2054        Parameters(params): Parameters<GetMemoryRevisionsParams>,
2055    ) -> Result<CallToolResult, McpError> {
2056        self.get_memory_revisions_impl(&params.memory_id).await
2057    }
2058
2059    #[tool(
2060        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.",
2061        annotations(
2062            title = "Get page revisions",
2063            read_only_hint = true,
2064            destructive_hint = false,
2065            idempotent_hint = true,
2066            open_world_hint = false
2067        )
2068    )]
2069    async fn get_page_revisions(
2070        &self,
2071        Parameters(params): Parameters<GetPageRevisionsParams>,
2072    ) -> Result<CallToolResult, McpError> {
2073        self.get_page_revisions_impl(&params.page_id).await
2074    }
2075
2076    #[tool(
2077        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.",
2078        annotations(
2079            title = "List memories",
2080            read_only_hint = true,
2081            open_world_hint = false
2082        )
2083    )]
2084    async fn list_memories(
2085        &self,
2086        Parameters(params): Parameters<ListMemoriesParams>,
2087    ) -> Result<CallToolResult, McpError> {
2088        self.list_memories_impl(params).await
2089    }
2090
2091    #[tool(
2092        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.",
2093        annotations(title = "Search pages", read_only_hint = true, open_world_hint = false)
2094    )]
2095    async fn search_pages(
2096        &self,
2097        Parameters(params): Parameters<SearchPagesParams>,
2098    ) -> Result<CallToolResult, McpError> {
2099        self.search_pages_impl(params).await
2100    }
2101
2102    #[tool(
2103        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.",
2104        annotations(title = "Recent pages", read_only_hint = true, open_world_hint = false)
2105    )]
2106    async fn list_pages_recent(
2107        &self,
2108        Parameters(params): Parameters<ListPagesRecentParams>,
2109    ) -> Result<CallToolResult, McpError> {
2110        self.list_pages_recent_impl(params).await
2111    }
2112
2113    #[tool(
2114        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.",
2115        annotations(title = "List spaces", read_only_hint = true, open_world_hint = false)
2116    )]
2117    async fn list_spaces(
2118        &self,
2119        Parameters(params): Parameters<ListSpacesParams>,
2120    ) -> Result<CallToolResult, McpError> {
2121        self.list_spaces_impl(params).await
2122    }
2123
2124    // --- Review proposal tools ---
2125
2126    #[tool(
2127        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.",
2128        annotations(
2129            title = "List review proposals",
2130            read_only_hint = true,
2131            open_world_hint = false
2132        )
2133    )]
2134    async fn list_refinements(
2135        &self,
2136        Parameters(params): Parameters<ListRefinementsParams>,
2137    ) -> Result<CallToolResult, McpError> {
2138        self.list_refinements_impl(params).await
2139    }
2140
2141    #[tool(
2142        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).",
2143        annotations(
2144            title = "Reject review proposal",
2145            read_only_hint = false,
2146            destructive_hint = false,
2147            idempotent_hint = true,
2148            open_world_hint = false
2149        )
2150    )]
2151    async fn reject_refinement(
2152        &self,
2153        Parameters(params): Parameters<RejectRefinementParams>,
2154    ) -> Result<CallToolResult, McpError> {
2155        self.reject_refinement_impl(params).await
2156    }
2157
2158    #[tool(
2159        description = "Apply a review queue proposal using sensible defaults. \
2160            entity_merge: existing entity wins as canonical. \
2161            relation_conflict: new relation supersedes. \
2162            detect_contradiction: previously-stored memory flagged for revision. \
2163            Returns 422 for suggest_entity (no producer) and dedup_merge (deprecated). \
2164            Not available over remote HTTP MCP transport (local stdio only).",
2165        annotations(
2166            title = "Accept review proposal",
2167            read_only_hint = false,
2168            destructive_hint = false,
2169            idempotent_hint = true,
2170            open_world_hint = false
2171        )
2172    )]
2173    async fn accept_refinement(
2174        &self,
2175        Parameters(params): Parameters<AcceptRefinementParams>,
2176    ) -> Result<CallToolResult, McpError> {
2177        self.accept_refinement_impl(params).await
2178    }
2179
2180    // --- Curation read tools ---
2181
2182    #[tool(
2183        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).",
2184        annotations(
2185            title = "List nurture cards",
2186            read_only_hint = true,
2187            idempotent_hint = true,
2188            open_world_hint = false
2189        )
2190    )]
2191    async fn list_nurture(
2192        &self,
2193        Parameters(params): Parameters<ListNurtureParams>,
2194    ) -> Result<CallToolResult, McpError> {
2195        self.list_nurture_impl(params).await
2196    }
2197
2198    #[tool(
2199        description = "List entity-suggestion proposals from the daemon review queue \
2200                       (action='suggest_entity'). Use when the user asks 'what entities \
2201                       does the daemon want to create' or wants to triage merge-vs-create \
2202                       decisions. Returns id, proposed entity_name, source_ids, confidence. \
2203                       Pair with PR2's approve/dismiss verbs once they land.",
2204        annotations(
2205            title = "List entity suggestions",
2206            read_only_hint = true,
2207            idempotent_hint = true,
2208            open_world_hint = false
2209        )
2210    )]
2211    async fn list_entity_suggestions(
2212        &self,
2213        Parameters(params): Parameters<ListEntitySuggestionsParams>,
2214    ) -> Result<CallToolResult, McpError> {
2215        self.list_entity_suggestions_impl(params).await
2216    }
2217
2218    #[tool(
2219        description = "Accept a pending memory revision. Replaces the target memory's content \
2220                       with the proposed revision content and removes the revision row from the \
2221                       pending list. Returns the consumed revision id. Returns an error if no \
2222                       pending revision exists for that target. Not available over remote HTTP MCP transport (local stdio only).",
2223        annotations(
2224            title = "Accept revision",
2225            read_only_hint = false,
2226            destructive_hint = false,
2227            idempotent_hint = false,
2228            open_world_hint = false
2229        )
2230    )]
2231    async fn accept_revision(
2232        &self,
2233        Parameters(req): Parameters<AcceptRevisionRequest>,
2234    ) -> Result<CallToolResult, McpError> {
2235        self.accept_revision_impl(req).await
2236    }
2237
2238    #[tool(
2239        description = "Dismiss a pending memory revision. Deletes the revision row; the original \
2240                       memory is unchanged. Returns an error if no pending revision exists for \
2241                       that target. Not available over remote HTTP MCP transport (local stdio only).",
2242        annotations(
2243            title = "Dismiss revision",
2244            read_only_hint = false,
2245            destructive_hint = false,
2246            idempotent_hint = false,
2247            open_world_hint = false
2248        )
2249    )]
2250    async fn dismiss_revision(
2251        &self,
2252        Parameters(req): Parameters<DismissRevisionRequest>,
2253    ) -> Result<CallToolResult, McpError> {
2254        self.dismiss_revision_impl(req).await
2255    }
2256
2257    #[tool(
2258        description = "Dismiss all awaiting-review contradiction flags for a memory. Idempotent. \
2259                       Returns wrote:true even if no rows matched. Not available over remote HTTP MCP transport (local stdio only).",
2260        annotations(
2261            title = "Dismiss contradiction",
2262            read_only_hint = false,
2263            destructive_hint = false,
2264            idempotent_hint = true,
2265            open_world_hint = false
2266        )
2267    )]
2268    async fn dismiss_contradiction(
2269        &self,
2270        Parameters(req): Parameters<DismissContradictionRequest>,
2271    ) -> Result<CallToolResult, McpError> {
2272        self.dismiss_contradiction_impl(req).await
2273    }
2274
2275    #[tool(
2276        description = "List in-flight chat-history imports awaiting processing or completion. \
2277                       Use when the user asks 'what imports are running', 'is my Claude.ai \
2278                       export done', or to surface import progress. Returns id, vendor, \
2279                       stage, source path, processed/total conversation counts.",
2280        annotations(
2281            title = "List pending imports",
2282            read_only_hint = true,
2283            idempotent_hint = true,
2284            open_world_hint = false
2285        )
2286    )]
2287    async fn list_pending_imports(
2288        &self,
2289        Parameters(params): Parameters<ListPendingImportsParams>,
2290    ) -> Result<CallToolResult, McpError> {
2291        self.list_pending_imports_impl(params).await
2292    }
2293
2294    #[tool(
2295        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').",
2296        annotations(
2297            title = "List rejections",
2298            read_only_hint = true,
2299            idempotent_hint = true,
2300            open_world_hint = false
2301        )
2302    )]
2303    async fn list_rejections(
2304        &self,
2305        Parameters(params): Parameters<ListRejectionsParams>,
2306    ) -> Result<CallToolResult, McpError> {
2307        self.list_rejections_impl(params).await
2308    }
2309
2310    #[tool(
2311        description = "List memories awaiting human accept/dismiss because a newer version \
2312                       was proposed (Protected tier supersede). Use when the user asks \
2313                       'what revisions are pending', 'show me memories awaiting approval'. \
2314                       Each item carries target_source_id (the memory being revised: pass \
2315                       THIS to accept_pending_revision in PR2) and revision_content for \
2316                       display. Optional `limit` caps results (default 50, max 500).",
2317        annotations(
2318            title = "List pending revisions",
2319            read_only_hint = true,
2320            idempotent_hint = true,
2321            open_world_hint = false
2322        )
2323    )]
2324    async fn list_pending_revisions(
2325        &self,
2326        Parameters(params): Parameters<ListPendingRevisionsParams>,
2327    ) -> Result<CallToolResult, McpError> {
2328        self.list_pending_revisions_impl(params).await
2329    }
2330
2331    #[tool(
2332        description = "List wiki-link labels that appear in page bodies but have no matching \
2333                       page title. Use when the user asks 'what links are broken', 'orphan links', \
2334                       or wants to find knowledge gaps. Returns label names and reference counts. \
2335                       Optional `min_count` filters to labels referenced at least N times \
2336                       (default 1, minimum 1).",
2337        annotations(
2338            title = "List orphan links",
2339            read_only_hint = true,
2340            idempotent_hint = true,
2341            open_world_hint = false
2342        )
2343    )]
2344    async fn list_orphan_links(
2345        &self,
2346        Parameters(params): Parameters<ListOrphanLinksParams>,
2347    ) -> Result<CallToolResult, McpError> {
2348        self.list_orphan_links_impl(params).await
2349    }
2350}
2351
2352// ===== Schema gating =====
2353
2354/// Return a copy of `tool` with the `space` field removed from its
2355/// `inputSchema.properties` (and from `required` if present).
2356///
2357/// Called when `ORIGIN_SPACE` is locked so the model never sees the field.
2358/// The runtime guard in `effective_space()` is the load-bearing safety net;
2359/// this is UX polish on top.
2360fn strip_space_from_tool_schema(mut tool: Tool) -> Tool {
2361    let mut schema = (*tool.input_schema).clone();
2362    if let Some(props) = schema.get_mut("properties").and_then(|v| v.as_object_mut()) {
2363        props.remove("space");
2364    }
2365    if let Some(required) = schema.get_mut("required").and_then(|v| v.as_array_mut()) {
2366        required.retain(|v| v.as_str() != Some("space"));
2367    }
2368    tool.input_schema = std::sync::Arc::new(schema);
2369    tool
2370}
2371
2372// ===== ServerHandler =====
2373
2374#[tool_handler]
2375impl ServerHandler for OriginMcpServer {
2376    async fn list_tools(
2377        &self,
2378        _request: Option<PaginatedRequestParams>,
2379        _context: RequestContext<RoleServer>,
2380    ) -> Result<ListToolsResult, McpError> {
2381        let tools = Self::tool_router().list_all();
2382        let tools = if crate::lock_state::is_locked() {
2383            tools
2384                .into_iter()
2385                .map(strip_space_from_tool_schema)
2386                .collect()
2387        } else {
2388            tools
2389        };
2390        Ok(ListToolsResult {
2391            tools,
2392            meta: None,
2393            next_cursor: None,
2394        })
2395    }
2396
2397    async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
2398        // Capture client name from MCP initialize handshake
2399        if let Some(client_info) = context.peer.peer_info() {
2400            let name = &client_info.client_info.name;
2401            if !name.is_empty() {
2402                if let Ok(mut guard) = self.client_name.lock() {
2403                    tracing::info!("MCP client identified: {}", name);
2404                    *guard = Some(name.clone());
2405                }
2406            }
2407        }
2408    }
2409
2410    fn get_info(&self) -> InitializeResult {
2411        InitializeResult::new(
2412            ServerCapabilities::builder()
2413                .enable_tools()
2414                .build(),
2415        )
2416        .with_server_info(
2417            Implementation::new("origin-mcp", env!("CARGO_PKG_VERSION"))
2418        )
2419        .with_instructions(
2420            "Origin is your personal memory layer — a local knowledge base that persists across sessions and tools.\n\
2421             Think of yourself as a curator, not a logger. Store insights, not conversation artifacts.\n\n\
2422             Origin is cumulative: each memory you store can be recalled, linked, and distilled into knowledge over time. \
2423             It's also shared across all the user's tools: what you write, other agents (Claude Desktop, Claude Code, \
2424             ChatGPT, Cursor, etc.) will read later. Write for any future reader, not just this conversation.\n\n\
2425             FIRST THING EVERY SESSION: Call context to load the user's identity, preferences, goals, and\n\
2426             topic-relevant memories. This is how you know who you're talking to. Use the result to model how the \
2427             user thinks — their preferences, corrections, and past decisions tell you how they want to be helped, \
2428             not just what they already know.\n\n\
2429             STORE PROACTIVELY — don't wait for the user to ask.\n\
2430             - The user states a preference (\"I use X because...\", \"I prefer Y over Z\")\n\
2431             - The user makes a decision (\"going with approach A\", \"switching to B\")\n\
2432             - The user corrects you or prior info (\"actually, it's C, not D\") — store the correction so it sticks\n\
2433             - The user shares a durable fact about themselves, their work, or people/projects/tools they care about — \
2434               anchor it to the entity\n\n\
2435             If the user asks explicitly (\"remember this\", \"save this\", \"don't forget\"), that's a floor — you \
2436             should have already stored it.\n\n\
2437             WHEN NOT TO STORE:\n\
2438             - Conversation filler (\"ok\", \"thanks\", \"let's move on\")\n\
2439             - Things the user can trivially re-derive (file paths, recent git history)\n\
2440             - Anything already stored — recall first if unsure\n\
2441             - Tool output or command results (file contents, git history, build logs) — these are derivable\n\
2442             - General world facts or documentation that aren't personal to this user (e.g., \"Rust has a borrow \
2443               checker\", \"PostgreSQL supports JSONB\") — those are not memory material.\n\
2444             - Your own inferences about the user that they didn't express. Store what they said; infer from that \
2445               when responding.\n\n\
2446             CONTENT QUALITY — this is where you make the biggest difference:\n\
2447             - Specific beats vague: \"prefers Rust for CLI tools because of compile-time safety\" > \"likes Rust\"\n\
2448             - Include the WHY: the backend can classify \"dark mode\" as a preference, but only you know\n\
2449               \"switched to dark mode because of migraines from bright screens\"\n\
2450             - Name the entities: mention people, projects, tools by name — this powers the knowledge graph\n\
2451             - Atomic: one idea per memory — \"prefers TDD\" and \"uses pytest\" should be two memories, not one\n\
2452             - Declarative, not narrative: \"User prefers X because Y\" — not \"User said today they prefer X\". \
2453               Memories outlive the conversation that produced them.\n\n\
2454             MEMORY TYPES — omit and trust the backend.\n\n\
2455             By default, do NOT set memory_type. The backend auto-classifies into identity / preference / \
2456             decision / lesson / gotcha / fact with more context than you have. Agents that over-specify \
2457             types tend to pick wrong.\n\n\
2458             Opt-in specification:\n\
2459             - \"profile\"   — you're sure it's about the user (identity / preference)\n\
2460             - \"knowledge\" — you're sure it's about the world (decision / lesson / gotcha / fact)\n\
2461             - Precise type — only if you're confident and the distinction matters.\n\n\
2462             EXCEPTION — decisions carry structured fields (alternatives considered, reversibility, domain) \
2463             that power the Decision Log view. Set memory_type=\"decision\" explicitly ONLY when the user \
2464             articulated alternatives weighed AND the reasoning for the choice. A bare \"I'm switching to Cursor\" \
2465             is just a preference change — omit the type. \"Switching to Cursor over VSCode because of better \
2466             Claude integration, and we can always go back\" — that's a decision.\n\n\
2467             RECALL vs CONTEXT:\n\
2468             - context: broad orientation, session start, topic shifts, \"catch me up\"\n\
2469             - recall: specific lookup (\"what's Alice's role?\", \"database preferences\", \"our auth decision\")\n\n\
2470             The backend handles classification, entity extraction, structured fields, quality scoring,\n\
2471             and dedup — you don't need to replicate that logic. Focus on what only you know:\n\
2472             the conversational context, why something matters, and what the user actually cares about."
2473        )
2474    }
2475}
2476
2477#[cfg(test)]
2478mod tests {
2479    use super::*;
2480    use crate::client::OriginClient;
2481    use crate::types::{
2482        ChatContextRequest, ChatContextResponse, SearchMemoryRequest, SearchResult,
2483        StoreMemoryRequest, StoreMemoryResponse,
2484    };
2485
2486    fn make_server(
2487        transport: TransportMode,
2488        agent_name: &str,
2489        user_id: Option<&str>,
2490    ) -> OriginMcpServer {
2491        let client = OriginClient::new("http://127.0.0.1:19999".into());
2492        OriginMcpServer::new(
2493            client,
2494            transport,
2495            agent_name.into(),
2496            user_id.map(String::from),
2497        )
2498    }
2499
2500    // ===== Transport resolution (existing) =====
2501
2502    #[test]
2503    fn test_http_mode_prefers_param_over_agent_name() {
2504        let server = make_server(TransportMode::Http, "claude.ai", None);
2505        // Explicit param has highest priority
2506        let result = server.resolve_source_agent(Some("user-provided".into()));
2507        assert_eq!(result, Some("user-provided".into()));
2508    }
2509
2510    #[test]
2511    fn test_http_mode_sets_source_agent_when_none() {
2512        let server = make_server(TransportMode::Http, "chatgpt", None);
2513        let result = server.resolve_source_agent(None);
2514        assert_eq!(result, Some("chatgpt".into()));
2515    }
2516
2517    #[test]
2518    fn test_stdio_mode_passes_through_source_agent() {
2519        let server = make_server(TransportMode::Stdio, "ignored", None);
2520        let result = server.resolve_source_agent(Some("user-provided".into()));
2521        assert_eq!(result, Some("user-provided".into()));
2522    }
2523
2524    #[test]
2525    fn test_stdio_mode_falls_back_to_agent_name() {
2526        let server = make_server(TransportMode::Stdio, "fallback", None);
2527        // No param, no client_name → falls back to configured agent_name
2528        let result = server.resolve_source_agent(None);
2529        assert_eq!(result, Some("fallback".into()));
2530    }
2531
2532    #[test]
2533    fn test_http_mode_resolves_configured_user_id_for_local_use() {
2534        let server = make_server(TransportMode::Http, "agent", Some("lucian"));
2535        let result = server.resolve_user_id(None);
2536        assert_eq!(result, Some("lucian".into()));
2537    }
2538
2539    #[test]
2540    fn test_transport_mode_equality() {
2541        assert_eq!(TransportMode::Stdio, TransportMode::Stdio);
2542        assert_eq!(TransportMode::Http, TransportMode::Http);
2543        assert_ne!(TransportMode::Stdio, TransportMode::Http);
2544    }
2545
2546    // ===== Param deserialization: CaptureParams =====
2547
2548    #[test]
2549    fn test_capture_params_minimal() {
2550        let json = r#"{"content": "Lucian prefers dark mode"}"#;
2551        let params: CaptureParams = serde_json::from_str(json).unwrap();
2552        assert_eq!(params.content, "Lucian prefers dark mode");
2553        assert!(params.memory_type.is_none());
2554        assert!(params.space.is_none());
2555        assert!(params.entity.is_none());
2556        assert!(params.confidence.is_none());
2557        assert!(params.supersedes.is_none());
2558    }
2559
2560    #[test]
2561    fn test_capture_params_full() {
2562        let json = r#"{
2563            "content": "We chose PostgreSQL over MongoDB",
2564            "memory_type": "decision",
2565            "space": "origin",
2566            "entity": "PostgreSQL",
2567            "confidence": 0.95,
2568            "supersedes": "mem_abc123"
2569        }"#;
2570        let params: CaptureParams = serde_json::from_str(json).unwrap();
2571        assert_eq!(params.content, "We chose PostgreSQL over MongoDB");
2572        assert_eq!(params.memory_type.as_deref(), Some("decision"));
2573        assert_eq!(params.space.as_deref(), Some("origin"));
2574        assert_eq!(params.entity.as_deref(), Some("PostgreSQL"));
2575        assert_eq!(params.confidence, Some(0.95));
2576        assert_eq!(params.supersedes.as_deref(), Some("mem_abc123"));
2577    }
2578
2579    #[test]
2580    fn test_capture_params_missing_content_fails() {
2581        let json = r#"{"memory_type": "fact"}"#;
2582        let result = serde_json::from_str::<CaptureParams>(json);
2583        assert!(result.is_err());
2584    }
2585
2586    // ===== Param deserialization: RecallParams =====
2587
2588    #[test]
2589    fn test_recall_params_minimal() {
2590        let json = r#"{"query": "what does Alice work on?"}"#;
2591        let params: RecallParams = serde_json::from_str(json).unwrap();
2592        assert_eq!(params.query, "what does Alice work on?");
2593        assert!(params.limit.is_none());
2594        assert!(
2595            params.rerank.is_none(),
2596            "rerank omitted must remain None so the daemon receives default false"
2597        );
2598    }
2599
2600    #[test]
2601    fn test_recall_params_full() {
2602        let json = r#"{
2603            "query": "database preferences",
2604            "limit": 5,
2605            "memory_type": "decision",
2606            "space": "origin",
2607            "rerank": true
2608        }"#;
2609        let params: RecallParams = serde_json::from_str(json).unwrap();
2610        assert_eq!(params.query, "database preferences");
2611        assert_eq!(params.limit, Some(5));
2612        assert_eq!(params.memory_type.as_deref(), Some("decision"));
2613        assert_eq!(params.space.as_deref(), Some("origin"));
2614        assert_eq!(params.rerank, Some(true));
2615    }
2616
2617    #[test]
2618    fn test_recall_params_limit_as_string() {
2619        let json = r#"{"query": "test", "limit": "10"}"#;
2620        let params: RecallParams = serde_json::from_str(json).unwrap();
2621        assert_eq!(params.limit, Some(10));
2622    }
2623
2624    #[test]
2625    fn test_recall_params_missing_query_fails() {
2626        let json = r#"{"limit": 5}"#;
2627        let result = serde_json::from_str::<RecallParams>(json);
2628        assert!(result.is_err());
2629    }
2630
2631    // ===== Param deserialization: ContextParams =====
2632
2633    #[test]
2634    fn test_context_params_empty() {
2635        let json = r#"{}"#;
2636        let params: ContextParams = serde_json::from_str(json).unwrap();
2637        assert!(params.topic.is_none());
2638        assert!(params.limit.is_none());
2639        assert!(params.space.is_none());
2640    }
2641
2642    #[test]
2643    fn test_context_params_full() {
2644        let json = r#"{"topic": "project Origin architecture", "limit": 30, "space": "work"}"#;
2645        let params: ContextParams = serde_json::from_str(json).unwrap();
2646        assert_eq!(params.topic.as_deref(), Some("project Origin architecture"));
2647        assert_eq!(params.limit, Some(30));
2648        assert_eq!(params.space.as_deref(), Some("work"));
2649    }
2650
2651    #[test]
2652    fn test_context_params_limit_as_string() {
2653        let json = r#"{"limit": "20"}"#;
2654        let params: ContextParams = serde_json::from_str(json).unwrap();
2655        assert_eq!(params.limit, Some(20));
2656    }
2657
2658    #[test]
2659    fn legacy_domain_alias_still_deserializes() {
2660        // Cached MCP clients (pre-0.7.0 schema) send `"domain"` instead of `"space"`.
2661        // The serde alias must accept legacy JSON so they don't break for the one-release window.
2662        let json = r#"{"topic": "project work", "domain": "work"}"#;
2663        let params: ContextParams =
2664            serde_json::from_str(json).expect("legacy 'domain' key must deserialize");
2665        assert_eq!(
2666            params.space.as_deref(),
2667            Some("work"),
2668            "alias must map domain → space"
2669        );
2670    }
2671
2672    #[test]
2673    fn store_memory_request_serialization_excludes_user_id() {
2674        let req = StoreMemoryRequest {
2675            content: "test content".into(),
2676            memory_type: None,
2677            space: None,
2678            source_agent: Some("test-agent".into()),
2679            title: None,
2680            confidence: None,
2681            supersedes: None,
2682            entity: None,
2683            entity_id: None,
2684            structured_fields: None,
2685            retrieval_cue: None,
2686        };
2687        let json = serde_json::to_value(&req).unwrap();
2688        let obj = json.as_object().unwrap();
2689        assert!(
2690            !obj.contains_key("user_id"),
2691            "user_id must not be on the wire; got: {:?}",
2692            obj.keys().collect::<Vec<_>>()
2693        );
2694    }
2695
2696    #[test]
2697    fn capture_success_message_is_terse() {
2698        let resp = StoreMemoryResponse {
2699            source_id: "mem_abc".into(),
2700            chunks_created: 3,
2701            memory_type: "fact".into(),
2702            entity_id: Some("ent_xyz".into()),
2703            quality: Some("high".into()),
2704            warnings: vec![],
2705            extraction_method: "llm".into(),
2706            enrichment: String::new(),
2707            hint: String::new(),
2708            triggered_revisions: vec![],
2709            auto_superseded: vec![],
2710        };
2711        let msg = format_capture_success(&resp);
2712        assert_eq!(msg, "Stored mem_abc");
2713        assert!(!msg.contains("chunks"));
2714        assert!(!msg.contains("quality"));
2715        assert!(!msg.contains("entity"));
2716    }
2717
2718    #[test]
2719    fn capture_success_message_surfaces_warnings() {
2720        let resp = StoreMemoryResponse {
2721            source_id: "mem_abc".into(),
2722            chunks_created: 1,
2723            memory_type: "decision".into(),
2724            entity_id: None,
2725            quality: None,
2726            warnings: vec!["decision memory missing required 'claim' field".into()],
2727            extraction_method: "agent".into(),
2728            enrichment: String::new(),
2729            hint: String::new(),
2730            triggered_revisions: vec![],
2731            auto_superseded: vec![],
2732        };
2733        let msg = format_capture_success(&resp);
2734        assert!(msg.starts_with("Stored mem_abc"));
2735        assert!(msg.contains("Warnings:"));
2736        assert!(msg.contains("decision memory missing required 'claim' field"));
2737    }
2738
2739    #[test]
2740    fn format_capture_success_surfaces_triggered_revisions() {
2741        let resp = StoreMemoryResponse {
2742            source_id: "mem_new".into(),
2743            chunks_created: 1,
2744            memory_type: "fact".into(),
2745            entity_id: None,
2746            quality: None,
2747            warnings: vec![],
2748            extraction_method: "agent".into(),
2749            enrichment: String::new(),
2750            hint: String::new(),
2751            triggered_revisions: vec!["mem_protected_target".to_string()],
2752            auto_superseded: vec![],
2753        };
2754        let out = format_capture_success(&resp);
2755        assert!(out.contains("Triggered revisions"));
2756        assert!(out.contains("mem_protected_target"));
2757        assert!(out.contains("accept_revision"));
2758        assert!(out.contains("dismiss_revision"));
2759    }
2760
2761    #[test]
2762    fn format_capture_success_omits_section_when_empty() {
2763        let resp = StoreMemoryResponse {
2764            source_id: "mem_new".into(),
2765            chunks_created: 1,
2766            memory_type: "fact".into(),
2767            entity_id: None,
2768            quality: None,
2769            warnings: vec![],
2770            extraction_method: "agent".into(),
2771            enrichment: String::new(),
2772            hint: String::new(),
2773            triggered_revisions: vec![],
2774            auto_superseded: vec![],
2775        };
2776        let out = format_capture_success(&resp);
2777        assert!(!out.contains("Triggered revisions"));
2778    }
2779
2780    #[test]
2781    fn format_capture_success_surfaces_auto_superseded() {
2782        let resp = StoreMemoryResponse {
2783            source_id: "mem_new".into(),
2784            chunks_created: 1,
2785            memory_type: "fact".into(),
2786            entity_id: None,
2787            quality: None,
2788            warnings: vec![],
2789            extraction_method: "agent".into(),
2790            enrichment: String::new(),
2791            hint: String::new(),
2792            triggered_revisions: vec![],
2793            auto_superseded: vec!["mem_old_xyz".to_string()],
2794        };
2795        let out = format_capture_success(&resp);
2796        assert!(out.contains("Auto-superseded"));
2797        assert!(out.contains("mem_old_xyz"));
2798        assert!(out.contains("no action needed"));
2799    }
2800
2801    #[test]
2802    fn format_capture_success_omits_auto_superseded_when_empty() {
2803        let resp = StoreMemoryResponse {
2804            source_id: "mem_new".into(),
2805            chunks_created: 1,
2806            memory_type: "fact".into(),
2807            entity_id: None,
2808            quality: None,
2809            warnings: vec![],
2810            extraction_method: "agent".into(),
2811            enrichment: String::new(),
2812            hint: String::new(),
2813            triggered_revisions: vec![],
2814            auto_superseded: vec![],
2815        };
2816        let out = format_capture_success(&resp);
2817        assert!(!out.contains("Auto-superseded"));
2818    }
2819
2820    #[test]
2821    fn doctor_local_memory_message_sets_expectations() {
2822        let msg = format_doctor_message(&serde_json::json!({
2823            "setup_completed": true,
2824            "mode": "basic-memory",
2825            "anthropic_key_configured": false,
2826            "local_model_selected": null,
2827            "local_model_loaded": null,
2828            "local_model_cached": false
2829        }));
2830
2831        assert!(msg.contains("Mode: Local Memory"));
2832        assert!(msg.contains("On-device model: not selected"));
2833        assert!(msg.contains("Distill cycles: off"));
2834        assert!(msg.contains("Local memory works now: capture, recall, and context are available"));
2835        assert!(msg.contains("origin model install"));
2836        assert!(msg.contains("origin key set anthropic"));
2837    }
2838
2839    #[test]
2840    fn doctor_on_device_model_message_shows_loaded_model() {
2841        let msg = format_doctor_message(&serde_json::json!({
2842            "setup_completed": true,
2843            "mode": "local-model",
2844            "anthropic_key_configured": false,
2845            "local_model_selected": "qwen3-1.7b",
2846            "local_model_loaded": "qwen3-1.7b",
2847            "local_model_cached": true
2848        }));
2849
2850        assert!(msg.contains("Mode: On-device Model"), "{msg}");
2851        assert!(
2852            msg.contains("On-device model: qwen3-1.7b (downloaded, loaded)"),
2853            "{msg}"
2854        );
2855        assert!(msg.contains("Distill cycles: enabled"), "{msg}");
2856        assert!(!msg.contains("Local memory works now"));
2857    }
2858
2859    #[test]
2860    fn doctor_unconfigured_message_names_three_setup_paths() {
2861        let msg = format_doctor_message(&serde_json::json!({
2862            "setup_completed": false,
2863            "mode": "unknown",
2864            "anthropic_key_configured": false,
2865            "local_model_selected": null,
2866            "local_model_loaded": null,
2867            "local_model_cached": false
2868        }));
2869
2870        assert!(msg.contains("Setup: not completed"));
2871        assert!(msg.contains("Run `origin setup`"));
2872        assert!(msg.contains("Local Memory, On-device Model, or Anthropic Key"));
2873    }
2874
2875    #[test]
2876    fn search_memory_request_serialization_excludes_entity() {
2877        let req = SearchMemoryRequest {
2878            query: "test".into(),
2879            limit: 10,
2880            memory_type: None,
2881            space: None,
2882            source_agent: None,
2883            rerank: false,
2884        };
2885        let json = serde_json::to_value(&req).unwrap();
2886        let obj = json.as_object().unwrap();
2887        assert!(
2888            !obj.contains_key("entity"),
2889            "entity must not be on the wire; got keys: {:?}",
2890            obj.keys().collect::<Vec<_>>()
2891        );
2892    }
2893
2894    #[test]
2895    fn chat_context_request_serialization_includes_domain() {
2896        #[allow(deprecated)]
2897        let req = ChatContextRequest {
2898            query: None,
2899            conversation_id: Some("topic".into()),
2900            max_chunks: 20,
2901            relevance_threshold: None,
2902            include_goals: true,
2903            space: Some("work".into()),
2904        };
2905        let json = serde_json::to_value(&req).unwrap();
2906        assert_eq!(json["space"], serde_json::json!("work"));
2907        assert_eq!(json["conversation_id"], serde_json::json!("topic"));
2908    }
2909
2910    #[test]
2911    fn chat_context_response_deserializes_with_profile_and_knowledge() {
2912        let json = r#"{
2913            "context": "user is Lucian, prefers Rust",
2914            "profile": {
2915                "narrative": "n",
2916                "identity": ["rust"],
2917                "preferences": [],
2918                "goals": []
2919            },
2920            "knowledge": {
2921                "pages": [],
2922                "decisions": [],
2923                "relevant_memories": [],
2924                "graph_context": []
2925            },
2926            "took_ms": 42.0,
2927            "token_estimates": {
2928                "tier1_identity": 10,
2929                "tier2_project": 20,
2930                "tier3_relevant": 30,
2931                "total": 60
2932            }
2933        }"#;
2934        let parsed: ChatContextResponse = serde_json::from_str(json).unwrap();
2935        assert_eq!(parsed.context, "user is Lucian, prefers Rust");
2936        assert_eq!(parsed.profile.identity, vec!["rust"]);
2937        assert_eq!(parsed.token_estimates.total, 60);
2938    }
2939
2940    #[test]
2941    fn capture_params_structured_fields_schema_is_object() {
2942        use schemars::schema_for;
2943
2944        let schema = schema_for!(CaptureParams);
2945        let json = serde_json::to_value(&schema).unwrap();
2946        let sf_schema = json
2947            .pointer("/properties/structured_fields")
2948            .expect("structured_fields property in schema");
2949        let type_val = sf_schema
2950            .pointer("/type")
2951            .unwrap_or(&serde_json::Value::Null);
2952        let type_str = match type_val {
2953            serde_json::Value::String(s) => s.clone(),
2954            serde_json::Value::Array(arr) => arr
2955                .iter()
2956                .filter_map(|v| v.as_str())
2957                .collect::<Vec<_>>()
2958                .join(","),
2959            other => panic!(
2960                "structured_fields schema lacks type constraint; got: {:?}",
2961                other
2962            ),
2963        };
2964        assert!(
2965            type_str.contains("object"),
2966            "expected object type, got: {}",
2967            type_str
2968        );
2969    }
2970
2971    // ===== Param deserialization: ForgetParams =====
2972
2973    #[test]
2974    fn test_forget_params() {
2975        let json = r#"{"memory_id": "mem_abc123"}"#;
2976        let params: ForgetParams = serde_json::from_str(json).unwrap();
2977        assert_eq!(params.memory_id, "mem_abc123");
2978    }
2979
2980    #[test]
2981    fn test_forget_params_missing_id_fails() {
2982        let json = r#"{}"#;
2983        let result = serde_json::from_str::<ForgetParams>(json);
2984        assert!(result.is_err());
2985    }
2986
2987    // ===== Request serialization: StoreMemoryRequest =====
2988
2989    #[test]
2990    fn test_store_request_includes_new_fields() {
2991        let req = StoreMemoryRequest {
2992            content: "test".into(),
2993            memory_type: Some("decision".into()),
2994            space: None,
2995            source_agent: Some("claude".into()),
2996            title: None,
2997            confidence: Some(0.9),
2998            supersedes: Some("old_id".into()),
2999            entity: Some("PostgreSQL".into()),
3000            entity_id: None,
3001            structured_fields: None,
3002            retrieval_cue: None,
3003        };
3004        let json = serde_json::to_value(&req).unwrap();
3005        assert_eq!(json["entity"], "PostgreSQL");
3006        assert_eq!(json["supersedes"], "old_id");
3007        assert!(json["confidence"].as_f64().unwrap() > 0.89);
3008        assert_eq!(json["source_agent"], "claude");
3009        assert!(json.get("user_id").is_none());
3010    }
3011
3012    #[test]
3013    fn test_store_request_minimal() {
3014        let req = StoreMemoryRequest {
3015            content: "hello".into(),
3016            memory_type: Some("fact".into()),
3017            space: None,
3018            source_agent: None,
3019            title: None,
3020            confidence: None,
3021            supersedes: None,
3022            entity: None,
3023            entity_id: None,
3024            structured_fields: None,
3025            retrieval_cue: None,
3026        };
3027        let json = serde_json::to_value(&req).unwrap();
3028        assert_eq!(json["content"], "hello");
3029        assert_eq!(json["memory_type"], "fact");
3030        assert!(json.get("user_id").is_none());
3031    }
3032
3033    // ===== Response deserialization: StoreMemoryResponse =====
3034
3035    #[test]
3036    fn test_store_response_with_new_fields() {
3037        let json = r#"{
3038            "source_id": "mem_xyz",
3039            "chunks_created": 2,
3040            "memory_type": "fact",
3041            "entity_id": "ent_abc",
3042            "quality": "high",
3043            "warnings": ["decision memory missing claim"],
3044            "extraction_method": "agent"
3045        }"#;
3046        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
3047        assert_eq!(resp.source_id, "mem_xyz");
3048        assert_eq!(resp.chunks_created, 2);
3049        assert_eq!(resp.memory_type, "fact");
3050        assert_eq!(resp.entity_id.as_deref(), Some("ent_abc"));
3051        assert_eq!(resp.quality.as_deref(), Some("high"));
3052        assert_eq!(resp.warnings, vec!["decision memory missing claim"]);
3053        assert_eq!(resp.extraction_method, "agent");
3054    }
3055
3056    #[test]
3057    fn test_store_response_backward_compat_no_new_fields() {
3058        // Old backend response without warnings/extraction_method
3059        let json = r#"{
3060            "source_id": "mem_old",
3061            "chunks_created": 1,
3062            "memory_type": "fact"
3063        }"#;
3064        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
3065        assert_eq!(resp.source_id, "mem_old");
3066        assert_eq!(resp.chunks_created, 1);
3067        assert_eq!(resp.memory_type, "fact");
3068        assert!(resp.entity_id.is_none());
3069        assert!(resp.quality.is_none());
3070        assert!(resp.warnings.is_empty());
3071        assert_eq!(resp.extraction_method, "unknown");
3072    }
3073
3074    #[test]
3075    fn test_store_response_with_warnings_and_extraction_method() {
3076        let json = r#"{
3077            "source_id": "mem_xyz",
3078            "chunks_created": 1,
3079            "memory_type": "decision",
3080            "warnings": ["decision memory missing required 'claim' field"],
3081            "extraction_method": "llm"
3082        }"#;
3083        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
3084        assert_eq!(resp.memory_type, "decision");
3085        assert_eq!(
3086            resp.warnings,
3087            vec!["decision memory missing required 'claim' field"]
3088        );
3089        assert_eq!(resp.extraction_method, "llm");
3090    }
3091
3092    // ===== Response deserialization: SearchResult =====
3093
3094    #[test]
3095    fn test_search_result_with_new_fields() {
3096        let json = r#"{
3097            "id": "1",
3098            "content": "We chose Postgres",
3099            "source": "memory",
3100            "source_id": "mem_1",
3101            "title": "DB decision",
3102            "url": null,
3103            "chunk_index": 0,
3104            "last_modified": 1711000000,
3105            "score": 0.95,
3106            "chunk_type": "memory",
3107            "language": "en",
3108            "semantic_unit": "sentence",
3109            "memory_type": "decision",
3110            "space": "origin",
3111            "source_agent": "claude",
3112            "confidence": 0.9,
3113            "confirmed": true,
3114            "stability": "standard",
3115            "supersedes": "mem_0",
3116            "summary": "DB choice",
3117            "entity_id": "ent_pg",
3118            "entity_name": "PostgreSQL",
3119            "quality": "high",
3120            "is_archived": false,
3121            "is_recap": false,
3122            "source_text": "We chose Postgres",
3123            "raw_score": 0.42
3124        }"#;
3125        let result: SearchResult = serde_json::from_str(json).unwrap();
3126        assert_eq!(result.chunk_type.as_deref(), Some("memory"));
3127        assert_eq!(result.language.as_deref(), Some("en"));
3128        assert_eq!(result.semantic_unit.as_deref(), Some("sentence"));
3129        assert_eq!(result.stability.as_deref(), Some("standard"));
3130        assert_eq!(result.supersedes.as_deref(), Some("mem_0"));
3131        assert_eq!(result.summary.as_deref(), Some("DB choice"));
3132        assert_eq!(result.entity_id.as_deref(), Some("ent_pg"));
3133        assert_eq!(result.entity_name.as_deref(), Some("PostgreSQL"));
3134        assert_eq!(result.quality.as_deref(), Some("high"));
3135        assert!(!result.is_archived);
3136        assert!(!result.is_recap);
3137        assert_eq!(result.source_text.as_deref(), Some("We chose Postgres"));
3138        assert!((result.raw_score - 0.42).abs() < f32::EPSILON);
3139    }
3140
3141    #[test]
3142    fn test_search_result_backward_compat_no_new_fields() {
3143        // Old backend response without entity/quality/archive/recap
3144        let json = r#"{
3145            "id": "1",
3146            "content": "test",
3147            "source": "memory",
3148            "source_id": "mem_1",
3149            "title": "test",
3150            "url": null,
3151            "chunk_index": 0,
3152            "last_modified": 1711000000,
3153            "score": 0.8,
3154            "memory_type": "fact",
3155            "space": null,
3156            "source_agent": null,
3157            "confidence": null,
3158            "confirmed": null
3159        }"#;
3160        let result: SearchResult = serde_json::from_str(json).unwrap();
3161        assert!(result.entity_id.is_none());
3162        assert!(result.entity_name.is_none());
3163        assert!(result.quality.is_none());
3164        assert!(!result.is_archived);
3165        assert!(!result.is_recap);
3166        assert!(result.structured_fields.is_none());
3167        assert!(result.retrieval_cue.is_none());
3168        assert_eq!(result.raw_score, 0.0);
3169    }
3170
3171    #[test]
3172    fn test_search_result_with_structured_fields_and_retrieval_cue() {
3173        let json = r#"{
3174            "id": "1",
3175            "content": "Lucian prefers dark mode",
3176            "source": "memory",
3177            "source_id": "mem_1",
3178            "title": "Dark mode preference",
3179            "url": null,
3180            "chunk_index": 0,
3181            "last_modified": 1711000000,
3182            "score": 0.92,
3183            "memory_type": "preference",
3184            "space": null,
3185            "source_agent": null,
3186            "confidence": null,
3187            "confirmed": null,
3188            "structured_fields": "{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}",
3189            "retrieval_cue": "What UI theme does Lucian prefer?"
3190        }"#;
3191        let result: SearchResult = serde_json::from_str(json).unwrap();
3192        assert_eq!(
3193            result.structured_fields.as_deref(),
3194            Some("{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}")
3195        );
3196        assert_eq!(
3197            result.retrieval_cue.as_deref(),
3198            Some("What UI theme does Lucian prefer?")
3199        );
3200        assert!(!result.is_archived);
3201        assert!(!result.is_recap);
3202        assert_eq!(result.raw_score, 0.0);
3203    }
3204
3205    #[test]
3206    fn test_search_result_knowledge_graph_source() {
3207        // Entity-boosted observation results from knowledge graph
3208        let json = r#"{
3209            "id": "obs_1",
3210            "content": "Prefers Rust over Go",
3211            "source": "knowledge_graph",
3212            "source_id": "ent_lucian",
3213            "title": "Lucian",
3214            "url": null,
3215            "chunk_index": 0,
3216            "last_modified": 1711000000,
3217            "score": 1.14,
3218            "memory_type": null,
3219            "space": null,
3220            "source_agent": null,
3221            "confidence": null,
3222            "confirmed": null,
3223            "entity_id": "ent_lucian",
3224            "entity_name": "Lucian"
3225        }"#;
3226        let result: SearchResult = serde_json::from_str(json).unwrap();
3227        assert_eq!(result.source, "knowledge_graph");
3228        assert_eq!(result.entity_id.as_deref(), Some("ent_lucian"));
3229        assert_eq!(result.entity_name.as_deref(), Some("Lucian"));
3230        assert!(!result.is_archived);
3231        assert!(!result.is_recap);
3232        assert_eq!(result.raw_score, 0.0);
3233    }
3234
3235    // ===== Transport security: forget blocks on HTTP =====
3236
3237    #[tokio::test]
3238    async fn test_forget_blocked_on_http_transport() {
3239        let server = make_server(TransportMode::Http, "agent", None);
3240        let result = server.forget_impl("mem_123").await.unwrap();
3241        // Should return error content, not an Err
3242        let content = &result.content[0];
3243        match content.raw {
3244            rmcp::model::RawContent::Text(ref tc) => {
3245                assert!(tc.text.contains("not available over remote connections"));
3246            }
3247            _ => panic!("expected text content"),
3248        }
3249    }
3250
3251    #[tokio::test]
3252    async fn test_forget_allowed_on_stdio_transport() {
3253        // This will fail with connection error (no server), which proves
3254        // the transport check passed and it tried to make the HTTP call.
3255        // The error comes back as CallToolResult with is_error: true
3256        // (tool-level failure), not McpError (protocol-level).
3257        let server = make_server(TransportMode::Stdio, "agent", None);
3258        let result = server.forget_impl("mem_123").await.unwrap();
3259        assert!(
3260            result.is_error.unwrap_or(false),
3261            "should fail with connection error, not transport block"
3262        );
3263    }
3264
3265    // ===== Transport security: revision wrappers block on HTTP =====
3266
3267    #[tokio::test]
3268    async fn test_accept_revision_blocked_on_http_transport() {
3269        let server = make_server(TransportMode::Http, "agent", None);
3270        let req = AcceptRevisionRequest {
3271            target_source_id: "mem_x".into(),
3272        };
3273        let result = server.accept_revision_impl(req).await.unwrap();
3274        let content = &result.content[0];
3275        match content.raw {
3276            rmcp::model::RawContent::Text(ref tc) => {
3277                assert!(tc.text.contains("not available over remote connections"));
3278            }
3279            _ => panic!("expected text content"),
3280        }
3281    }
3282
3283    #[tokio::test]
3284    async fn test_accept_revision_allowed_on_stdio_transport() {
3285        let server = make_server(TransportMode::Stdio, "agent", None);
3286        let req = AcceptRevisionRequest {
3287            target_source_id: "mem_x".into(),
3288        };
3289        let result = server.accept_revision_impl(req).await.unwrap();
3290        assert!(
3291            result.is_error.unwrap_or(false),
3292            "should fail with connection error, not transport block"
3293        );
3294    }
3295
3296    #[tokio::test]
3297    async fn test_dismiss_revision_blocked_on_http_transport() {
3298        let server = make_server(TransportMode::Http, "agent", None);
3299        let req = DismissRevisionRequest {
3300            target_source_id: "mem_x".into(),
3301        };
3302        let result = server.dismiss_revision_impl(req).await.unwrap();
3303        let content = &result.content[0];
3304        match content.raw {
3305            rmcp::model::RawContent::Text(ref tc) => {
3306                assert!(tc.text.contains("not available over remote connections"));
3307            }
3308            _ => panic!("expected text content"),
3309        }
3310    }
3311
3312    #[tokio::test]
3313    async fn test_dismiss_revision_allowed_on_stdio_transport() {
3314        let server = make_server(TransportMode::Stdio, "agent", None);
3315        let req = DismissRevisionRequest {
3316            target_source_id: "mem_x".into(),
3317        };
3318        let result = server.dismiss_revision_impl(req).await.unwrap();
3319        assert!(
3320            result.is_error.unwrap_or(false),
3321            "should fail with connection error, not transport block"
3322        );
3323    }
3324
3325    #[tokio::test]
3326    async fn test_dismiss_contradiction_blocked_on_http_transport() {
3327        let server = make_server(TransportMode::Http, "agent", None);
3328        let req = DismissContradictionRequest {
3329            source_id: "mem_x".into(),
3330        };
3331        let result = server.dismiss_contradiction_impl(req).await.unwrap();
3332        let content = &result.content[0];
3333        match content.raw {
3334            rmcp::model::RawContent::Text(ref tc) => {
3335                assert!(tc.text.contains("not available over remote connections"));
3336            }
3337            _ => panic!("expected text content"),
3338        }
3339    }
3340
3341    #[tokio::test]
3342    async fn test_dismiss_contradiction_allowed_on_stdio_transport() {
3343        let server = make_server(TransportMode::Stdio, "agent", None);
3344        let req = DismissContradictionRequest {
3345            source_id: "mem_x".into(),
3346        };
3347        let result = server.dismiss_contradiction_impl(req).await.unwrap();
3348        assert!(
3349            result.is_error.unwrap_or(false),
3350            "should fail with connection error, not transport block"
3351        );
3352    }
3353
3354    #[tokio::test]
3355    async fn test_confirm_entity_blocked_on_http_transport() {
3356        let server = make_server(TransportMode::Http, "agent", None);
3357        let params = ConfirmEntityParams {
3358            entity_id: "ent_x".into(),
3359            confirmed: true,
3360        };
3361        let result = server.confirm_entity_impl(params).await.unwrap();
3362        let content = &result.content[0];
3363        match content.raw {
3364            rmcp::model::RawContent::Text(ref tc) => {
3365                assert!(tc.text.contains("not available over remote connections"));
3366            }
3367            _ => panic!("expected text content"),
3368        }
3369    }
3370
3371    #[tokio::test]
3372    async fn test_confirm_entity_allowed_on_stdio_transport() {
3373        let server = make_server(TransportMode::Stdio, "agent", None);
3374        let params = ConfirmEntityParams {
3375            entity_id: "ent_x".into(),
3376            confirmed: true,
3377        };
3378        let result = server.confirm_entity_impl(params).await.unwrap();
3379        assert!(
3380            result.is_error.unwrap_or(false),
3381            "should fail with connection error, not transport block"
3382        );
3383    }
3384
3385    #[tokio::test]
3386    async fn test_confirm_observation_blocked_on_http_transport() {
3387        let server = make_server(TransportMode::Http, "agent", None);
3388        let params = ConfirmObservationParams {
3389            observation_id: "obs_x".into(),
3390            confirmed: true,
3391        };
3392        let result = server.confirm_observation_impl(params).await.unwrap();
3393        let content = &result.content[0];
3394        match content.raw {
3395            rmcp::model::RawContent::Text(ref tc) => {
3396                assert!(tc.text.contains("not available over remote connections"));
3397            }
3398            _ => panic!("expected text content"),
3399        }
3400    }
3401
3402    #[tokio::test]
3403    async fn test_confirm_observation_allowed_on_stdio_transport() {
3404        let server = make_server(TransportMode::Stdio, "agent", None);
3405        let params = ConfirmObservationParams {
3406            observation_id: "obs_x".into(),
3407            confirmed: true,
3408        };
3409        let result = server.confirm_observation_impl(params).await.unwrap();
3410        assert!(
3411            result.is_error.unwrap_or(false),
3412            "should fail with connection error, not transport block"
3413        );
3414    }
3415
3416    #[tokio::test]
3417    async fn test_update_observation_blocked_on_http_transport() {
3418        let server = make_server(TransportMode::Http, "agent", None);
3419        let params = UpdateObservationParams {
3420            observation_id: "obs_x".into(),
3421            content: "new content".into(),
3422        };
3423        let result = server.update_observation_impl(params).await.unwrap();
3424        let content = &result.content[0];
3425        match content.raw {
3426            rmcp::model::RawContent::Text(ref tc) => {
3427                assert!(tc.text.contains("not available over remote connections"));
3428            }
3429            _ => panic!("expected text content"),
3430        }
3431    }
3432
3433    #[tokio::test]
3434    async fn test_update_observation_allowed_on_stdio_transport() {
3435        let server = make_server(TransportMode::Stdio, "agent", None);
3436        let params = UpdateObservationParams {
3437            observation_id: "obs_x".into(),
3438            content: "new content".into(),
3439        };
3440        let result = server.update_observation_impl(params).await.unwrap();
3441        assert!(
3442            result.is_error.unwrap_or(false),
3443            "should fail with connection error, not transport block"
3444        );
3445    }
3446
3447    #[tokio::test]
3448    async fn test_update_page_blocked_on_http_transport() {
3449        let server = make_server(TransportMode::Http, "agent", None);
3450        let params = UpdatePageParams {
3451            page_id: "page_x".into(),
3452            content: "body".into(),
3453            source_memory_ids: vec!["mem_a".into()],
3454            summary: None,
3455        };
3456        let result = server.update_page_impl(params).await.unwrap();
3457        let content = &result.content[0];
3458        match content.raw {
3459            rmcp::model::RawContent::Text(ref tc) => {
3460                assert!(tc.text.contains("not available over remote connections"));
3461            }
3462            _ => panic!("expected text content"),
3463        }
3464    }
3465
3466    #[tokio::test]
3467    async fn test_update_page_allowed_on_stdio_transport() {
3468        let server = make_server(TransportMode::Stdio, "agent", None);
3469        let params = UpdatePageParams {
3470            page_id: "page_x".into(),
3471            content: "body".into(),
3472            source_memory_ids: vec!["mem_a".into()],
3473            summary: None,
3474        };
3475        let result = server.update_page_impl(params).await.unwrap();
3476        assert!(
3477            result.is_error.unwrap_or(false),
3478            "should fail with connection error, not transport block"
3479        );
3480    }
3481
3482    // ===== Refinement queue guards =====
3483
3484    #[tokio::test]
3485    async fn test_reject_refinement_blocked_on_http_transport() {
3486        let server = make_server(TransportMode::Http, "agent", None);
3487        let params = RejectRefinementParams {
3488            id: "merge_abc_def".into(),
3489        };
3490        let result = server.reject_refinement_impl(params).await.unwrap();
3491        let content = &result.content[0];
3492        match content.raw {
3493            rmcp::model::RawContent::Text(ref tc) => {
3494                assert!(tc.text.contains("not available over remote connections"));
3495            }
3496            _ => panic!("expected text content"),
3497        }
3498    }
3499
3500    #[tokio::test]
3501    async fn test_reject_refinement_allowed_on_stdio_transport() {
3502        let server = make_server(TransportMode::Stdio, "agent", None);
3503        let params = RejectRefinementParams {
3504            id: "merge_abc_def".into(),
3505        };
3506        let result = server.reject_refinement_impl(params).await.unwrap();
3507        assert!(
3508            result.is_error.unwrap_or(false),
3509            "should fail with connection error, not transport block"
3510        );
3511    }
3512
3513    #[tokio::test]
3514    async fn test_accept_refinement_blocked_on_http_transport() {
3515        let server = make_server(TransportMode::Http, "agent", None);
3516        let params = AcceptRefinementParams {
3517            id: "merge_abc_def".into(),
3518        };
3519        let result = server.accept_refinement_impl(params).await.unwrap();
3520        let content = &result.content[0];
3521        match content.raw {
3522            rmcp::model::RawContent::Text(ref tc) => {
3523                assert!(tc.text.contains("not available over remote connections"));
3524            }
3525            _ => panic!("expected text content"),
3526        }
3527    }
3528
3529    #[tokio::test]
3530    async fn test_accept_refinement_allowed_on_stdio_transport() {
3531        let server = make_server(TransportMode::Stdio, "agent", None);
3532        let params = AcceptRefinementParams {
3533            id: "merge_abc_def".into(),
3534        };
3535        let result = server.accept_refinement_impl(params).await.unwrap();
3536        assert!(
3537            result.is_error.unwrap_or(false),
3538            "should fail with connection error, not transport block"
3539        );
3540    }
3541
3542    // ===== Context default limit =====
3543
3544    #[test]
3545    fn test_context_request_default_limit() {
3546        let params = ContextParams {
3547            topic: Some("test".into()),
3548            limit: None,
3549            space: None,
3550        };
3551        #[allow(deprecated)]
3552        let req = ChatContextRequest {
3553            query: None,
3554            conversation_id: params.topic,
3555            max_chunks: params.limit.unwrap_or(20),
3556            relevance_threshold: None,
3557            include_goals: true,
3558            space: params.space,
3559        };
3560        assert_eq!(req.max_chunks, 20);
3561    }
3562
3563    #[test]
3564    fn test_context_request_custom_limit() {
3565        let params = ContextParams {
3566            topic: None,
3567            limit: Some(5),
3568            space: Some("work".into()),
3569        };
3570        #[allow(deprecated)]
3571        let req = ChatContextRequest {
3572            query: None,
3573            conversation_id: params.topic,
3574            max_chunks: params.limit.unwrap_or(20),
3575            relevance_threshold: None,
3576            include_goals: true,
3577            space: params.space,
3578        };
3579        assert_eq!(req.max_chunks, 5);
3580        assert_eq!(req.space.as_deref(), Some("work"));
3581    }
3582
3583    #[test]
3584    fn test_context_maps_topic_to_conversation_id() {
3585        let params = ContextParams {
3586            topic: Some("project Origin".into()),
3587            limit: None,
3588            space: None,
3589        };
3590        #[allow(deprecated)]
3591        let req = ChatContextRequest {
3592            query: None,
3593            conversation_id: params.topic.clone(),
3594            max_chunks: params.limit.unwrap_or(20),
3595            relevance_threshold: None,
3596            include_goals: true,
3597            space: params.space,
3598        };
3599        assert_eq!(req.conversation_id.as_deref(), Some("project Origin"));
3600    }
3601
3602    // ===== Remember request construction =====
3603
3604    #[test]
3605    fn test_capture_constructs_store_request_with_entity() {
3606        let server = make_server(TransportMode::Stdio, "claude", None);
3607        let params = CaptureParams {
3608            content: "Alice manages the frontend team".into(),
3609            memory_type: Some("fact".into()),
3610            space: Some("work".into()),
3611            entity: Some("Alice".into()),
3612            confidence: Some(0.9),
3613            supersedes: None,
3614            structured_fields: None,
3615            retrieval_cue: None,
3616        };
3617
3618        // Replicate capture_impl's request construction
3619        let source_agent = server.resolve_source_agent(None);
3620
3621        let req = StoreMemoryRequest {
3622            content: params.content,
3623            memory_type: params.memory_type,
3624            space: params.space,
3625            source_agent,
3626            title: None,
3627            confidence: params.confidence,
3628            supersedes: params.supersedes,
3629            entity: params.entity,
3630            entity_id: None,
3631            structured_fields: params.structured_fields.map(serde_json::Value::Object),
3632            retrieval_cue: params.retrieval_cue,
3633        };
3634
3635        let json = serde_json::to_value(&req).unwrap();
3636        assert_eq!(json["content"], "Alice manages the frontend team");
3637        assert_eq!(json["memory_type"], "fact");
3638        assert_eq!(json["space"], "work");
3639        assert_eq!(json["entity"], "Alice");
3640        assert!(json["confidence"].as_f64().unwrap() > 0.89);
3641        // stdio mode: no param, no client_name → falls back to agent_name "claude"
3642        assert_eq!(json["source_agent"], "claude");
3643    }
3644
3645    #[test]
3646    fn test_remember_http_mode_injects_agent() {
3647        let server = make_server(TransportMode::Http, "claude.ai", Some("lucian"));
3648        let source_agent = server.resolve_source_agent(None);
3649
3650        assert_eq!(source_agent, Some("claude.ai".into()));
3651    }
3652
3653    // ===== Recall request construction =====
3654
3655    #[test]
3656    fn test_recall_constructs_search_request() {
3657        let params = RecallParams {
3658            query: "database choices".into(),
3659            limit: Some(5),
3660            memory_type: Some("decision".into()),
3661            space: None,
3662            rerank: None,
3663        };
3664
3665        let req = SearchMemoryRequest {
3666            query: params.query,
3667            limit: params.limit.unwrap_or(10),
3668            memory_type: params.memory_type,
3669            space: params.space,
3670            source_agent: None,
3671            rerank: params.rerank.unwrap_or(false),
3672        };
3673
3674        let json = serde_json::to_value(&req).unwrap();
3675        assert_eq!(json["query"], "database choices");
3676        assert_eq!(json["limit"], 5);
3677        assert_eq!(json["memory_type"], "decision");
3678        assert!(json.get("entity").is_none());
3679        assert!(json["space"].is_null());
3680        assert!(json["source_agent"].is_null());
3681        assert_eq!(json["rerank"], false);
3682    }
3683
3684    #[test]
3685    fn test_recall_forwards_rerank_flag() {
3686        // When the caller passes rerank: Some(true), the constructed
3687        // SearchMemoryRequest must carry rerank=true through to the daemon.
3688        let params = RecallParams {
3689            query: "database choices".into(),
3690            limit: None,
3691            memory_type: None,
3692            space: None,
3693            rerank: Some(true),
3694        };
3695
3696        let req = SearchMemoryRequest {
3697            query: params.query,
3698            limit: params.limit.unwrap_or(10),
3699            memory_type: params.memory_type,
3700            space: params.space,
3701            source_agent: None,
3702            rerank: params.rerank.unwrap_or(false),
3703        };
3704
3705        assert!(
3706            req.rerank,
3707            "RecallParams.rerank=Some(true) must flow through to SearchMemoryRequest.rerank=true"
3708        );
3709        let json = serde_json::to_value(&req).unwrap();
3710        assert_eq!(json["rerank"], true);
3711    }
3712
3713    #[test]
3714    fn test_recall_params_schema_advertises_rerank() {
3715        // The schemars-derived JSON Schema for RecallParams must advertise
3716        // the rerank field so MCP clients (Claude Desktop, Cursor, etc.) see
3717        // it as an available parameter.
3718        let params_schema = serde_json::to_string(&schemars::schema_for!(RecallParams))
3719            .expect("RecallParams schema serializes");
3720        assert!(
3721            params_schema.contains("rerank"),
3722            "RecallParams schema must advertise the `rerank` field, got: {params_schema}"
3723        );
3724        assert!(
3725            params_schema.contains("cross-encoder"),
3726            "RecallParams.rerank description must mention cross-encoder so models understand the tradeoff, got: {params_schema}"
3727        );
3728    }
3729
3730    // ===== Memory type pass-through =====
3731
3732    /// CaptureParams must pass every canonical memory_type through to the
3733    /// daemon verbatim. The MCP layer is dumb wire — it doesn't validate or
3734    /// rewrite the value; the daemon owns that. Drift test sourced from
3735    /// `MemoryType::all_values()` so adding a variant extends coverage
3736    /// automatically.
3737    #[test]
3738    fn test_capture_passes_through_all_canonical_types() {
3739        for t in origin_types::MemoryType::all_values() {
3740            let params = CaptureParams {
3741                content: "test".into(),
3742                memory_type: Some((*t).to_string()),
3743                space: None,
3744                entity: None,
3745                confidence: None,
3746                supersedes: None,
3747                structured_fields: None,
3748                retrieval_cue: None,
3749            };
3750            assert_eq!(params.memory_type.as_deref(), Some(*t));
3751        }
3752    }
3753
3754    /// Legacy "goal" alias still flows through the wire untouched —
3755    /// `MemoryType::FromStr` folds it to "identity" daemon-side. The MCP
3756    /// layer must not pre-reject it (the daemon owns the fold decision).
3757    #[test]
3758    fn test_capture_passes_through_legacy_goal_alias() {
3759        let params = CaptureParams {
3760            content: "test".into(),
3761            memory_type: Some("goal".into()),
3762            space: None,
3763            entity: None,
3764            confidence: None,
3765            supersedes: None,
3766            structured_fields: None,
3767            retrieval_cue: None,
3768        };
3769        assert_eq!(params.memory_type.as_deref(), Some("goal"));
3770    }
3771
3772    // ===== Structured fields in remember params =====
3773
3774    #[test]
3775    fn test_capture_params_with_structured_fields_and_cue() {
3776        let json = r#"{
3777            "content": "Lucian prefers dark mode",
3778            "structured_fields": {"theme":"dark"},
3779            "retrieval_cue": "What theme does Lucian prefer?"
3780        }"#;
3781        let params: CaptureParams = serde_json::from_str(json).unwrap();
3782        let structured_fields = params.structured_fields.expect("structured_fields");
3783        assert_eq!(
3784            structured_fields.get("theme"),
3785            Some(&serde_json::Value::String("dark".into()))
3786        );
3787        assert_eq!(
3788            params.retrieval_cue.as_deref(),
3789            Some("What theme does Lucian prefer?")
3790        );
3791    }
3792
3793    #[test]
3794    fn test_store_request_with_structured_fields() {
3795        let req = StoreMemoryRequest {
3796            content: "test".into(),
3797            memory_type: Some("fact".into()),
3798            space: None,
3799            source_agent: None,
3800            title: None,
3801            confidence: None,
3802            supersedes: None,
3803            entity: None,
3804            entity_id: None,
3805            structured_fields: Some(serde_json::json!({"key":"val"})),
3806            retrieval_cue: Some("What is the key?".into()),
3807        };
3808        let json = serde_json::to_value(&req).unwrap();
3809        assert_eq!(json["structured_fields"], serde_json::json!({"key":"val"}));
3810        assert_eq!(json["retrieval_cue"], "What is the key?");
3811    }
3812
3813    // ===== ChatContextResponse deserialization =====
3814
3815    #[test]
3816    fn test_chat_context_response() {
3817        let json = r#"{
3818            "context": "User prefers dark mode. Works on Origin project.",
3819            "profile": {
3820                "narrative": "narrative",
3821                "identity": [],
3822                "preferences": [],
3823                "goals": []
3824            },
3825            "knowledge": {
3826                "pages": [],
3827                "decisions": [],
3828                "relevant_memories": [],
3829                "graph_context": []
3830            },
3831            "took_ms": 12.5,
3832            "token_estimates": {
3833                "tier1_identity": 1,
3834                "tier2_project": 2,
3835                "tier3_relevant": 3,
3836                "total": 6
3837            }
3838        }"#;
3839        let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
3840        assert!(!resp.context.is_empty());
3841        assert!(resp.profile.identity.is_empty());
3842        assert_eq!(resp.took_ms, 12.5);
3843        assert_eq!(resp.token_estimates.total, 6);
3844    }
3845
3846    #[test]
3847    fn test_chat_context_response_empty() {
3848        let json = r#"{
3849            "context": "",
3850            "profile": {
3851                "narrative": "",
3852                "identity": [],
3853                "preferences": [],
3854                "goals": []
3855            },
3856            "knowledge": {
3857                "pages": [],
3858                "decisions": [],
3859                "relevant_memories": [],
3860                "graph_context": []
3861            },
3862            "took_ms": 1.0,
3863            "token_estimates": {
3864                "tier1_identity": 0,
3865                "tier2_project": 0,
3866                "tier3_relevant": 0,
3867                "total": 0
3868            }
3869        }"#;
3870        let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
3871        assert!(resp.context.is_empty());
3872    }
3873
3874    // ===== with_instructions content assertions =====
3875    // These tests lock in the refined agent-facing guidance. If any
3876    // assertion fails, either the rule was intentionally changed
3877    // (update the test) or the refinement was accidentally dropped
3878    // (restore the rule).
3879
3880    fn server_instructions() -> String {
3881        let s = make_server(TransportMode::Stdio, "test", None);
3882        s.get_info()
3883            .instructions
3884            .expect("server must ship with_instructions")
3885    }
3886
3887    #[test]
3888    fn instructions_mention_cumulative_knowledge() {
3889        assert!(
3890            server_instructions().contains("cumulative"),
3891            "with_instructions must describe Origin as cumulative"
3892        );
3893    }
3894
3895    #[test]
3896    fn instructions_mention_shared_across_tools() {
3897        assert!(
3898            server_instructions().contains("shared across all"),
3899            "with_instructions must tell agents the store is shared across tools"
3900        );
3901    }
3902
3903    #[test]
3904    fn instructions_mention_how_user_thinks() {
3905        assert!(
3906            server_instructions().contains("how the user thinks"),
3907            "with_instructions must frame context as modeling how the user thinks"
3908        );
3909    }
3910
3911    #[test]
3912    fn instructions_use_proactive_framing() {
3913        assert!(
3914            server_instructions().contains("STORE PROACTIVELY"),
3915            "with_instructions must use STORE PROACTIVELY framing (not passive WHEN TO STORE)"
3916        );
3917    }
3918
3919    #[test]
3920    fn instructions_ban_tool_output_storage() {
3921        assert!(
3922            server_instructions().contains("Tool output or command results"),
3923            "with_instructions must explicitly rule out tool output as storage material"
3924        );
3925    }
3926
3927    #[test]
3928    fn instructions_ban_ghost_inferences() {
3929        assert!(
3930            server_instructions().contains("Your own inferences"),
3931            "with_instructions must rule out storing agent's own inferences user didn't express"
3932        );
3933    }
3934
3935    #[test]
3936    fn instructions_call_out_atomic_memory() {
3937        assert!(
3938            server_instructions().contains("Atomic: one idea per memory"),
3939            "with_instructions must call out the atomic-memory rule explicitly by name"
3940        );
3941    }
3942
3943    #[test]
3944    fn instructions_specify_declarative_writing() {
3945        assert!(
3946            server_instructions().contains("Declarative, not narrative"),
3947            "with_instructions must require declarative (not narrative) writing style"
3948        );
3949    }
3950
3951    #[test]
3952    fn instructions_default_to_omit_memory_type() {
3953        let i = server_instructions();
3954        assert!(
3955            i.contains("omit and trust the backend"),
3956            "with_instructions must default agents to omitting memory_type"
3957        );
3958        assert!(
3959            i.contains("do NOT set memory_type"),
3960            "with_instructions must explicitly say do NOT set memory_type by default"
3961        );
3962    }
3963
3964    #[test]
3965    fn instructions_list_every_canonical_memory_type() {
3966        let i = server_instructions();
3967        for ty in origin_types::MemoryType::all_values() {
3968            assert!(
3969                contains_word(&i, ty),
3970                "with_instructions must list canonical memory type \"{ty}\" so MCP clients see the full vocabulary",
3971            );
3972        }
3973    }
3974
3975    #[test]
3976    fn instructions_omit_legacy_goal_type() {
3977        let i = server_instructions();
3978        // "goal" (singular) is a legacy memory_type folded to Identity by
3979        // MemoryType::FromStr. The plural English noun "goals" (life goals,
3980        // profile.goals chat-context field) is a separate concern and must
3981        // NOT trigger this test — tokenizing on word boundaries lets one
3982        // through while still catching the legacy memory-type token.
3983        assert!(
3984            !contains_word(&i, "goal"),
3985            "with_instructions must not advertise legacy \"goal\" memory_type"
3986        );
3987    }
3988
3989    /// Tokenize on non-alphanumeric boundaries and check whether `needle`
3990    /// appears as a standalone token. Mirrors the helper used by the
3991    /// origin-types drift tests so "goals" (plural noun) does not false-match
3992    /// the legacy "goal" memory_type token.
3993    fn contains_word(haystack: &str, needle: &str) -> bool {
3994        haystack
3995            .split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
3996            .any(|tok| tok == needle)
3997    }
3998
3999    #[test]
4000    fn instructions_carve_out_decisions_for_decision_log() {
4001        let i = server_instructions();
4002        assert!(
4003            i.contains("Decision Log"),
4004            "with_instructions must name the Decision Log as the reason for explicit decision typing"
4005        );
4006        assert!(
4007            i.contains("memory_type=\"decision\""),
4008            "with_instructions must tell agents to set memory_type=\"decision\" explicitly for decisions"
4009        );
4010    }
4011
4012    // ===== tool-level and param-level description assertions =====
4013
4014    fn tool_descriptions() -> std::collections::HashMap<String, String> {
4015        let server = make_server(TransportMode::Stdio, "test", None);
4016        server
4017            .tool_router
4018            .list_all()
4019            .into_iter()
4020            .filter_map(|t| {
4021                let desc = t.description.as_ref()?.to_string();
4022                Some((t.name.to_string(), desc))
4023            })
4024            .collect()
4025    }
4026
4027    #[test]
4028    fn capture_description_calls_out_atomic() {
4029        let descriptions = tool_descriptions();
4030        let capture = descriptions.get("capture").expect("capture tool exists");
4031        assert!(
4032            capture.contains("Each call is one atomic idea"),
4033            "capture description must call out atomic-per-call explicitly, got: {capture}"
4034        );
4035    }
4036
4037    #[test]
4038    fn context_description_frames_modeling_user() {
4039        let descriptions = tool_descriptions();
4040        let ctx = descriptions.get("context").expect("context tool exists");
4041        assert!(
4042            ctx.contains("how the user thinks"),
4043            "context description must frame the result as modeling how the user thinks, got: {ctx}"
4044        );
4045    }
4046
4047    #[test]
4048    fn doctor_description_mentions_setup_mode() {
4049        let descriptions = tool_descriptions();
4050        let status = descriptions.get("doctor").expect("doctor tool exists");
4051        assert!(
4052            status.contains("Local Memory"),
4053            "doctor description must mention setup modes, got: {status}"
4054        );
4055        assert!(
4056            status.contains("On-device Model"),
4057            "doctor description must mention on-device setup, got: {status}"
4058        );
4059        assert!(
4060            status.contains("not part of the memory loop"),
4061            "doctor description must frame itself as diagnostic-only, got: {status}"
4062        );
4063    }
4064
4065    #[test]
4066    fn recall_memory_type_param_lists_two_level_filter() {
4067        let params_schema = serde_json::to_string(&schemars::schema_for!(RecallParams))
4068            .expect("RecallParams schema serializes");
4069        assert!(
4070            params_schema.contains("Two-level filter"),
4071            "RecallParams.memory_type must advertise the two-level filter, got schema: {params_schema}"
4072        );
4073        assert!(
4074            params_schema.contains("profile"),
4075            "RecallParams.memory_type must mention profile alias"
4076        );
4077        assert!(
4078            params_schema.contains("knowledge"),
4079            "RecallParams.memory_type must mention knowledge alias"
4080        );
4081    }
4082
4083    // ===== Knowledge graph / page CRUD =====
4084
4085    // --- CreateEntityParams ---
4086
4087    #[test]
4088    fn test_create_entity_params_minimal() {
4089        let json = r#"{"name": "Alice", "entity_type": "person"}"#;
4090        let params: CreateEntityParams = serde_json::from_str(json).unwrap();
4091        assert_eq!(params.name, "Alice");
4092        assert_eq!(params.entity_type, "person");
4093        assert!(params.space.is_none());
4094        assert!(params.confidence.is_none());
4095    }
4096
4097    #[test]
4098    fn test_create_entity_params_full() {
4099        let json = r#"{
4100            "name": "PostgreSQL",
4101            "entity_type": "tool",
4102            "space": "origin",
4103            "confidence": 0.9
4104        }"#;
4105        let params: CreateEntityParams = serde_json::from_str(json).unwrap();
4106        assert_eq!(params.name, "PostgreSQL");
4107        assert_eq!(params.entity_type, "tool");
4108        assert_eq!(params.space.as_deref(), Some("origin"));
4109        assert_eq!(params.confidence, Some(0.9));
4110    }
4111
4112    #[test]
4113    fn test_create_entity_params_missing_name_fails() {
4114        let json = r#"{"entity_type": "person"}"#;
4115        let result = serde_json::from_str::<CreateEntityParams>(json);
4116        assert!(result.is_err());
4117    }
4118
4119    #[test]
4120    fn test_create_entity_params_missing_type_fails() {
4121        let json = r#"{"name": "Alice"}"#;
4122        let result = serde_json::from_str::<CreateEntityParams>(json);
4123        assert!(result.is_err());
4124    }
4125
4126    #[test]
4127    fn test_create_entity_request_body_shape() {
4128        let server = make_server(TransportMode::Stdio, "claude", None);
4129        let params = CreateEntityParams {
4130            name: "Origin".into(),
4131            entity_type: "project".into(),
4132            space: Some("origin".into()),
4133            confidence: Some(0.95),
4134        };
4135        let source_agent = server.resolve_source_agent(None);
4136        let req = CreateEntityRequest {
4137            name: params.name,
4138            entity_type: params.entity_type,
4139            space: params.space,
4140            source_agent,
4141            confidence: params.confidence,
4142        };
4143        let json = serde_json::to_value(&req).unwrap();
4144        assert_eq!(json["name"], "Origin");
4145        assert_eq!(json["entity_type"], "project");
4146        assert_eq!(json["space"], "origin");
4147        assert_eq!(json["source_agent"], "claude");
4148        assert!(json["confidence"].as_f64().unwrap() > 0.94);
4149    }
4150
4151    // --- CreateRelationParams ---
4152
4153    #[test]
4154    fn test_create_relation_params() {
4155        let json = r#"{
4156            "from_entity": "Alice",
4157            "to_entity": "Origin",
4158            "relation_type": "works_on"
4159        }"#;
4160        let params: CreateRelationParams = serde_json::from_str(json).unwrap();
4161        assert_eq!(params.from_entity, "Alice");
4162        assert_eq!(params.to_entity, "Origin");
4163        assert_eq!(params.relation_type, "works_on");
4164    }
4165
4166    #[test]
4167    fn test_create_relation_params_missing_field_fails() {
4168        let json = r#"{"from_entity": "Alice", "to_entity": "Origin"}"#;
4169        let result = serde_json::from_str::<CreateRelationParams>(json);
4170        assert!(result.is_err());
4171    }
4172
4173    #[test]
4174    fn test_create_relation_request_body_shape() {
4175        let server = make_server(TransportMode::Stdio, "claude", None);
4176        let params = CreateRelationParams {
4177            from_entity: "Alice".into(),
4178            to_entity: "Origin".into(),
4179            relation_type: "prefers".into(),
4180        };
4181        let source_agent = server.resolve_source_agent(None);
4182        let req = CreateRelationRequest {
4183            from_entity: params.from_entity,
4184            to_entity: params.to_entity,
4185            relation_type: params.relation_type,
4186            source_agent,
4187            confidence: None,
4188            explanation: None,
4189            source_memory_id: None,
4190        };
4191        let json = serde_json::to_value(&req).unwrap();
4192        assert_eq!(json["from_entity"], "Alice");
4193        assert_eq!(json["to_entity"], "Origin");
4194        assert_eq!(json["relation_type"], "prefers");
4195        assert_eq!(json["source_agent"], "claude");
4196    }
4197
4198    // --- CreatePageParams ---
4199
4200    #[test]
4201    fn test_create_page_params_minimal() {
4202        let json = r#"{"title": "Origin daemon", "content": "Body text."}"#;
4203        let params: CreatePageParams = serde_json::from_str(json).unwrap();
4204        assert_eq!(params.title, "Origin daemon");
4205        assert_eq!(params.content, "Body text.");
4206        assert!(params.summary.is_none());
4207        assert!(params.entity_id.is_none());
4208        assert!(params.space.is_none());
4209        assert!(params.source_memory_ids.is_empty());
4210    }
4211
4212    #[test]
4213    fn test_create_page_params_full() {
4214        let json = r##"{
4215            "title": "Origin daemon",
4216            "content": "Markdown body with [[wikilinks]].",
4217            "summary": "The headless HTTP daemon at the heart of Origin.",
4218            "entity_id": "ent_origin",
4219            "space": "origin",
4220            "source_memory_ids": ["mem_1", "mem_2"]
4221        }"##;
4222        let params: CreatePageParams = serde_json::from_str(json).unwrap();
4223        assert_eq!(params.title, "Origin daemon");
4224        assert_eq!(
4225            params.summary.as_deref(),
4226            Some("The headless HTTP daemon at the heart of Origin.")
4227        );
4228        assert_eq!(params.entity_id.as_deref(), Some("ent_origin"));
4229        assert_eq!(params.space.as_deref(), Some("origin"));
4230        assert_eq!(params.source_memory_ids, vec!["mem_1", "mem_2"]);
4231    }
4232
4233    #[test]
4234    fn test_create_page_params_missing_required_fails() {
4235        let json = r#"{"title": "Only title"}"#;
4236        let result = serde_json::from_str::<CreatePageParams>(json);
4237        assert!(result.is_err());
4238    }
4239
4240    #[test]
4241    fn test_create_page_request_body_shape() {
4242        let params = CreatePageParams {
4243            title: "Page".into(),
4244            content: "Body".into(),
4245            summary: Some("S".into()),
4246            entity_id: Some("ent_1".into()),
4247            space: Some("origin".into()),
4248            source_memory_ids: vec!["mem_1".into()],
4249        };
4250        let req = CreateConceptRequest {
4251            title: params.title,
4252            content: params.content,
4253            summary: params.summary,
4254            entity_id: params.entity_id,
4255            space: params.space,
4256            source_memory_ids: params.source_memory_ids,
4257            creation_kind: None,
4258        };
4259        let json = serde_json::to_value(&req).unwrap();
4260        assert_eq!(json["title"], "Page");
4261        assert_eq!(json["content"], "Body");
4262        assert_eq!(json["summary"], "S");
4263        assert_eq!(json["entity_id"], "ent_1");
4264        assert_eq!(json["space"], "origin");
4265        assert_eq!(json["source_memory_ids"], serde_json::json!(["mem_1"]));
4266    }
4267
4268    // --- DeletePageParams ---
4269
4270    #[test]
4271    fn test_delete_page_params() {
4272        let json = r#"{"page_id": "page_abc"}"#;
4273        let params: DeletePageParams = serde_json::from_str(json).unwrap();
4274        assert_eq!(params.page_id, "page_abc");
4275    }
4276
4277    #[test]
4278    fn test_delete_page_params_missing_fails() {
4279        let json = r#"{}"#;
4280        let result = serde_json::from_str::<DeletePageParams>(json);
4281        assert!(result.is_err());
4282    }
4283
4284    #[tokio::test]
4285    async fn test_delete_page_blocked_on_http_transport() {
4286        let server = make_server(TransportMode::Http, "agent", None);
4287        let result = server.delete_page_impl("page_123").await.unwrap();
4288        let content = &result.content[0];
4289        match content.raw {
4290            rmcp::model::RawContent::Text(ref tc) => {
4291                assert!(tc.text.contains("not available over remote connections"));
4292            }
4293            _ => panic!("expected text content"),
4294        }
4295    }
4296
4297    #[tokio::test]
4298    async fn test_delete_page_allowed_on_stdio_transport() {
4299        // No daemon running → falls through to connection error (not transport block).
4300        let server = make_server(TransportMode::Stdio, "agent", None);
4301        let result = server.delete_page_impl("page_123").await.unwrap();
4302        assert!(
4303            result.is_error.unwrap_or(false),
4304            "should fail with connection error, not transport block"
4305        );
4306    }
4307
4308    #[tokio::test]
4309    async fn delete_observation_refuses_http_transport() {
4310        let server = make_server(TransportMode::Http, "agent", None);
4311        let params = DeleteObservationParams {
4312            observation_id: "obs_123".to_string(),
4313        };
4314        let result = server.delete_observation_impl(params).await.unwrap();
4315        let content = &result.content[0];
4316        match content.raw {
4317            rmcp::model::RawContent::Text(ref tc) => {
4318                assert!(tc.text.contains("not available over remote connections"));
4319            }
4320            _ => panic!("expected text content"),
4321        }
4322    }
4323
4324    // --- GetPageParams ---
4325
4326    #[test]
4327    fn test_get_page_params() {
4328        let json = r#"{"page_id": "page_abc"}"#;
4329        let params: GetPageParams = serde_json::from_str(json).unwrap();
4330        assert_eq!(params.page_id, "page_abc");
4331    }
4332
4333    #[test]
4334    fn test_get_page_params_missing_fails() {
4335        let json = r#"{}"#;
4336        let result = serde_json::from_str::<GetPageParams>(json);
4337        assert!(result.is_err());
4338    }
4339
4340    // --- ListMemoriesParams ---
4341
4342    #[test]
4343    fn test_list_memories_params_empty() {
4344        let json = r#"{}"#;
4345        let params: ListMemoriesParams = serde_json::from_str(json).unwrap();
4346        assert!(params.memory_type.is_none());
4347        assert!(params.space.is_none());
4348        assert!(params.limit.is_none());
4349    }
4350
4351    #[test]
4352    fn test_list_memories_params_full() {
4353        let json = r#"{"memory_type": "decision", "space": "origin", "limit": 50}"#;
4354        let params: ListMemoriesParams = serde_json::from_str(json).unwrap();
4355        assert_eq!(params.memory_type.as_deref(), Some("decision"));
4356        assert_eq!(params.space.as_deref(), Some("origin"));
4357        assert_eq!(params.limit, Some(50));
4358    }
4359
4360    #[test]
4361    fn test_list_memories_params_limit_as_string() {
4362        // MCP clients sometimes serialize numeric params as strings.
4363        let json = r#"{"limit": "25"}"#;
4364        let params: ListMemoriesParams = serde_json::from_str(json).unwrap();
4365        assert_eq!(params.limit, Some(25));
4366    }
4367
4368    #[test]
4369    fn test_list_memories_request_body_shape() {
4370        let params = ListMemoriesParams {
4371            memory_type: Some("fact".into()),
4372            space: None,
4373            limit: Some(10),
4374        };
4375        let req = ListMemoriesRequest {
4376            memory_type: params.memory_type,
4377            space: params.space,
4378            limit: params.limit.unwrap_or(100),
4379            confirmed: None,
4380        };
4381        let json = serde_json::to_value(&req).unwrap();
4382        assert_eq!(json["memory_type"], "fact");
4383        assert!(json["space"].is_null());
4384        assert_eq!(json["limit"], 10);
4385    }
4386
4387    #[test]
4388    fn test_list_memories_request_default_limit() {
4389        let params = ListMemoriesParams {
4390            memory_type: None,
4391            space: None,
4392            limit: None,
4393        };
4394        let req = ListMemoriesRequest {
4395            memory_type: params.memory_type,
4396            space: params.space,
4397            limit: params.limit.unwrap_or(100),
4398            confirmed: None,
4399        };
4400        assert_eq!(req.limit, 100);
4401    }
4402
4403    // --- UpdatePageParams ---
4404
4405    #[test]
4406    fn test_update_page_params_minimal() {
4407        let json =
4408            r#"{"page_id": "page_abc", "content": "fresh body", "source_memory_ids": ["mem_1"]}"#;
4409        let params: UpdatePageParams = serde_json::from_str(json).unwrap();
4410        assert_eq!(params.page_id, "page_abc");
4411        assert_eq!(params.content, "fresh body");
4412        assert_eq!(params.source_memory_ids, vec!["mem_1"]);
4413        assert!(params.summary.is_none());
4414    }
4415
4416    #[test]
4417    fn test_update_page_params_with_summary() {
4418        let json = r#"{
4419            "page_id": "page_abc",
4420            "content": "body",
4421            "source_memory_ids": ["mem_1", "mem_2"],
4422            "summary": "Refreshed claim."
4423        }"#;
4424        let params: UpdatePageParams = serde_json::from_str(json).unwrap();
4425        assert_eq!(params.summary.as_deref(), Some("Refreshed claim."));
4426        assert_eq!(params.source_memory_ids.len(), 2);
4427    }
4428
4429    #[test]
4430    fn test_update_page_params_missing_required_fails() {
4431        // Missing source_memory_ids is a hard fail — refresh without sources
4432        // would orphan the page from its provenance trail.
4433        let json = r#"{"page_id": "page_abc", "content": "body"}"#;
4434        let result = serde_json::from_str::<UpdatePageParams>(json);
4435        assert!(result.is_err());
4436    }
4437
4438    #[test]
4439    fn test_update_page_request_body_shape() {
4440        let params = UpdatePageParams {
4441            page_id: "page_abc".into(),
4442            content: "Body".into(),
4443            source_memory_ids: vec!["mem_1".into()],
4444            summary: Some("S".into()),
4445        };
4446        let req = origin_types::requests::RefreshPageRequest {
4447            content: params.content,
4448            source_memory_ids: params.source_memory_ids,
4449            summary: params.summary,
4450        };
4451        let json = serde_json::to_value(&req).unwrap();
4452        assert_eq!(json["content"], "Body");
4453        assert_eq!(json["source_memory_ids"], serde_json::json!(["mem_1"]));
4454        assert_eq!(json["summary"], "S");
4455        // page_id stays in the URL, never the body.
4456        assert!(json.get("page_id").is_none());
4457    }
4458
4459    // --- Tool registration ---
4460
4461    #[test]
4462    fn new_crud_tools_are_registered() {
4463        let descriptions = tool_descriptions();
4464        for name in [
4465            "create_entity",
4466            "create_relation",
4467            "create_observation",
4468            "confirm_entity",
4469            "update_observation",
4470            "confirm_observation",
4471            "delete_observation",
4472            "create_page",
4473            "update_page",
4474            "delete_page",
4475            "get_page",
4476            "get_page_links",
4477            "list_memories",
4478            "search_pages",
4479            "list_pages_recent",
4480            "list_spaces",
4481        ] {
4482            assert!(
4483                descriptions.contains_key(name),
4484                "tool `{name}` must be registered, got: {:?}",
4485                descriptions.keys().collect::<Vec<_>>()
4486            );
4487        }
4488    }
4489
4490    #[test]
4491    fn capture_memory_type_schema_lists_every_canonical_type() {
4492        let params_schema = serde_json::to_string(&schemars::schema_for!(CaptureParams))
4493            .expect("CaptureParams schema serializes");
4494        for ty in origin_types::MemoryType::all_values() {
4495            assert!(
4496                params_schema.contains(ty),
4497                "CaptureParams.memory_type schema must list canonical type \"{ty}\", got: {params_schema}"
4498            );
4499        }
4500    }
4501
4502    #[test]
4503    fn recall_memory_type_schema_lists_every_canonical_type() {
4504        let params_schema = serde_json::to_string(&schemars::schema_for!(RecallParams))
4505            .expect("RecallParams schema serializes");
4506        for ty in origin_types::MemoryType::all_values() {
4507            assert!(
4508                params_schema.contains(ty),
4509                "RecallParams.memory_type schema must list canonical type \"{ty}\", got: {params_schema}"
4510            );
4511        }
4512    }
4513
4514    #[test]
4515    fn create_entity_schema_documents_name_and_type() {
4516        let schema = serde_json::to_string(&schemars::schema_for!(CreateEntityParams))
4517            .expect("CreateEntityParams schema serializes");
4518        assert!(
4519            schema.contains("Canonical entity name"),
4520            "schema must describe `name` field"
4521        );
4522        assert!(
4523            schema.contains("Entity category"),
4524            "schema must describe `entity_type` field"
4525        );
4526    }
4527
4528    #[test]
4529    fn create_page_schema_documents_traceability() {
4530        let schema = serde_json::to_string(&schemars::schema_for!(CreatePageParams))
4531            .expect("CreatePageParams schema serializes");
4532        assert!(
4533            schema.contains("traceability"),
4534            "schema must spell out why source_memory_ids matter"
4535        );
4536    }
4537
4538    #[test]
4539    fn delete_page_tool_is_marked_destructive() {
4540        let server = make_server(TransportMode::Stdio, "test", None);
4541        let tool = server
4542            .tool_router
4543            .list_all()
4544            .into_iter()
4545            .find(|t| t.name == "delete_page")
4546            .expect("delete_page registered");
4547        let ann = tool.annotations.as_ref().expect("annotations present");
4548        assert_eq!(
4549            ann.destructive_hint,
4550            Some(true),
4551            "delete_page must declare destructive_hint=true"
4552        );
4553    }
4554
4555    // --- SearchPagesParams ---
4556
4557    #[test]
4558    fn test_search_pages_params_minimal() {
4559        let json = r#"{"query": "mutex deadlock"}"#;
4560        let params: SearchPagesParams = serde_json::from_str(json).unwrap();
4561        assert_eq!(params.query, "mutex deadlock");
4562        assert!(params.limit.is_none());
4563    }
4564
4565    #[test]
4566    fn test_search_pages_params_full() {
4567        let json = r#"{"query": "distill architecture", "limit": 5}"#;
4568        let params: SearchPagesParams = serde_json::from_str(json).unwrap();
4569        assert_eq!(params.query, "distill architecture");
4570        assert_eq!(params.limit, Some(5));
4571    }
4572
4573    #[test]
4574    fn test_search_pages_params_missing_query_fails() {
4575        let json = r#"{"limit": 10}"#;
4576        let result = serde_json::from_str::<SearchPagesParams>(json);
4577        assert!(result.is_err());
4578    }
4579
4580    #[test]
4581    fn test_search_pages_params_limit_as_string() {
4582        let json = r#"{"query": "x", "limit": "3"}"#;
4583        let params: SearchPagesParams = serde_json::from_str(json).unwrap();
4584        assert_eq!(params.limit, Some(3));
4585    }
4586
4587    #[test]
4588    fn test_search_pages_request_body_shape() {
4589        let params = SearchPagesParams {
4590            query: "mutex".into(),
4591            limit: Some(7),
4592            page_type: None,
4593        };
4594        let req = SearchPagesRequest {
4595            query: params.query,
4596            limit: params.limit,
4597            page_type: params.page_type,
4598        };
4599        let json = serde_json::to_value(&req).unwrap();
4600        assert_eq!(json["query"], "mutex");
4601        assert_eq!(json["limit"], 7);
4602    }
4603
4604    // --- ListPagesRecentParams ---
4605
4606    #[test]
4607    fn test_list_pages_recent_params_empty() {
4608        let json = r#"{}"#;
4609        let params: ListPagesRecentParams = serde_json::from_str(json).unwrap();
4610        assert!(params.limit.is_none());
4611        assert!(params.since_ms.is_none());
4612    }
4613
4614    #[test]
4615    fn test_list_pages_recent_params_full() {
4616        let json = r#"{"limit": 20, "since_ms": 1715000000000}"#;
4617        let params: ListPagesRecentParams = serde_json::from_str(json).unwrap();
4618        assert_eq!(params.limit, Some(20));
4619        assert_eq!(params.since_ms, Some(1715000000000));
4620    }
4621
4622    #[test]
4623    fn test_list_pages_recent_params_string_numbers() {
4624        let json = r#"{"limit": "15", "since_ms": "1715000000000"}"#;
4625        let params: ListPagesRecentParams = serde_json::from_str(json).unwrap();
4626        assert_eq!(params.limit, Some(15));
4627        assert_eq!(params.since_ms, Some(1715000000000));
4628    }
4629
4630    #[test]
4631    fn list_pages_recent_url_construction() {
4632        // Exercises the actual builder used by `list_pages_recent_impl` so the
4633        // test cannot drift from production behavior.
4634        assert_eq!(build_recent_pages_path(None, None), "/api/pages/recent");
4635        assert_eq!(
4636            build_recent_pages_path(Some(5), None),
4637            "/api/pages/recent?limit=5"
4638        );
4639        assert_eq!(
4640            build_recent_pages_path(None, Some(123)),
4641            "/api/pages/recent?since_ms=123"
4642        );
4643        assert_eq!(
4644            build_recent_pages_path(Some(10), Some(456)),
4645            "/api/pages/recent?limit=10&since_ms=456"
4646        );
4647        // Negative since_ms (i64 — sentinel like "-1" must still serialize).
4648        assert_eq!(
4649            build_recent_pages_path(None, Some(-1)),
4650            "/api/pages/recent?since_ms=-1"
4651        );
4652    }
4653
4654    #[test]
4655    fn search_pages_and_list_pages_recent_are_read_only() {
4656        let server = make_server(TransportMode::Stdio, "test", None);
4657        for name in ["search_pages", "list_pages_recent"] {
4658            let tool = server
4659                .tool_router
4660                .list_all()
4661                .into_iter()
4662                .find(|t| t.name == name)
4663                .unwrap_or_else(|| panic!("`{name}` registered"));
4664            let ann = tool.annotations.as_ref().expect("annotations present");
4665            assert_eq!(
4666                ann.read_only_hint,
4667                Some(true),
4668                "`{name}` must declare read_only_hint=true"
4669            );
4670        }
4671    }
4672
4673    #[test]
4674    fn accept_refinement_response_typed_deserialize() {
4675        let raw = r#"{"id":"ref_xyz","action_applied":"entity_merge"}"#;
4676        let parsed: AcceptRefinementResponse = serde_json::from_str(raw).unwrap();
4677        assert_eq!(parsed.id, "ref_xyz");
4678        assert_eq!(parsed.action_applied, "entity_merge");
4679    }
4680
4681    #[test]
4682    fn accept_refinement_response_rejects_extra_envelope() {
4683        // Daemon must not wrap successful response under an extra key — the
4684        // lesson_mcp_typed_deserialize guard. This test verifies a non-typed
4685        // shape fails to deserialize loud.
4686        let wrong = r#"{"data":{"id":"ref_xyz","action_applied":"entity_merge"}}"#;
4687        let result: Result<AcceptRefinementResponse, _> = serde_json::from_str(wrong);
4688        assert!(
4689            result.is_err(),
4690            "envelope-wrapped response must fail typed deserialize"
4691        );
4692    }
4693
4694    // ===== DistillParams force field =====
4695
4696    #[test]
4697    fn distill_params_deserializes_force() {
4698        let p: DistillParams =
4699            serde_json::from_str(r#"{"target":"page_xyz","force":true}"#).unwrap();
4700        assert_eq!(p.target.as_deref(), Some("page_xyz"));
4701        assert_eq!(p.force, Some(true));
4702    }
4703
4704    #[test]
4705    fn distill_params_defaults_force_to_none() {
4706        let p: DistillParams = serde_json::from_str(r#"{"target":"foo"}"#).unwrap();
4707        assert_eq!(p.force, None);
4708    }
4709
4710    // ===== effective_space =====
4711
4712    #[test]
4713    fn locked_overrides_inbound_space() {
4714        let _guard = crate::lock_state::ENV_LOCK.lock().unwrap();
4715        std::env::set_var("ORIGIN_SPACE", "career");
4716        crate::lock_state::init_from_env();
4717
4718        let inbound = Some("ideas".to_string());
4719        let resolved = effective_space(&inbound);
4720        assert_eq!(resolved.as_deref(), Some("career"));
4721    }
4722
4723    #[test]
4724    fn unlocked_passes_inbound_through() {
4725        let _guard = crate::lock_state::ENV_LOCK.lock().unwrap();
4726        std::env::remove_var("ORIGIN_SPACE");
4727        crate::lock_state::init_from_env();
4728
4729        let inbound = Some("ideas".to_string());
4730        let resolved = effective_space(&inbound);
4731        assert_eq!(resolved.as_deref(), Some("ideas"));
4732    }
4733
4734    #[test]
4735    fn locked_with_no_inbound_yields_locked() {
4736        let _guard = crate::lock_state::ENV_LOCK.lock().unwrap();
4737        std::env::set_var("ORIGIN_SPACE", "career");
4738        crate::lock_state::init_from_env();
4739
4740        let inbound: Option<String> = None;
4741        let resolved = effective_space(&inbound);
4742        assert_eq!(resolved.as_deref(), Some("career"));
4743    }
4744
4745    #[test]
4746    fn unlocked_with_no_inbound_yields_none() {
4747        let _guard = crate::lock_state::ENV_LOCK.lock().unwrap();
4748        std::env::remove_var("ORIGIN_SPACE");
4749        crate::lock_state::init_from_env();
4750
4751        let inbound: Option<String> = None;
4752        let resolved = effective_space(&inbound);
4753        assert_eq!(resolved, None);
4754    }
4755
4756    // ===== Schema gating =====
4757
4758    /// Baseline: the raw `capture` schema from the tool router includes `space`.
4759    #[test]
4760    fn capture_schema_has_space_in_raw_router() {
4761        let tools = OriginMcpServer::tool_router().list_all();
4762        let capture = tools
4763            .into_iter()
4764            .find(|t| t.name == "capture")
4765            .expect("capture tool registered");
4766        let props = capture
4767            .input_schema
4768            .get("properties")
4769            .and_then(|v| v.as_object())
4770            .expect("capture has properties");
4771        assert!(
4772            props.contains_key("space"),
4773            "baseline: capture schema must have space before gating"
4774        );
4775    }
4776
4777    /// When locked, `strip_space_from_tool_schema` removes `space` from properties.
4778    #[test]
4779    fn capture_tool_schema_omits_space_when_locked() {
4780        let _guard = crate::lock_state::ENV_LOCK.lock().unwrap();
4781        std::env::set_var("ORIGIN_SPACE", "career");
4782        crate::lock_state::init_from_env();
4783
4784        let tools = OriginMcpServer::tool_router().list_all();
4785        let tools: Vec<_> = tools
4786            .into_iter()
4787            .map(strip_space_from_tool_schema)
4788            .collect();
4789        let capture = tools
4790            .iter()
4791            .find(|t| t.name == "capture")
4792            .expect("capture tool registered");
4793        let props = capture
4794            .input_schema
4795            .get("properties")
4796            .and_then(|v| v.as_object())
4797            .expect("capture has properties");
4798        assert!(
4799            !props.contains_key("space"),
4800            "space field must be omitted from capture schema when ORIGIN_SPACE is locked"
4801        );
4802
4803        // Clean up.
4804        std::env::remove_var("ORIGIN_SPACE");
4805        crate::lock_state::init_from_env();
4806    }
4807
4808    /// Unlocked: `list_tools` equivalent — raw router listing preserves `space`.
4809    #[test]
4810    fn capture_tool_schema_includes_space_when_unlocked() {
4811        let _guard = crate::lock_state::ENV_LOCK.lock().unwrap();
4812        std::env::remove_var("ORIGIN_SPACE");
4813        crate::lock_state::init_from_env();
4814
4815        // When not locked, tools are returned as-is (no stripping).
4816        let tools = OriginMcpServer::tool_router().list_all();
4817        let capture = tools
4818            .iter()
4819            .find(|t| t.name == "capture")
4820            .expect("capture tool registered");
4821        let props = capture
4822            .input_schema
4823            .get("properties")
4824            .and_then(|v| v.as_object())
4825            .expect("capture has properties");
4826        assert!(
4827            props.contains_key("space"),
4828            "space field must be present in capture schema when ORIGIN_SPACE is not locked"
4829        );
4830    }
4831}