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