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