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