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 pub domain: Option<String>,
95 #[schemars(
96 description = "Person, project, or tool name to anchor to (e.g. 'Alice', 'Origin', 'PostgreSQL'). Helps build the knowledge graph."
97 )]
98 pub entity: Option<String>,
99 #[schemars(
100 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."
101 )]
102 pub confidence: Option<f32>,
103 #[schemars(
104 description = "source_id of a memory this replaces. Use when correcting or updating an existing memory — get the ID from recall first."
105 )]
106 pub supersedes: Option<String>,
107 #[schemars(
108 description = "Pre-extracted structured fields as a JSON object. Auto-extracted by backend; only supply if you have high-quality structured data already."
109 )]
110 pub structured_fields: Option<serde_json::Map<String, serde_json::Value>>,
111 #[schemars(
112 description = "A question this memory answers, for search matching. Auto-generated by backend; only supply to override."
113 )]
114 pub retrieval_cue: Option<String>,
115}
116
117#[derive(Debug, Deserialize, schemars::JsonSchema)]
118pub struct RecallParams {
119 #[schemars(
120 description = "Natural language search. Be specific: 'Alice database preference' finds more than 'database stuff'."
121 )]
122 pub query: String,
123 #[schemars(
124 description = "Max results, default 10. Use 3-5 for quick lookups, 10-20 for exploration."
125 )]
126 #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
127 pub limit: Option<usize>,
128 #[schemars(description = origin_types::MEMORY_TYPE_FILTER_DESCRIPTION)]
129 pub memory_type: Option<String>,
130 #[schemars(description = "Filter by topic scope.")]
131 pub domain: Option<String>,
132}
133
134#[derive(Debug, Deserialize, schemars::JsonSchema)]
135pub struct ContextParams {
136 #[schemars(
137 description = "Topic or conversation summary to focus context retrieval. Omit at session start for general orientation; provide when shifting topics."
138 )]
139 pub topic: Option<String>,
140 #[schemars(
141 description = "Max context chunks, default 20. Increase for complex topics, decrease for quick check-ins."
142 )]
143 #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
144 pub limit: Option<usize>,
145 #[schemars(
146 description = "Scope context to a domain/space (e.g. 'work', 'personal'). Auto-detected from conversation if omitted."
147 )]
148 pub domain: Option<String>,
149}
150
151#[derive(Debug, Deserialize, schemars::JsonSchema)]
152pub struct ForgetParams {
153 #[schemars(
154 description = "The source_id of the memory to delete. Get this from recall results first."
155 )]
156 pub memory_id: String,
157}
158
159#[derive(Debug, Deserialize, schemars::JsonSchema)]
160pub struct DistillParams {
161 #[schemars(
162 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 domain value (e.g. `work`, `personal`) to scope to that domain. 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."
163 )]
164 #[serde(default, alias = "page_id")]
165 pub target: Option<String>,
166}
167
168#[derive(Debug, Deserialize, schemars::JsonSchema)]
169pub struct ListPendingParams {
170 #[schemars(
171 description = "Max results, default 20. Increase for full audit, decrease for quick check-in."
172 )]
173 #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
174 pub limit: Option<usize>,
175}
176
177#[derive(Debug, Deserialize, schemars::JsonSchema)]
178pub struct ConfirmMemoryParams {
179 #[schemars(
180 description = "The source_id of the memory to confirm. Get this from list_pending or recall results."
181 )]
182 pub memory_id: String,
183}
184
185#[derive(Debug, Deserialize, schemars::JsonSchema)]
188pub struct CreateEntityParams {
189 #[schemars(
190 description = "Canonical entity name (e.g. 'Alice', 'Origin', 'PostgreSQL'). Use the exact, full name — aliases resolve to this canonical form."
191 )]
192 pub name: String,
193 #[schemars(
194 description = "Entity category: 'person', 'project', 'tool', 'place', 'organization', etc. Free-form string; choose the noun that best describes what it is."
195 )]
196 pub entity_type: String,
197 #[schemars(description = "Topic scope (e.g. 'work', 'origin'). Optional.")]
198 pub domain: Option<String>,
199 #[schemars(
200 description = "0.0-1.0 confidence in the entity assertion. Leave unset for caller-default."
201 )]
202 pub confidence: Option<f32>,
203}
204
205#[derive(Debug, Deserialize, schemars::JsonSchema)]
206pub struct CreateRelationParams {
207 #[schemars(
208 description = "Canonical name of the source entity (e.g. 'Alice'). Must exist or will be created on the daemon side."
209 )]
210 pub from_entity: String,
211 #[schemars(
212 description = "Canonical name of the target entity (e.g. 'Origin'). Must exist or will be created on the daemon side."
213 )]
214 pub to_entity: String,
215 #[schemars(
216 description = "Verb describing the directed relation (e.g. 'works_on', 'prefers', 'uses', 'depends_on'). Snake_case, present-tense."
217 )]
218 pub relation_type: String,
219}
220
221#[derive(Debug, Deserialize, schemars::JsonSchema)]
222pub struct CreatePageParams {
223 #[schemars(
224 description = "Short noun phrase that names the page (e.g. 'Origin daemon architecture')."
225 )]
226 pub title: String,
227 #[schemars(
228 description = "Markdown body — 3-7 paragraphs of wiki prose with [[wikilinks]]. Cite source ids inline as (source: mem_XXX)."
229 )]
230 pub content: String,
231 #[schemars(description = "Optional one-sentence summary — the durable claim.")]
232 pub summary: Option<String>,
233 #[schemars(
234 description = "Optional entity_id (e.g. 'ent_abc') to anchor the page to a knowledge-graph entity."
235 )]
236 pub entity_id: Option<String>,
237 #[schemars(description = "Topic scope (e.g. 'origin', 'work'). Optional.")]
238 pub domain: Option<String>,
239 #[schemars(
240 description = "Memory source_ids the page is distilled from. Required for traceability."
241 )]
242 #[serde(default)]
243 pub source_memory_ids: Vec<String>,
244}
245
246#[derive(Debug, Deserialize, schemars::JsonSchema)]
247pub struct DeletePageParams {
248 #[schemars(
249 description = "Page id (e.g. 'page_abc' or legacy 'concept_abc'). Get it from get_page or distill output."
250 )]
251 pub page_id: String,
252}
253
254#[derive(Debug, Deserialize, schemars::JsonSchema)]
255pub struct UpdatePageParams {
256 #[schemars(
257 description = "Page id (e.g. 'page_abc' or legacy 'concept_abc'). Get it from the `stale_pages` block in distill output."
258 )]
259 pub page_id: String,
260 #[schemars(
261 description = "Refreshed markdown body — same wiki-prose style as create_page. Replaces the existing content."
262 )]
263 pub content: String,
264 #[schemars(
265 description = "Full source_memory_ids list for the refreshed page — typically the stale page's existing list (carry through from distill output)."
266 )]
267 pub source_memory_ids: Vec<String>,
268 #[schemars(
269 description = "Optional one-sentence summary. Omit to keep the existing summary; pass empty string to clear it."
270 )]
271 pub summary: Option<String>,
272}
273
274#[derive(Debug, Deserialize, schemars::JsonSchema)]
275pub struct GetPageParams {
276 #[schemars(
277 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."
278 )]
279 pub page_id: String,
280}
281
282#[derive(Debug, Deserialize, schemars::JsonSchema)]
283pub struct GetPageLinksParams {
284 #[schemars(
285 description = "Page id (e.g. 'page_abc'). Returns inbound + outbound wikilink graph for that page."
286 )]
287 pub page_id: String,
288}
289
290#[derive(Debug, Deserialize, schemars::JsonSchema)]
291pub struct ListMemoriesParams {
292 #[schemars(
293 description = "Filter by memory type (e.g. 'fact', 'preference', 'decision'). Optional."
294 )]
295 pub memory_type: Option<String>,
296 #[schemars(description = "Filter by topic/domain. Optional.")]
297 pub domain: Option<String>,
298 #[schemars(
299 description = "Max results, default 100. Increase for bulk listings, decrease for quick scans."
300 )]
301 #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
302 pub limit: Option<usize>,
303}
304
305#[derive(Debug, Deserialize, schemars::JsonSchema)]
306pub struct SearchPagesParams {
307 #[schemars(
308 description = "Natural-language search over page title + body content (e.g. 'mutex deadlock', 'distillation architecture')."
309 )]
310 pub query: String,
311 #[schemars(
312 description = "Max results, default 20. Use 1 to resolve a title to its id before calling get_page; higher for broader search."
313 )]
314 #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
315 pub limit: Option<usize>,
316}
317
318#[derive(Debug, Deserialize, schemars::JsonSchema)]
319pub struct ListPagesRecentParams {
320 #[schemars(
321 description = "Max results, default 10. Use higher (up to ~50) for a wider sweep of recent activity."
322 )]
323 #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
324 pub limit: Option<usize>,
325 #[schemars(
326 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."
327 )]
328 #[serde(default, deserialize_with = "deserialize_optional_i64_lenient")]
329 pub since_ms: Option<i64>,
330}
331
332fn format_capture_success(resp: &StoreMemoryResponse) -> String {
335 let mut msg = format!("Stored {}", resp.source_id);
336 if !resp.warnings.is_empty() {
337 msg.push_str("\nWarnings:");
338 for warning in &resp.warnings {
339 msg.push_str(&format!("\n - {}", warning));
340 }
341 }
342 msg
343}
344
345fn daemon_setup_hint() -> &'static str {
346 "Install the local Origin runtime and run `origin setup`.
347
348Setup choices:
349- Basic Memory: store, search, and recall now. No model download or API key.
350- On-device Model: private local extraction and background refinement after model download.
351- Anthropic Key: richer extraction and background refinement using your API key.
352
353Install:
354 curl -fsSL https://raw.githubusercontent.com/7xuanlu/origin/main/install.sh | bash
355 export PATH=\"$HOME/.origin/bin:$PATH\"
356 origin setup
357 origin install
358 origin status"
359}
360
361fn tool_error(e: OriginError, verb: &str) -> CallToolResult {
365 let msg = match &e {
366 OriginError::Unreachable(_) => format!(
367 "Origin daemon is not reachable (retried 3x over ~6s). \
368 The {verb} was NOT completed.\n\n{}",
369 daemon_setup_hint()
370 ),
371 OriginError::Api { status, body } => format!(
372 "Origin daemon returned HTTP {status}: {body}. The {verb} may not have completed."
373 ),
374 OriginError::Deserialize(detail) => format!(
375 "Failed to parse daemon response: {detail}. \
376 This may indicate a version mismatch between origin-mcp and the daemon."
377 ),
378 };
379 CallToolResult::error(vec![Content::text(msg)])
380}
381
382fn format_doctor_message(status: &serde_json::Value) -> String {
383 let mode = status
384 .get("mode")
385 .and_then(|v| v.as_str())
386 .unwrap_or("unknown");
387 let setup_completed = status
388 .get("setup_completed")
389 .and_then(|v| v.as_bool())
390 .unwrap_or(false);
391 let anthropic_key_configured = status
392 .get("anthropic_key_configured")
393 .and_then(|v| v.as_bool())
394 .unwrap_or(false);
395 let local_model_selected = status.get("local_model_selected").and_then(|v| v.as_str());
396 let local_model_loaded = status.get("local_model_loaded").and_then(|v| v.as_str());
397 let local_model_cached = status
398 .get("local_model_cached")
399 .and_then(|v| v.as_bool())
400 .unwrap_or(false);
401
402 let mode_label = match mode {
403 "basic-memory" => "Basic Memory",
404 "local-model" => "On-device Model",
405 "anthropic-key" => "Anthropic Key",
406 other => other,
407 };
408 let local_model_line = match local_model_selected {
409 Some(id) => {
410 let cache_status = if local_model_cached {
411 "downloaded"
412 } else {
413 "not downloaded"
414 };
415 let loaded_status = if Some(id) == local_model_loaded {
416 ", loaded"
417 } else {
418 ""
419 };
420 format!("{id} ({cache_status}{loaded_status})")
421 }
422 None => "not selected".to_string(),
423 };
424 let refinement_line = if anthropic_key_configured || local_model_loaded.is_some() {
425 "enabled (richer extraction and background refinement are active)"
426 } else if setup_completed {
427 "paused (Basic Memory stores, searches, and recalls now. Choose an on-device model or Anthropic key for richer extraction.)"
428 } else {
429 "not configured"
430 };
431
432 let mut msg = format!(
433 "Origin daemon: running\n\
434 Setup: {}\n\
435 Mode: {mode_label}\n\
436 Anthropic key: {}\n\
437 On-device model: {local_model_line}\n\
438 Background refinement: {refinement_line}",
439 if setup_completed {
440 "completed"
441 } else {
442 "not completed"
443 },
444 if anthropic_key_configured {
445 "configured"
446 } else {
447 "not configured"
448 }
449 );
450
451 if !setup_completed {
452 msg.push_str(
453 "\n\nRun `origin setup` to choose Basic Memory, On-device Model, or Anthropic Key.",
454 );
455 } else if !anthropic_key_configured && local_model_loaded.is_none() {
456 msg.push_str(
457 "\n\nBasic Memory works now: capture, recall, and context are available. \
458 To enable richer extraction and background refinement, run `origin model install` \
459 or `origin key set anthropic`.",
460 );
461 }
462
463 msg
464}
465
466impl OriginMcpServer {
467 fn resolve_source_agent(&self, param_agent: Option<String>) -> Option<String> {
470 if let Some(ref agent) = param_agent {
472 if !agent.is_empty() {
473 return param_agent;
474 }
475 }
476 if let Ok(guard) = self.client_name.lock() {
478 if let Some(ref name) = *guard {
479 return Some(name.clone());
480 }
481 }
482 Some(self.agent_name.clone())
484 }
485
486 fn resolve_user_id(&self, param_user_id: Option<String>) -> Option<String> {
489 if self.transport == TransportMode::Http {
490 self.user_id.clone().or(param_user_id)
491 } else {
492 param_user_id
493 }
494 }
495
496 pub async fn capture_impl(&self, params: CaptureParams) -> Result<CallToolResult, McpError> {
497 let source_agent = self.resolve_source_agent(None);
501 if let Some(uid) = self.resolve_user_id(None) {
502 tracing::debug!(user_id = %uid, "capture invoked");
503 }
504
505 let req = StoreMemoryRequest {
506 content: params.content,
507 memory_type: params.memory_type,
508 domain: params.domain,
509 source_agent,
510 title: None,
511 confidence: params.confidence,
512 supersedes: params.supersedes,
513 entity: params.entity,
514 entity_id: None,
515 structured_fields: params.structured_fields.map(serde_json::Value::Object),
516 retrieval_cue: params.retrieval_cue,
517 };
518
519 let resp: StoreMemoryResponse = match self.client.post("/api/memory/store", &req).await {
520 Ok(r) => r,
521 Err(e) => return Ok(tool_error(e, "memory store")),
522 };
523
524 Ok(CallToolResult::success(vec![Content::text(
525 format_capture_success(&resp),
526 )]))
527 }
528
529 pub async fn recall_impl(&self, params: RecallParams) -> Result<CallToolResult, McpError> {
530 let req = SearchMemoryRequest {
531 query: params.query,
532 limit: params.limit.unwrap_or(10),
533 memory_type: params.memory_type,
534 domain: params.domain,
535 source_agent: self.resolve_source_agent(None),
536 };
537
538 let resp: SearchMemoryResponse = match self.client.post("/api/memory/search", &req).await {
539 Ok(r) => r,
540 Err(e) => return Ok(tool_error(e, "search")),
541 };
542
543 let json = serde_json::to_string_pretty(&resp.results)
544 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
545
546 Ok(CallToolResult::success(vec![Content::text(format!(
547 "{} results ({:.1}ms)\n{}",
548 resp.results.len(),
549 resp.took_ms,
550 json
551 ))]))
552 }
553
554 pub async fn context_impl(&self, params: ContextParams) -> Result<CallToolResult, McpError> {
555 #[allow(deprecated)]
556 let req = ChatContextRequest {
557 query: None,
558 conversation_id: params.topic,
559 max_chunks: params.limit.unwrap_or(20),
560 relevance_threshold: None,
561 include_goals: true,
562 domain: params.domain,
563 };
564
565 let raw: serde_json::Value = match self.client.post("/api/chat-context", &req).await {
573 Ok(r) => r,
574 Err(e) => return Ok(tool_error(e, "context load")),
575 };
576
577 let context = raw
578 .get("context")
579 .and_then(|v| v.as_str())
580 .unwrap_or_default()
581 .to_string();
582
583 if context.is_empty() {
584 Ok(CallToolResult::success(vec![Content::text(
585 "No relevant context found".to_string(),
586 )]))
587 } else {
588 Ok(CallToolResult::success(vec![Content::text(context)]))
589 }
590 }
591
592 pub async fn doctor_impl(&self) -> Result<CallToolResult, McpError> {
593 let status: serde_json::Value = match self.client.get("/api/setup/status").await {
594 Ok(r) => r,
595 Err(OriginError::Api { status: 404, .. }) => {
596 return Ok(CallToolResult::error(vec![Content::text(
597 "Origin daemon is running, but it does not expose /api/setup/status. \
598 Update Origin, then run `origin doctor`."
599 .to_string(),
600 )]));
601 }
602 Err(e) => return Ok(tool_error(e, "status check")),
603 };
604
605 Ok(CallToolResult::success(vec![Content::text(
606 format_doctor_message(&status),
607 )]))
608 }
609
610 pub async fn forget_impl(&self, memory_id: &str) -> Result<CallToolResult, McpError> {
611 if self.transport == TransportMode::Http {
612 return Ok(CallToolResult::error(vec![Content::text(
613 "Delete operations are not available over remote connections. \
614 Use local MCP on the machine running Origin to delete memories."
615 .to_string(),
616 )]));
617 }
618
619 let resp: DeleteResponse = match self
620 .client
621 .delete(&format!("/api/memory/delete/{}", memory_id))
622 .await
623 {
624 Ok(r) => r,
625 Err(e) => return Ok(tool_error(e, "delete")),
626 };
627
628 Ok(CallToolResult::success(vec![Content::text(
629 if resp.deleted {
630 "Memory deleted"
631 } else {
632 "Memory not found"
633 }
634 .to_string(),
635 )]))
636 }
637
638 pub async fn distill_impl(&self, params: DistillParams) -> Result<CallToolResult, McpError> {
639 let body = match params.target.as_deref() {
640 Some(t) if !t.is_empty() => serde_json::json!({ "target": t }),
641 _ => serde_json::json!({}),
642 };
643 match self
644 .client
645 .post::<serde_json::Value, serde_json::Value>("/api/distill", &body)
646 .await
647 {
648 Ok(resp) => {
649 if let Some(unresolved) = resp.get("unresolved").and_then(|v| v.as_str()) {
650 let hint = resp
651 .get("hint")
652 .and_then(|v| v.as_str())
653 .unwrap_or("no matching target");
654 return Ok(CallToolResult::success(vec![Content::text(format!(
655 "Could not resolve target `{}`. {}",
656 unresolved, hint
657 ))]));
658 }
659 let pretty =
665 serde_json::to_string_pretty(&resp).unwrap_or_else(|_| resp.to_string());
666 Ok(CallToolResult::success(vec![Content::text(pretty)]))
667 }
668 Err(e) => Ok(tool_error(e, "distill")),
669 }
670 }
671
672 pub async fn list_pending_impl(
673 &self,
674 params: ListPendingParams,
675 ) -> Result<CallToolResult, McpError> {
676 let limit = params.limit.unwrap_or(20).min(100);
677 let path = format!("/api/memory/list?confirmed=false&limit={}", limit);
678 let value: serde_json::Value = match self.client.get(&path).await {
679 Ok(v) => v,
680 Err(e) => return Ok(tool_error(e, "list_pending")),
681 };
682 let body = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
683 Ok(CallToolResult::success(vec![Content::text(body)]))
684 }
685
686 pub async fn confirm_memory_impl(&self, memory_id: &str) -> Result<CallToolResult, McpError> {
687 if self.transport == TransportMode::Http {
688 return Ok(CallToolResult::error(vec![Content::text(
689 "Confirm operations are not available over remote connections. \
690 Use local MCP on the machine running Origin for review."
691 .to_string(),
692 )]));
693 }
694 let path = format!("/api/memory/confirm/{}", memory_id);
695 match self
696 .client
697 .post::<serde_json::Value, serde_json::Value>(&path, &serde_json::json!({}))
698 .await
699 {
700 Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!(
701 "Memory {} confirmed.",
702 memory_id
703 ))])),
704 Err(e) => Ok(tool_error(e, "confirm_memory")),
705 }
706 }
707
708 pub async fn create_entity_impl(
709 &self,
710 params: CreateEntityParams,
711 ) -> Result<CallToolResult, McpError> {
712 let source_agent = self.resolve_source_agent(None);
713 let req = CreateEntityRequest {
714 name: params.name,
715 entity_type: params.entity_type,
716 domain: params.domain,
717 source_agent,
718 confidence: params.confidence,
719 };
720 let resp: CreateEntityResponse = match self.client.post("/api/memory/entities", &req).await
721 {
722 Ok(r) => r,
723 Err(e) => return Ok(tool_error(e, "create_entity")),
724 };
725 Ok(CallToolResult::success(vec![Content::text(format!(
726 "Created entity {}",
727 resp.id
728 ))]))
729 }
730
731 pub async fn create_relation_impl(
732 &self,
733 params: CreateRelationParams,
734 ) -> Result<CallToolResult, McpError> {
735 let source_agent = self.resolve_source_agent(None);
736 let req = CreateRelationRequest {
737 from_entity: params.from_entity,
738 to_entity: params.to_entity,
739 relation_type: params.relation_type,
740 source_agent,
741 };
742 let resp: CreateRelationResponse =
743 match self.client.post("/api/memory/relations", &req).await {
744 Ok(r) => r,
745 Err(e) => return Ok(tool_error(e, "create_relation")),
746 };
747 Ok(CallToolResult::success(vec![Content::text(format!(
748 "Created relation {}",
749 resp.id
750 ))]))
751 }
752
753 pub async fn create_page_impl(
754 &self,
755 params: CreatePageParams,
756 ) -> Result<CallToolResult, McpError> {
757 let req = CreateConceptRequest {
758 title: params.title,
759 content: params.content,
760 summary: params.summary,
761 entity_id: params.entity_id,
762 domain: params.domain,
763 source_memory_ids: params.source_memory_ids,
764 };
765 let resp: serde_json::Value = match self.client.post("/api/pages", &req).await {
766 Ok(r) => r,
767 Err(e) => return Ok(tool_error(e, "create_page")),
768 };
769 let id = resp
770 .get("id")
771 .and_then(|v| v.as_str())
772 .unwrap_or("(unknown)");
773 Ok(CallToolResult::success(vec![Content::text(format!(
774 "Created page {}",
775 id
776 ))]))
777 }
778
779 pub async fn update_page_impl(
780 &self,
781 params: UpdatePageParams,
782 ) -> Result<CallToolResult, McpError> {
783 let req = origin_types::requests::RefreshPageRequest {
784 content: params.content,
785 source_memory_ids: params.source_memory_ids,
786 summary: params.summary,
787 };
788 let path = format!("/api/pages/{}", params.page_id);
789 let _: origin_types::responses::SuccessResponse = match self.client.put(&path, &req).await {
793 Ok(r) => r,
794 Err(e) => return Ok(tool_error(e, "update_page")),
795 };
796 Ok(CallToolResult::success(vec![Content::text(format!(
797 "Refreshed page {}",
798 params.page_id
799 ))]))
800 }
801
802 pub async fn delete_page_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
803 if self.transport == TransportMode::Http {
804 return Ok(CallToolResult::error(vec![Content::text(
805 "Delete operations are not available over remote connections. \
806 Use local MCP on the machine running Origin to delete pages."
807 .to_string(),
808 )]));
809 }
810
811 let path = format!("/api/pages/{}", page_id);
812 let resp: serde_json::Value = match self.client.delete(&path).await {
813 Ok(r) => r,
814 Err(e) => return Ok(tool_error(e, "delete_page")),
815 };
816 let status = resp
817 .get("status")
818 .and_then(|v| v.as_str())
819 .unwrap_or("deleted");
820 Ok(CallToolResult::success(vec![Content::text(format!(
821 "Page {} {}",
822 page_id, status
823 ))]))
824 }
825
826 pub async fn get_page_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
827 let path = format!("/api/pages/{}", page_id);
828 let resp: serde_json::Value = match self.client.get(&path).await {
829 Ok(r) => r,
830 Err(e) => return Ok(tool_error(e, "get_page")),
831 };
832 let pretty = serde_json::to_string_pretty(&resp).unwrap_or_else(|_| resp.to_string());
833 Ok(CallToolResult::success(vec![Content::text(pretty)]))
834 }
835
836 pub async fn get_page_links_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
837 let path = format!("/api/pages/{}/links", page_id);
838 let resp: origin_types::responses::PageLinksResponse = match self.client.get(&path).await {
840 Ok(r) => r,
841 Err(e) => return Ok(tool_error(e, "get_page_links")),
842 };
843 let pretty = serde_json::to_string_pretty(&resp).unwrap_or_else(|_| String::new());
844 Ok(CallToolResult::success(vec![Content::text(pretty)]))
845 }
846
847 pub async fn list_memories_impl(
848 &self,
849 params: ListMemoriesParams,
850 ) -> Result<CallToolResult, McpError> {
851 let req = ListMemoriesRequest {
852 memory_type: params.memory_type,
853 domain: params.domain,
854 limit: params.limit.unwrap_or(100),
855 };
856 let resp: ListMemoriesResponse = match self.client.post("/api/memory/list", &req).await {
857 Ok(r) => r,
858 Err(e) => return Ok(tool_error(e, "list_memories")),
859 };
860 let pretty = serde_json::to_string_pretty(&resp.memories)
861 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
862 Ok(CallToolResult::success(vec![Content::text(format!(
863 "{} memories\n{}",
864 resp.memories.len(),
865 pretty
866 ))]))
867 }
868
869 pub async fn search_pages_impl(
870 &self,
871 params: SearchPagesParams,
872 ) -> Result<CallToolResult, McpError> {
873 let req = SearchPagesRequest {
874 query: params.query,
875 limit: params.limit,
876 };
877 let resp: SearchPagesResponse = match self.client.post("/api/pages/search", &req).await {
878 Ok(r) => r,
879 Err(e) => return Ok(tool_error(e, "search_pages")),
880 };
881 let pretty = serde_json::to_string_pretty(&resp.pages)
882 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
883 Ok(CallToolResult::success(vec![Content::text(format!(
884 "{} pages\n{}",
885 resp.pages.len(),
886 pretty
887 ))]))
888 }
889
890 pub async fn list_pages_recent_impl(
891 &self,
892 params: ListPagesRecentParams,
893 ) -> Result<CallToolResult, McpError> {
894 let path = build_recent_pages_path(params.limit, params.since_ms);
895 let resp: Vec<RecentActivityItem> = match self.client.get(&path).await {
896 Ok(r) => r,
897 Err(e) => return Ok(tool_error(e, "list_pages_recent")),
898 };
899 let pretty = serde_json::to_string_pretty(&resp)
900 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
901 Ok(CallToolResult::success(vec![Content::text(format!(
902 "{} recent pages\n{}",
903 resp.len(),
904 pretty
905 ))]))
906 }
907}
908
909fn build_recent_pages_path(limit: Option<usize>, since_ms: Option<i64>) -> String {
913 let mut path = String::from("/api/pages/recent");
914 let mut q: Vec<String> = Vec::new();
915 if let Some(l) = limit {
916 q.push(format!("limit={}", l));
917 }
918 if let Some(s) = since_ms {
919 q.push(format!("since_ms={}", s));
920 }
921 if !q.is_empty() {
922 path.push('?');
923 path.push_str(&q.join("&"));
924 }
925 path
926}
927
928#[tool_router]
931impl OriginMcpServer {
932 pub fn new(
933 client: OriginClient,
934 transport: TransportMode,
935 agent_name: String,
936 user_id: Option<String>,
937 ) -> Self {
938 Self {
939 tool_router: Self::tool_router(),
940 client,
941 transport,
942 agent_name,
943 client_name: std::sync::Arc::new(std::sync::Mutex::new(None)),
944 user_id,
945 }
946 }
947
948 #[tool(
951 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.",
952 annotations(
953 title = "Capture",
954 read_only_hint = false,
955 destructive_hint = false,
956 idempotent_hint = false,
957 open_world_hint = false
958 )
959 )]
960 async fn capture(
961 &self,
962 Parameters(params): Parameters<CaptureParams>,
963 ) -> Result<CallToolResult, McpError> {
964 self.capture_impl(params).await
965 }
966
967 #[tool(
968 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, domain) 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.",
969 annotations(title = "Recall", read_only_hint = true, open_world_hint = false)
970 )]
971 async fn recall(
972 &self,
973 Parameters(params): Parameters<RecallParams>,
974 ) -> Result<CallToolResult, McpError> {
975 self.recall_impl(params).await
976 }
977
978 #[tool(
979 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.",
980 annotations(title = "Context", read_only_hint = true, open_world_hint = false)
981 )]
982 async fn context(
983 &self,
984 Parameters(params): Parameters<ContextParams>,
985 ) -> Result<CallToolResult, McpError> {
986 self.context_impl(params).await
987 }
988
989 #[tool(
990 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 background refinement is paused. Reports daemon reachability, setup mode, Basic Memory, On-device Model, Anthropic key state, and on-device model state.",
991 annotations(title = "Doctor", read_only_hint = true, open_world_hint = false)
992 )]
993 async fn doctor(&self) -> Result<CallToolResult, McpError> {
994 self.doctor_impl().await
995 }
996
997 #[tool(
998 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.",
999 annotations(
1000 title = "Forget",
1001 read_only_hint = false,
1002 destructive_hint = true,
1003 idempotent_hint = true,
1004 open_world_hint = false
1005 )
1006 )]
1007 async fn forget(
1008 &self,
1009 Parameters(params): Parameters<ForgetParams>,
1010 ) -> Result<CallToolResult, McpError> {
1011 self.forget_impl(¶ms.memory_id).await
1012 }
1013
1014 #[tool(
1015 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 domain value scopes to that domain. 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.",
1016 annotations(
1017 title = "Distill",
1018 read_only_hint = false,
1019 destructive_hint = false,
1020 idempotent_hint = true,
1021 open_world_hint = false
1022 )
1023 )]
1024 async fn distill(
1025 &self,
1026 Parameters(params): Parameters<DistillParams>,
1027 ) -> Result<CallToolResult, McpError> {
1028 self.distill_impl(params).await
1029 }
1030
1031 #[tool(
1032 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.",
1033 annotations(title = "List pending", read_only_hint = true, open_world_hint = false)
1034 )]
1035 async fn list_pending(
1036 &self,
1037 Parameters(params): Parameters<ListPendingParams>,
1038 ) -> Result<CallToolResult, McpError> {
1039 self.list_pending_impl(params).await
1040 }
1041
1042 #[tool(
1043 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`.",
1044 annotations(
1045 title = "Confirm memory",
1046 read_only_hint = false,
1047 destructive_hint = false,
1048 idempotent_hint = true,
1049 open_world_hint = false
1050 )
1051 )]
1052 async fn confirm_memory(
1053 &self,
1054 Parameters(params): Parameters<ConfirmMemoryParams>,
1055 ) -> Result<CallToolResult, McpError> {
1056 self.confirm_memory_impl(¶ms.memory_id).await
1057 }
1058
1059 #[tool(
1062 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 an LLM is configured — call this explicitly only when there is no LLM (Basic Memory mode) or you need the id back synchronously.",
1063 annotations(
1064 title = "Create entity",
1065 read_only_hint = false,
1066 destructive_hint = false,
1067 idempotent_hint = false,
1068 open_world_hint = false
1069 )
1070 )]
1071 async fn create_entity(
1072 &self,
1073 Parameters(params): Parameters<CreateEntityParams>,
1074 ) -> Result<CallToolResult, McpError> {
1075 self.create_entity_impl(params).await
1076 }
1077
1078 #[tool(
1079 description = "Create a directed relation between two entities in the knowledge graph. Use sparingly — most relations come out of the daemon's enrichment when an LLM is configured. Call this explicitly to record a relation the user articulated that the daemon couldn't infer, or in Basic Memory mode where extraction does not run.",
1080 annotations(
1081 title = "Create relation",
1082 read_only_hint = false,
1083 destructive_hint = false,
1084 idempotent_hint = false,
1085 open_world_hint = false
1086 )
1087 )]
1088 async fn create_relation(
1089 &self,
1090 Parameters(params): Parameters<CreateRelationParams>,
1091 ) -> Result<CallToolResult, McpError> {
1092 self.create_relation_impl(params).await
1093 }
1094
1095 #[tool(
1096 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.",
1097 annotations(
1098 title = "Create page",
1099 read_only_hint = false,
1100 destructive_hint = false,
1101 idempotent_hint = false,
1102 open_world_hint = false
1103 )
1104 )]
1105 async fn create_page(
1106 &self,
1107 Parameters(params): Parameters<CreatePageParams>,
1108 ) -> Result<CallToolResult, McpError> {
1109 self.create_page_impl(params).await
1110 }
1111
1112 #[tool(
1113 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).",
1114 annotations(
1115 title = "Refresh page",
1116 read_only_hint = false,
1117 destructive_hint = false,
1118 idempotent_hint = false,
1119 open_world_hint = false
1120 )
1121 )]
1122 async fn update_page(
1123 &self,
1124 Parameters(params): Parameters<UpdatePageParams>,
1125 ) -> Result<CallToolResult, McpError> {
1126 self.update_page_impl(params).await
1127 }
1128
1129 #[tool(
1130 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.",
1131 annotations(
1132 title = "Delete page",
1133 read_only_hint = false,
1134 destructive_hint = true,
1135 idempotent_hint = true,
1136 open_world_hint = false
1137 )
1138 )]
1139 async fn delete_page(
1140 &self,
1141 Parameters(params): Parameters<DeletePageParams>,
1142 ) -> Result<CallToolResult, McpError> {
1143 self.delete_page_impl(¶ms.page_id).await
1144 }
1145
1146 #[tool(
1147 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.",
1148 annotations(title = "Get page", read_only_hint = true, open_world_hint = false)
1149 )]
1150 async fn get_page(
1151 &self,
1152 Parameters(params): Parameters<GetPageParams>,
1153 ) -> Result<CallToolResult, McpError> {
1154 self.get_page_impl(¶ms.page_id).await
1155 }
1156
1157 #[tool(
1158 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.",
1159 annotations(
1160 title = "Get page links",
1161 read_only_hint = true,
1162 destructive_hint = false,
1163 idempotent_hint = true,
1164 open_world_hint = false
1165 )
1166 )]
1167 async fn get_page_links(
1168 &self,
1169 Parameters(params): Parameters<GetPageLinksParams>,
1170 ) -> Result<CallToolResult, McpError> {
1171 self.get_page_links_impl(¶ms.page_id).await
1172 }
1173
1174 #[tool(
1175 description = "List memories filtered by type and/or domain. 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.",
1176 annotations(
1177 title = "List memories",
1178 read_only_hint = true,
1179 open_world_hint = false
1180 )
1181 )]
1182 async fn list_memories(
1183 &self,
1184 Parameters(params): Parameters<ListMemoriesParams>,
1185 ) -> Result<CallToolResult, McpError> {
1186 self.list_memories_impl(params).await
1187 }
1188
1189 #[tool(
1190 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. For listing recent activity instead, use list_pages_recent.",
1191 annotations(title = "Search pages", read_only_hint = true, open_world_hint = false)
1192 )]
1193 async fn search_pages(
1194 &self,
1195 Parameters(params): Parameters<SearchPagesParams>,
1196 ) -> Result<CallToolResult, McpError> {
1197 self.search_pages_impl(params).await
1198 }
1199
1200 #[tool(
1201 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.",
1202 annotations(title = "Recent pages", read_only_hint = true, open_world_hint = false)
1203 )]
1204 async fn list_pages_recent(
1205 &self,
1206 Parameters(params): Parameters<ListPagesRecentParams>,
1207 ) -> Result<CallToolResult, McpError> {
1208 self.list_pages_recent_impl(params).await
1209 }
1210}
1211
1212#[tool_handler]
1215impl ServerHandler for OriginMcpServer {
1216 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
1217 if let Some(client_info) = context.peer.peer_info() {
1219 let name = &client_info.client_info.name;
1220 if !name.is_empty() {
1221 if let Ok(mut guard) = self.client_name.lock() {
1222 tracing::info!("MCP client identified: {}", name);
1223 *guard = Some(name.clone());
1224 }
1225 }
1226 }
1227 }
1228
1229 fn get_info(&self) -> InitializeResult {
1230 InitializeResult::new(
1231 ServerCapabilities::builder()
1232 .enable_tools()
1233 .build(),
1234 )
1235 .with_server_info(
1236 Implementation::new("origin-mcp", env!("CARGO_PKG_VERSION"))
1237 )
1238 .with_instructions(
1239 "Origin is your personal memory layer — a local knowledge base that persists across sessions and tools.\n\
1240 Think of yourself as a curator, not a logger. Store insights, not conversation artifacts.\n\n\
1241 Origin is cumulative: each memory you store can be recalled, linked, and distilled into knowledge over time. \
1242 It's also shared across all the user's tools: what you write, other agents (Claude Desktop, Claude Code, \
1243 ChatGPT, Cursor, etc.) will read later. Write for any future reader, not just this conversation.\n\n\
1244 FIRST THING EVERY SESSION: Call context to load the user's identity, preferences, goals, and\n\
1245 topic-relevant memories. This is how you know who you're talking to. Use the result to model how the \
1246 user thinks — their preferences, corrections, and past decisions tell you how they want to be helped, \
1247 not just what they already know.\n\n\
1248 STORE PROACTIVELY — don't wait for the user to ask.\n\
1249 - The user states a preference (\"I use X because...\", \"I prefer Y over Z\")\n\
1250 - The user makes a decision (\"going with approach A\", \"switching to B\")\n\
1251 - The user corrects you or prior info (\"actually, it's C, not D\") — store the correction so it sticks\n\
1252 - The user shares a durable fact about themselves, their work, or people/projects/tools they care about — \
1253 anchor it to the entity\n\n\
1254 If the user asks explicitly (\"remember this\", \"save this\", \"don't forget\"), that's a floor — you \
1255 should have already stored it.\n\n\
1256 WHEN NOT TO STORE:\n\
1257 - Conversation filler (\"ok\", \"thanks\", \"let's move on\")\n\
1258 - Things the user can trivially re-derive (file paths, recent git history)\n\
1259 - Anything already stored — recall first if unsure\n\
1260 - Tool output or command results (file contents, git history, build logs) — these are derivable\n\
1261 - General world facts or documentation that aren't personal to this user (e.g., \"Rust has a borrow \
1262 checker\", \"PostgreSQL supports JSONB\") — those are not memory material.\n\
1263 - Your own inferences about the user that they didn't express. Store what they said; infer from that \
1264 when responding.\n\n\
1265 CONTENT QUALITY — this is where you make the biggest difference:\n\
1266 - Specific beats vague: \"prefers Rust for CLI tools because of compile-time safety\" > \"likes Rust\"\n\
1267 - Include the WHY: the backend can classify \"dark mode\" as a preference, but only you know\n\
1268 \"switched to dark mode because of migraines from bright screens\"\n\
1269 - Name the entities: mention people, projects, tools by name — this powers the knowledge graph\n\
1270 - Atomic: one idea per memory — \"prefers TDD\" and \"uses pytest\" should be two memories, not one\n\
1271 - Declarative, not narrative: \"User prefers X because Y\" — not \"User said today they prefer X\". \
1272 Memories outlive the conversation that produced them.\n\n\
1273 MEMORY TYPES — omit and trust the backend.\n\n\
1274 By default, do NOT set memory_type. The backend auto-classifies into identity / preference / \
1275 decision / lesson / gotcha / fact with more context than you have. Agents that over-specify \
1276 types tend to pick wrong.\n\n\
1277 Opt-in specification:\n\
1278 - \"profile\" — you're sure it's about the user (identity / preference)\n\
1279 - \"knowledge\" — you're sure it's about the world (decision / lesson / gotcha / fact)\n\
1280 - Precise type — only if you're confident and the distinction matters.\n\n\
1281 EXCEPTION — decisions carry structured fields (alternatives considered, reversibility, domain) \
1282 that power the Decision Log view. Set memory_type=\"decision\" explicitly ONLY when the user \
1283 articulated alternatives weighed AND the reasoning for the choice. A bare \"I'm switching to Cursor\" \
1284 is just a preference change — omit the type. \"Switching to Cursor over VSCode because of better \
1285 Claude integration, and we can always go back\" — that's a decision.\n\n\
1286 RECALL vs CONTEXT:\n\
1287 - context: broad orientation, session start, topic shifts, \"catch me up\"\n\
1288 - recall: specific lookup (\"what's Alice's role?\", \"database preferences\", \"our auth decision\")\n\n\
1289 The backend handles classification, entity extraction, structured fields, quality scoring,\n\
1290 and dedup — you don't need to replicate that logic. Focus on what only you know:\n\
1291 the conversational context, why something matters, and what the user actually cares about."
1292 )
1293 }
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298 use super::*;
1299 use crate::client::OriginClient;
1300 use crate::types::{
1301 ChatContextRequest, ChatContextResponse, SearchMemoryRequest, SearchResult,
1302 StoreMemoryRequest, StoreMemoryResponse,
1303 };
1304
1305 fn make_server(
1306 transport: TransportMode,
1307 agent_name: &str,
1308 user_id: Option<&str>,
1309 ) -> OriginMcpServer {
1310 let client = OriginClient::new("http://127.0.0.1:19999".into());
1311 OriginMcpServer::new(
1312 client,
1313 transport,
1314 agent_name.into(),
1315 user_id.map(String::from),
1316 )
1317 }
1318
1319 #[test]
1322 fn test_http_mode_prefers_param_over_agent_name() {
1323 let server = make_server(TransportMode::Http, "claude.ai", None);
1324 let result = server.resolve_source_agent(Some("user-provided".into()));
1326 assert_eq!(result, Some("user-provided".into()));
1327 }
1328
1329 #[test]
1330 fn test_http_mode_sets_source_agent_when_none() {
1331 let server = make_server(TransportMode::Http, "chatgpt", None);
1332 let result = server.resolve_source_agent(None);
1333 assert_eq!(result, Some("chatgpt".into()));
1334 }
1335
1336 #[test]
1337 fn test_stdio_mode_passes_through_source_agent() {
1338 let server = make_server(TransportMode::Stdio, "ignored", None);
1339 let result = server.resolve_source_agent(Some("user-provided".into()));
1340 assert_eq!(result, Some("user-provided".into()));
1341 }
1342
1343 #[test]
1344 fn test_stdio_mode_falls_back_to_agent_name() {
1345 let server = make_server(TransportMode::Stdio, "fallback", None);
1346 let result = server.resolve_source_agent(None);
1348 assert_eq!(result, Some("fallback".into()));
1349 }
1350
1351 #[test]
1352 fn test_http_mode_resolves_configured_user_id_for_local_use() {
1353 let server = make_server(TransportMode::Http, "agent", Some("lucian"));
1354 let result = server.resolve_user_id(None);
1355 assert_eq!(result, Some("lucian".into()));
1356 }
1357
1358 #[test]
1359 fn test_transport_mode_equality() {
1360 assert_eq!(TransportMode::Stdio, TransportMode::Stdio);
1361 assert_eq!(TransportMode::Http, TransportMode::Http);
1362 assert_ne!(TransportMode::Stdio, TransportMode::Http);
1363 }
1364
1365 #[test]
1368 fn test_capture_params_minimal() {
1369 let json = r#"{"content": "Lucian prefers dark mode"}"#;
1370 let params: CaptureParams = serde_json::from_str(json).unwrap();
1371 assert_eq!(params.content, "Lucian prefers dark mode");
1372 assert!(params.memory_type.is_none());
1373 assert!(params.domain.is_none());
1374 assert!(params.entity.is_none());
1375 assert!(params.confidence.is_none());
1376 assert!(params.supersedes.is_none());
1377 }
1378
1379 #[test]
1380 fn test_capture_params_full() {
1381 let json = r#"{
1382 "content": "We chose PostgreSQL over MongoDB",
1383 "memory_type": "decision",
1384 "domain": "origin",
1385 "entity": "PostgreSQL",
1386 "confidence": 0.95,
1387 "supersedes": "mem_abc123"
1388 }"#;
1389 let params: CaptureParams = serde_json::from_str(json).unwrap();
1390 assert_eq!(params.content, "We chose PostgreSQL over MongoDB");
1391 assert_eq!(params.memory_type.as_deref(), Some("decision"));
1392 assert_eq!(params.domain.as_deref(), Some("origin"));
1393 assert_eq!(params.entity.as_deref(), Some("PostgreSQL"));
1394 assert_eq!(params.confidence, Some(0.95));
1395 assert_eq!(params.supersedes.as_deref(), Some("mem_abc123"));
1396 }
1397
1398 #[test]
1399 fn test_capture_params_missing_content_fails() {
1400 let json = r#"{"memory_type": "fact"}"#;
1401 let result = serde_json::from_str::<CaptureParams>(json);
1402 assert!(result.is_err());
1403 }
1404
1405 #[test]
1408 fn test_recall_params_minimal() {
1409 let json = r#"{"query": "what does Alice work on?"}"#;
1410 let params: RecallParams = serde_json::from_str(json).unwrap();
1411 assert_eq!(params.query, "what does Alice work on?");
1412 assert!(params.limit.is_none());
1413 }
1414
1415 #[test]
1416 fn test_recall_params_full() {
1417 let json = r#"{
1418 "query": "database preferences",
1419 "limit": 5,
1420 "memory_type": "decision",
1421 "domain": "origin"
1422 }"#;
1423 let params: RecallParams = serde_json::from_str(json).unwrap();
1424 assert_eq!(params.query, "database preferences");
1425 assert_eq!(params.limit, Some(5));
1426 assert_eq!(params.memory_type.as_deref(), Some("decision"));
1427 assert_eq!(params.domain.as_deref(), Some("origin"));
1428 }
1429
1430 #[test]
1431 fn test_recall_params_limit_as_string() {
1432 let json = r#"{"query": "test", "limit": "10"}"#;
1433 let params: RecallParams = serde_json::from_str(json).unwrap();
1434 assert_eq!(params.limit, Some(10));
1435 }
1436
1437 #[test]
1438 fn test_recall_params_missing_query_fails() {
1439 let json = r#"{"limit": 5}"#;
1440 let result = serde_json::from_str::<RecallParams>(json);
1441 assert!(result.is_err());
1442 }
1443
1444 #[test]
1447 fn test_context_params_empty() {
1448 let json = r#"{}"#;
1449 let params: ContextParams = serde_json::from_str(json).unwrap();
1450 assert!(params.topic.is_none());
1451 assert!(params.limit.is_none());
1452 assert!(params.domain.is_none());
1453 }
1454
1455 #[test]
1456 fn test_context_params_full() {
1457 let json = r#"{"topic": "project Origin architecture", "limit": 30, "domain": "work"}"#;
1458 let params: ContextParams = serde_json::from_str(json).unwrap();
1459 assert_eq!(params.topic.as_deref(), Some("project Origin architecture"));
1460 assert_eq!(params.limit, Some(30));
1461 assert_eq!(params.domain.as_deref(), Some("work"));
1462 }
1463
1464 #[test]
1465 fn test_context_params_limit_as_string() {
1466 let json = r#"{"limit": "20"}"#;
1467 let params: ContextParams = serde_json::from_str(json).unwrap();
1468 assert_eq!(params.limit, Some(20));
1469 }
1470
1471 #[test]
1472 fn store_memory_request_serialization_excludes_user_id() {
1473 let req = StoreMemoryRequest {
1474 content: "test content".into(),
1475 memory_type: None,
1476 domain: None,
1477 source_agent: Some("test-agent".into()),
1478 title: None,
1479 confidence: None,
1480 supersedes: None,
1481 entity: None,
1482 entity_id: None,
1483 structured_fields: None,
1484 retrieval_cue: None,
1485 };
1486 let json = serde_json::to_value(&req).unwrap();
1487 let obj = json.as_object().unwrap();
1488 assert!(
1489 !obj.contains_key("user_id"),
1490 "user_id must not be on the wire; got: {:?}",
1491 obj.keys().collect::<Vec<_>>()
1492 );
1493 }
1494
1495 #[test]
1496 fn capture_success_message_is_terse() {
1497 let resp = StoreMemoryResponse {
1498 source_id: "mem_abc".into(),
1499 chunks_created: 3,
1500 memory_type: "fact".into(),
1501 entity_id: Some("ent_xyz".into()),
1502 quality: Some("high".into()),
1503 warnings: vec![],
1504 extraction_method: "llm".into(),
1505 enrichment: String::new(),
1506 hint: String::new(),
1507 };
1508 let msg = format_capture_success(&resp);
1509 assert_eq!(msg, "Stored mem_abc");
1510 assert!(!msg.contains("chunks"));
1511 assert!(!msg.contains("quality"));
1512 assert!(!msg.contains("entity"));
1513 }
1514
1515 #[test]
1516 fn capture_success_message_surfaces_warnings() {
1517 let resp = StoreMemoryResponse {
1518 source_id: "mem_abc".into(),
1519 chunks_created: 1,
1520 memory_type: "decision".into(),
1521 entity_id: None,
1522 quality: None,
1523 warnings: vec!["decision memory missing required 'claim' field".into()],
1524 extraction_method: "agent".into(),
1525 enrichment: String::new(),
1526 hint: String::new(),
1527 };
1528 let msg = format_capture_success(&resp);
1529 assert!(msg.starts_with("Stored mem_abc"));
1530 assert!(msg.contains("Warnings:"));
1531 assert!(msg.contains("decision memory missing required 'claim' field"));
1532 }
1533
1534 #[test]
1535 fn doctor_basic_memory_message_sets_expectations() {
1536 let msg = format_doctor_message(&serde_json::json!({
1537 "setup_completed": true,
1538 "mode": "basic-memory",
1539 "anthropic_key_configured": false,
1540 "local_model_selected": null,
1541 "local_model_loaded": null,
1542 "local_model_cached": false
1543 }));
1544
1545 assert!(msg.contains("Mode: Basic Memory"));
1546 assert!(msg.contains("On-device model: not selected"));
1547 assert!(msg.contains("Background refinement: paused"));
1548 assert!(msg.contains("Basic Memory works now: capture, recall, and context are available"));
1549 assert!(msg.contains("origin model install"));
1550 assert!(msg.contains("origin key set anthropic"));
1551 }
1552
1553 #[test]
1554 fn doctor_on_device_model_message_shows_loaded_model() {
1555 let msg = format_doctor_message(&serde_json::json!({
1556 "setup_completed": true,
1557 "mode": "local-model",
1558 "anthropic_key_configured": false,
1559 "local_model_selected": "qwen3-1.7b",
1560 "local_model_loaded": "qwen3-1.7b",
1561 "local_model_cached": true
1562 }));
1563
1564 assert!(msg.contains("Mode: On-device Model"), "{msg}");
1565 assert!(
1566 msg.contains("On-device model: qwen3-1.7b (downloaded, loaded)"),
1567 "{msg}"
1568 );
1569 assert!(msg.contains("Background refinement: enabled"), "{msg}");
1570 assert!(!msg.contains("Basic Memory works now"));
1571 }
1572
1573 #[test]
1574 fn doctor_unconfigured_message_names_three_setup_paths() {
1575 let msg = format_doctor_message(&serde_json::json!({
1576 "setup_completed": false,
1577 "mode": "unknown",
1578 "anthropic_key_configured": false,
1579 "local_model_selected": null,
1580 "local_model_loaded": null,
1581 "local_model_cached": false
1582 }));
1583
1584 assert!(msg.contains("Setup: not completed"));
1585 assert!(msg.contains("Run `origin setup`"));
1586 assert!(msg.contains("Basic Memory, On-device Model, or Anthropic Key"));
1587 }
1588
1589 #[test]
1590 fn search_memory_request_serialization_excludes_entity() {
1591 let req = SearchMemoryRequest {
1592 query: "test".into(),
1593 limit: 10,
1594 memory_type: None,
1595 domain: None,
1596 source_agent: None,
1597 };
1598 let json = serde_json::to_value(&req).unwrap();
1599 let obj = json.as_object().unwrap();
1600 assert!(
1601 !obj.contains_key("entity"),
1602 "entity must not be on the wire; got keys: {:?}",
1603 obj.keys().collect::<Vec<_>>()
1604 );
1605 }
1606
1607 #[test]
1608 fn chat_context_request_serialization_includes_domain() {
1609 #[allow(deprecated)]
1610 let req = ChatContextRequest {
1611 query: None,
1612 conversation_id: Some("topic".into()),
1613 max_chunks: 20,
1614 relevance_threshold: None,
1615 include_goals: true,
1616 domain: Some("work".into()),
1617 };
1618 let json = serde_json::to_value(&req).unwrap();
1619 assert_eq!(json["domain"], serde_json::json!("work"));
1620 assert_eq!(json["conversation_id"], serde_json::json!("topic"));
1621 }
1622
1623 #[test]
1624 fn chat_context_response_deserializes_with_profile_and_knowledge() {
1625 let json = r#"{
1626 "context": "user is Lucian, prefers Rust",
1627 "profile": {
1628 "narrative": "n",
1629 "identity": ["rust"],
1630 "preferences": [],
1631 "goals": []
1632 },
1633 "knowledge": {
1634 "pages": [],
1635 "decisions": [],
1636 "relevant_memories": [],
1637 "graph_context": []
1638 },
1639 "took_ms": 42.0,
1640 "token_estimates": {
1641 "tier1_identity": 10,
1642 "tier2_project": 20,
1643 "tier3_relevant": 30,
1644 "total": 60
1645 }
1646 }"#;
1647 let parsed: ChatContextResponse = serde_json::from_str(json).unwrap();
1648 assert_eq!(parsed.context, "user is Lucian, prefers Rust");
1649 assert_eq!(parsed.profile.identity, vec!["rust"]);
1650 assert_eq!(parsed.token_estimates.total, 60);
1651 }
1652
1653 #[test]
1654 fn capture_params_structured_fields_schema_is_object() {
1655 use schemars::schema_for;
1656
1657 let schema = schema_for!(CaptureParams);
1658 let json = serde_json::to_value(&schema).unwrap();
1659 let sf_schema = json
1660 .pointer("/properties/structured_fields")
1661 .expect("structured_fields property in schema");
1662 let type_val = sf_schema
1663 .pointer("/type")
1664 .unwrap_or(&serde_json::Value::Null);
1665 let type_str = match type_val {
1666 serde_json::Value::String(s) => s.clone(),
1667 serde_json::Value::Array(arr) => arr
1668 .iter()
1669 .filter_map(|v| v.as_str())
1670 .collect::<Vec<_>>()
1671 .join(","),
1672 other => panic!(
1673 "structured_fields schema lacks type constraint; got: {:?}",
1674 other
1675 ),
1676 };
1677 assert!(
1678 type_str.contains("object"),
1679 "expected object type, got: {}",
1680 type_str
1681 );
1682 }
1683
1684 #[test]
1687 fn test_forget_params() {
1688 let json = r#"{"memory_id": "mem_abc123"}"#;
1689 let params: ForgetParams = serde_json::from_str(json).unwrap();
1690 assert_eq!(params.memory_id, "mem_abc123");
1691 }
1692
1693 #[test]
1694 fn test_forget_params_missing_id_fails() {
1695 let json = r#"{}"#;
1696 let result = serde_json::from_str::<ForgetParams>(json);
1697 assert!(result.is_err());
1698 }
1699
1700 #[test]
1703 fn test_store_request_includes_new_fields() {
1704 let req = StoreMemoryRequest {
1705 content: "test".into(),
1706 memory_type: Some("decision".into()),
1707 domain: None,
1708 source_agent: Some("claude".into()),
1709 title: None,
1710 confidence: Some(0.9),
1711 supersedes: Some("old_id".into()),
1712 entity: Some("PostgreSQL".into()),
1713 entity_id: None,
1714 structured_fields: None,
1715 retrieval_cue: None,
1716 };
1717 let json = serde_json::to_value(&req).unwrap();
1718 assert_eq!(json["entity"], "PostgreSQL");
1719 assert_eq!(json["supersedes"], "old_id");
1720 assert!(json["confidence"].as_f64().unwrap() > 0.89);
1721 assert_eq!(json["source_agent"], "claude");
1722 assert!(json.get("user_id").is_none());
1723 }
1724
1725 #[test]
1726 fn test_store_request_minimal() {
1727 let req = StoreMemoryRequest {
1728 content: "hello".into(),
1729 memory_type: Some("fact".into()),
1730 domain: None,
1731 source_agent: None,
1732 title: None,
1733 confidence: None,
1734 supersedes: None,
1735 entity: None,
1736 entity_id: None,
1737 structured_fields: None,
1738 retrieval_cue: None,
1739 };
1740 let json = serde_json::to_value(&req).unwrap();
1741 assert_eq!(json["content"], "hello");
1742 assert_eq!(json["memory_type"], "fact");
1743 assert!(json.get("user_id").is_none());
1744 }
1745
1746 #[test]
1749 fn test_store_response_with_new_fields() {
1750 let json = r#"{
1751 "source_id": "mem_xyz",
1752 "chunks_created": 2,
1753 "memory_type": "fact",
1754 "entity_id": "ent_abc",
1755 "quality": "high",
1756 "warnings": ["decision memory missing claim"],
1757 "extraction_method": "agent"
1758 }"#;
1759 let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1760 assert_eq!(resp.source_id, "mem_xyz");
1761 assert_eq!(resp.chunks_created, 2);
1762 assert_eq!(resp.memory_type, "fact");
1763 assert_eq!(resp.entity_id.as_deref(), Some("ent_abc"));
1764 assert_eq!(resp.quality.as_deref(), Some("high"));
1765 assert_eq!(resp.warnings, vec!["decision memory missing claim"]);
1766 assert_eq!(resp.extraction_method, "agent");
1767 }
1768
1769 #[test]
1770 fn test_store_response_backward_compat_no_new_fields() {
1771 let json = r#"{
1773 "source_id": "mem_old",
1774 "chunks_created": 1,
1775 "memory_type": "fact"
1776 }"#;
1777 let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1778 assert_eq!(resp.source_id, "mem_old");
1779 assert_eq!(resp.chunks_created, 1);
1780 assert_eq!(resp.memory_type, "fact");
1781 assert!(resp.entity_id.is_none());
1782 assert!(resp.quality.is_none());
1783 assert!(resp.warnings.is_empty());
1784 assert_eq!(resp.extraction_method, "unknown");
1785 }
1786
1787 #[test]
1788 fn test_store_response_with_warnings_and_extraction_method() {
1789 let json = r#"{
1790 "source_id": "mem_xyz",
1791 "chunks_created": 1,
1792 "memory_type": "decision",
1793 "warnings": ["decision memory missing required 'claim' field"],
1794 "extraction_method": "llm"
1795 }"#;
1796 let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1797 assert_eq!(resp.memory_type, "decision");
1798 assert_eq!(
1799 resp.warnings,
1800 vec!["decision memory missing required 'claim' field"]
1801 );
1802 assert_eq!(resp.extraction_method, "llm");
1803 }
1804
1805 #[test]
1808 fn test_search_result_with_new_fields() {
1809 let json = r#"{
1810 "id": "1",
1811 "content": "We chose Postgres",
1812 "source": "memory",
1813 "source_id": "mem_1",
1814 "title": "DB decision",
1815 "url": null,
1816 "chunk_index": 0,
1817 "last_modified": 1711000000,
1818 "score": 0.95,
1819 "chunk_type": "memory",
1820 "language": "en",
1821 "semantic_unit": "sentence",
1822 "memory_type": "decision",
1823 "domain": "origin",
1824 "source_agent": "claude",
1825 "confidence": 0.9,
1826 "confirmed": true,
1827 "stability": "standard",
1828 "supersedes": "mem_0",
1829 "summary": "DB choice",
1830 "entity_id": "ent_pg",
1831 "entity_name": "PostgreSQL",
1832 "quality": "high",
1833 "is_archived": false,
1834 "is_recap": false,
1835 "source_text": "We chose Postgres",
1836 "raw_score": 0.42
1837 }"#;
1838 let result: SearchResult = serde_json::from_str(json).unwrap();
1839 assert_eq!(result.chunk_type.as_deref(), Some("memory"));
1840 assert_eq!(result.language.as_deref(), Some("en"));
1841 assert_eq!(result.semantic_unit.as_deref(), Some("sentence"));
1842 assert_eq!(result.stability.as_deref(), Some("standard"));
1843 assert_eq!(result.supersedes.as_deref(), Some("mem_0"));
1844 assert_eq!(result.summary.as_deref(), Some("DB choice"));
1845 assert_eq!(result.entity_id.as_deref(), Some("ent_pg"));
1846 assert_eq!(result.entity_name.as_deref(), Some("PostgreSQL"));
1847 assert_eq!(result.quality.as_deref(), Some("high"));
1848 assert!(!result.is_archived);
1849 assert!(!result.is_recap);
1850 assert_eq!(result.source_text.as_deref(), Some("We chose Postgres"));
1851 assert!((result.raw_score - 0.42).abs() < f32::EPSILON);
1852 }
1853
1854 #[test]
1855 fn test_search_result_backward_compat_no_new_fields() {
1856 let json = r#"{
1858 "id": "1",
1859 "content": "test",
1860 "source": "memory",
1861 "source_id": "mem_1",
1862 "title": "test",
1863 "url": null,
1864 "chunk_index": 0,
1865 "last_modified": 1711000000,
1866 "score": 0.8,
1867 "memory_type": "fact",
1868 "domain": null,
1869 "source_agent": null,
1870 "confidence": null,
1871 "confirmed": null
1872 }"#;
1873 let result: SearchResult = serde_json::from_str(json).unwrap();
1874 assert!(result.entity_id.is_none());
1875 assert!(result.entity_name.is_none());
1876 assert!(result.quality.is_none());
1877 assert!(!result.is_archived);
1878 assert!(!result.is_recap);
1879 assert!(result.structured_fields.is_none());
1880 assert!(result.retrieval_cue.is_none());
1881 assert_eq!(result.raw_score, 0.0);
1882 }
1883
1884 #[test]
1885 fn test_search_result_with_structured_fields_and_retrieval_cue() {
1886 let json = r#"{
1887 "id": "1",
1888 "content": "Lucian prefers dark mode",
1889 "source": "memory",
1890 "source_id": "mem_1",
1891 "title": "Dark mode preference",
1892 "url": null,
1893 "chunk_index": 0,
1894 "last_modified": 1711000000,
1895 "score": 0.92,
1896 "memory_type": "preference",
1897 "domain": null,
1898 "source_agent": null,
1899 "confidence": null,
1900 "confirmed": null,
1901 "structured_fields": "{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}",
1902 "retrieval_cue": "What UI theme does Lucian prefer?"
1903 }"#;
1904 let result: SearchResult = serde_json::from_str(json).unwrap();
1905 assert_eq!(
1906 result.structured_fields.as_deref(),
1907 Some("{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}")
1908 );
1909 assert_eq!(
1910 result.retrieval_cue.as_deref(),
1911 Some("What UI theme does Lucian prefer?")
1912 );
1913 assert!(!result.is_archived);
1914 assert!(!result.is_recap);
1915 assert_eq!(result.raw_score, 0.0);
1916 }
1917
1918 #[test]
1919 fn test_search_result_knowledge_graph_source() {
1920 let json = r#"{
1922 "id": "obs_1",
1923 "content": "Prefers Rust over Go",
1924 "source": "knowledge_graph",
1925 "source_id": "ent_lucian",
1926 "title": "Lucian",
1927 "url": null,
1928 "chunk_index": 0,
1929 "last_modified": 1711000000,
1930 "score": 1.14,
1931 "memory_type": null,
1932 "domain": null,
1933 "source_agent": null,
1934 "confidence": null,
1935 "confirmed": null,
1936 "entity_id": "ent_lucian",
1937 "entity_name": "Lucian"
1938 }"#;
1939 let result: SearchResult = serde_json::from_str(json).unwrap();
1940 assert_eq!(result.source, "knowledge_graph");
1941 assert_eq!(result.entity_id.as_deref(), Some("ent_lucian"));
1942 assert_eq!(result.entity_name.as_deref(), Some("Lucian"));
1943 assert!(!result.is_archived);
1944 assert!(!result.is_recap);
1945 assert_eq!(result.raw_score, 0.0);
1946 }
1947
1948 #[tokio::test]
1951 async fn test_forget_blocked_on_http_transport() {
1952 let server = make_server(TransportMode::Http, "agent", None);
1953 let result = server.forget_impl("mem_123").await.unwrap();
1954 let content = &result.content[0];
1956 match content.raw {
1957 rmcp::model::RawContent::Text(ref tc) => {
1958 assert!(tc.text.contains("not available over remote connections"));
1959 }
1960 _ => panic!("expected text content"),
1961 }
1962 }
1963
1964 #[tokio::test]
1965 async fn test_forget_allowed_on_stdio_transport() {
1966 let server = make_server(TransportMode::Stdio, "agent", None);
1971 let result = server.forget_impl("mem_123").await.unwrap();
1972 assert!(
1973 result.is_error.unwrap_or(false),
1974 "should fail with connection error, not transport block"
1975 );
1976 }
1977
1978 #[test]
1981 fn test_context_request_default_limit() {
1982 let params = ContextParams {
1983 topic: Some("test".into()),
1984 limit: None,
1985 domain: None,
1986 };
1987 #[allow(deprecated)]
1988 let req = ChatContextRequest {
1989 query: None,
1990 conversation_id: params.topic,
1991 max_chunks: params.limit.unwrap_or(20),
1992 relevance_threshold: None,
1993 include_goals: true,
1994 domain: params.domain,
1995 };
1996 assert_eq!(req.max_chunks, 20);
1997 }
1998
1999 #[test]
2000 fn test_context_request_custom_limit() {
2001 let params = ContextParams {
2002 topic: None,
2003 limit: Some(5),
2004 domain: Some("work".into()),
2005 };
2006 #[allow(deprecated)]
2007 let req = ChatContextRequest {
2008 query: None,
2009 conversation_id: params.topic,
2010 max_chunks: params.limit.unwrap_or(20),
2011 relevance_threshold: None,
2012 include_goals: true,
2013 domain: params.domain,
2014 };
2015 assert_eq!(req.max_chunks, 5);
2016 assert_eq!(req.domain.as_deref(), Some("work"));
2017 }
2018
2019 #[test]
2020 fn test_context_maps_topic_to_conversation_id() {
2021 let params = ContextParams {
2022 topic: Some("project Origin".into()),
2023 limit: None,
2024 domain: None,
2025 };
2026 #[allow(deprecated)]
2027 let req = ChatContextRequest {
2028 query: None,
2029 conversation_id: params.topic.clone(),
2030 max_chunks: params.limit.unwrap_or(20),
2031 relevance_threshold: None,
2032 include_goals: true,
2033 domain: params.domain,
2034 };
2035 assert_eq!(req.conversation_id.as_deref(), Some("project Origin"));
2036 }
2037
2038 #[test]
2041 fn test_capture_constructs_store_request_with_entity() {
2042 let server = make_server(TransportMode::Stdio, "claude", None);
2043 let params = CaptureParams {
2044 content: "Alice manages the frontend team".into(),
2045 memory_type: Some("fact".into()),
2046 domain: Some("work".into()),
2047 entity: Some("Alice".into()),
2048 confidence: Some(0.9),
2049 supersedes: None,
2050 structured_fields: None,
2051 retrieval_cue: None,
2052 };
2053
2054 let source_agent = server.resolve_source_agent(None);
2056
2057 let req = StoreMemoryRequest {
2058 content: params.content,
2059 memory_type: params.memory_type,
2060 domain: params.domain,
2061 source_agent,
2062 title: None,
2063 confidence: params.confidence,
2064 supersedes: params.supersedes,
2065 entity: params.entity,
2066 entity_id: None,
2067 structured_fields: params.structured_fields.map(serde_json::Value::Object),
2068 retrieval_cue: params.retrieval_cue,
2069 };
2070
2071 let json = serde_json::to_value(&req).unwrap();
2072 assert_eq!(json["content"], "Alice manages the frontend team");
2073 assert_eq!(json["memory_type"], "fact");
2074 assert_eq!(json["domain"], "work");
2075 assert_eq!(json["entity"], "Alice");
2076 assert!(json["confidence"].as_f64().unwrap() > 0.89);
2077 assert_eq!(json["source_agent"], "claude");
2079 }
2080
2081 #[test]
2082 fn test_remember_http_mode_injects_agent() {
2083 let server = make_server(TransportMode::Http, "claude.ai", Some("lucian"));
2084 let source_agent = server.resolve_source_agent(None);
2085
2086 assert_eq!(source_agent, Some("claude.ai".into()));
2087 }
2088
2089 #[test]
2092 fn test_recall_constructs_search_request() {
2093 let params = RecallParams {
2094 query: "database choices".into(),
2095 limit: Some(5),
2096 memory_type: Some("decision".into()),
2097 domain: None,
2098 };
2099
2100 let req = SearchMemoryRequest {
2101 query: params.query,
2102 limit: params.limit.unwrap_or(10),
2103 memory_type: params.memory_type,
2104 domain: params.domain,
2105 source_agent: None,
2106 };
2107
2108 let json = serde_json::to_value(&req).unwrap();
2109 assert_eq!(json["query"], "database choices");
2110 assert_eq!(json["limit"], 5);
2111 assert_eq!(json["memory_type"], "decision");
2112 assert!(json.get("entity").is_none());
2113 assert!(json["domain"].is_null());
2114 assert!(json["source_agent"].is_null());
2115 }
2116
2117 #[test]
2125 fn test_capture_passes_through_all_canonical_types() {
2126 for t in origin_types::MemoryType::all_values() {
2127 let params = CaptureParams {
2128 content: "test".into(),
2129 memory_type: Some((*t).to_string()),
2130 domain: None,
2131 entity: None,
2132 confidence: None,
2133 supersedes: None,
2134 structured_fields: None,
2135 retrieval_cue: None,
2136 };
2137 assert_eq!(params.memory_type.as_deref(), Some(*t));
2138 }
2139 }
2140
2141 #[test]
2145 fn test_capture_passes_through_legacy_goal_alias() {
2146 let params = CaptureParams {
2147 content: "test".into(),
2148 memory_type: Some("goal".into()),
2149 domain: None,
2150 entity: None,
2151 confidence: None,
2152 supersedes: None,
2153 structured_fields: None,
2154 retrieval_cue: None,
2155 };
2156 assert_eq!(params.memory_type.as_deref(), Some("goal"));
2157 }
2158
2159 #[test]
2162 fn test_capture_params_with_structured_fields_and_cue() {
2163 let json = r#"{
2164 "content": "Lucian prefers dark mode",
2165 "structured_fields": {"theme":"dark"},
2166 "retrieval_cue": "What theme does Lucian prefer?"
2167 }"#;
2168 let params: CaptureParams = serde_json::from_str(json).unwrap();
2169 let structured_fields = params.structured_fields.expect("structured_fields");
2170 assert_eq!(
2171 structured_fields.get("theme"),
2172 Some(&serde_json::Value::String("dark".into()))
2173 );
2174 assert_eq!(
2175 params.retrieval_cue.as_deref(),
2176 Some("What theme does Lucian prefer?")
2177 );
2178 }
2179
2180 #[test]
2181 fn test_store_request_with_structured_fields() {
2182 let req = StoreMemoryRequest {
2183 content: "test".into(),
2184 memory_type: Some("fact".into()),
2185 domain: None,
2186 source_agent: None,
2187 title: None,
2188 confidence: None,
2189 supersedes: None,
2190 entity: None,
2191 entity_id: None,
2192 structured_fields: Some(serde_json::json!({"key":"val"})),
2193 retrieval_cue: Some("What is the key?".into()),
2194 };
2195 let json = serde_json::to_value(&req).unwrap();
2196 assert_eq!(json["structured_fields"], serde_json::json!({"key":"val"}));
2197 assert_eq!(json["retrieval_cue"], "What is the key?");
2198 }
2199
2200 #[test]
2203 fn test_chat_context_response() {
2204 let json = r#"{
2205 "context": "User prefers dark mode. Works on Origin project.",
2206 "profile": {
2207 "narrative": "narrative",
2208 "identity": [],
2209 "preferences": [],
2210 "goals": []
2211 },
2212 "knowledge": {
2213 "pages": [],
2214 "decisions": [],
2215 "relevant_memories": [],
2216 "graph_context": []
2217 },
2218 "took_ms": 12.5,
2219 "token_estimates": {
2220 "tier1_identity": 1,
2221 "tier2_project": 2,
2222 "tier3_relevant": 3,
2223 "total": 6
2224 }
2225 }"#;
2226 let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
2227 assert!(!resp.context.is_empty());
2228 assert!(resp.profile.identity.is_empty());
2229 assert_eq!(resp.took_ms, 12.5);
2230 assert_eq!(resp.token_estimates.total, 6);
2231 }
2232
2233 #[test]
2234 fn test_chat_context_response_empty() {
2235 let json = r#"{
2236 "context": "",
2237 "profile": {
2238 "narrative": "",
2239 "identity": [],
2240 "preferences": [],
2241 "goals": []
2242 },
2243 "knowledge": {
2244 "pages": [],
2245 "decisions": [],
2246 "relevant_memories": [],
2247 "graph_context": []
2248 },
2249 "took_ms": 1.0,
2250 "token_estimates": {
2251 "tier1_identity": 0,
2252 "tier2_project": 0,
2253 "tier3_relevant": 0,
2254 "total": 0
2255 }
2256 }"#;
2257 let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
2258 assert!(resp.context.is_empty());
2259 }
2260
2261 fn server_instructions() -> String {
2268 let s = make_server(TransportMode::Stdio, "test", None);
2269 s.get_info()
2270 .instructions
2271 .expect("server must ship with_instructions")
2272 }
2273
2274 #[test]
2275 fn instructions_mention_cumulative_knowledge() {
2276 assert!(
2277 server_instructions().contains("cumulative"),
2278 "with_instructions must describe Origin as cumulative"
2279 );
2280 }
2281
2282 #[test]
2283 fn instructions_mention_shared_across_tools() {
2284 assert!(
2285 server_instructions().contains("shared across all"),
2286 "with_instructions must tell agents the store is shared across tools"
2287 );
2288 }
2289
2290 #[test]
2291 fn instructions_mention_how_user_thinks() {
2292 assert!(
2293 server_instructions().contains("how the user thinks"),
2294 "with_instructions must frame context as modeling how the user thinks"
2295 );
2296 }
2297
2298 #[test]
2299 fn instructions_use_proactive_framing() {
2300 assert!(
2301 server_instructions().contains("STORE PROACTIVELY"),
2302 "with_instructions must use STORE PROACTIVELY framing (not passive WHEN TO STORE)"
2303 );
2304 }
2305
2306 #[test]
2307 fn instructions_ban_tool_output_storage() {
2308 assert!(
2309 server_instructions().contains("Tool output or command results"),
2310 "with_instructions must explicitly rule out tool output as storage material"
2311 );
2312 }
2313
2314 #[test]
2315 fn instructions_ban_ghost_inferences() {
2316 assert!(
2317 server_instructions().contains("Your own inferences"),
2318 "with_instructions must rule out storing agent's own inferences user didn't express"
2319 );
2320 }
2321
2322 #[test]
2323 fn instructions_call_out_atomic_memory() {
2324 assert!(
2325 server_instructions().contains("Atomic: one idea per memory"),
2326 "with_instructions must call out the atomic-memory rule explicitly by name"
2327 );
2328 }
2329
2330 #[test]
2331 fn instructions_specify_declarative_writing() {
2332 assert!(
2333 server_instructions().contains("Declarative, not narrative"),
2334 "with_instructions must require declarative (not narrative) writing style"
2335 );
2336 }
2337
2338 #[test]
2339 fn instructions_default_to_omit_memory_type() {
2340 let i = server_instructions();
2341 assert!(
2342 i.contains("omit and trust the backend"),
2343 "with_instructions must default agents to omitting memory_type"
2344 );
2345 assert!(
2346 i.contains("do NOT set memory_type"),
2347 "with_instructions must explicitly say do NOT set memory_type by default"
2348 );
2349 }
2350
2351 #[test]
2352 fn instructions_list_every_canonical_memory_type() {
2353 let i = server_instructions();
2354 for ty in origin_types::MemoryType::all_values() {
2355 assert!(
2356 contains_word(&i, ty),
2357 "with_instructions must list canonical memory type \"{ty}\" so MCP clients see the full vocabulary",
2358 );
2359 }
2360 }
2361
2362 #[test]
2363 fn instructions_omit_legacy_goal_type() {
2364 let i = server_instructions();
2365 assert!(
2371 !contains_word(&i, "goal"),
2372 "with_instructions must not advertise legacy \"goal\" memory_type"
2373 );
2374 }
2375
2376 fn contains_word(haystack: &str, needle: &str) -> bool {
2381 haystack
2382 .split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
2383 .any(|tok| tok == needle)
2384 }
2385
2386 #[test]
2387 fn instructions_carve_out_decisions_for_decision_log() {
2388 let i = server_instructions();
2389 assert!(
2390 i.contains("Decision Log"),
2391 "with_instructions must name the Decision Log as the reason for explicit decision typing"
2392 );
2393 assert!(
2394 i.contains("memory_type=\"decision\""),
2395 "with_instructions must tell agents to set memory_type=\"decision\" explicitly for decisions"
2396 );
2397 }
2398
2399 fn tool_descriptions() -> std::collections::HashMap<String, String> {
2402 let server = make_server(TransportMode::Stdio, "test", None);
2403 server
2404 .tool_router
2405 .list_all()
2406 .into_iter()
2407 .filter_map(|t| {
2408 let desc = t.description.as_ref()?.to_string();
2409 Some((t.name.to_string(), desc))
2410 })
2411 .collect()
2412 }
2413
2414 #[test]
2415 fn capture_description_calls_out_atomic() {
2416 let descriptions = tool_descriptions();
2417 let capture = descriptions.get("capture").expect("capture tool exists");
2418 assert!(
2419 capture.contains("Each call is one atomic idea"),
2420 "capture description must call out atomic-per-call explicitly, got: {capture}"
2421 );
2422 }
2423
2424 #[test]
2425 fn context_description_frames_modeling_user() {
2426 let descriptions = tool_descriptions();
2427 let ctx = descriptions.get("context").expect("context tool exists");
2428 assert!(
2429 ctx.contains("how the user thinks"),
2430 "context description must frame the result as modeling how the user thinks, got: {ctx}"
2431 );
2432 }
2433
2434 #[test]
2435 fn doctor_description_mentions_setup_mode() {
2436 let descriptions = tool_descriptions();
2437 let status = descriptions.get("doctor").expect("doctor tool exists");
2438 assert!(
2439 status.contains("Basic Memory"),
2440 "doctor description must mention setup modes, got: {status}"
2441 );
2442 assert!(
2443 status.contains("On-device Model"),
2444 "doctor description must mention on-device setup, got: {status}"
2445 );
2446 assert!(
2447 status.contains("not part of the memory loop"),
2448 "doctor description must frame itself as diagnostic-only, got: {status}"
2449 );
2450 }
2451
2452 #[test]
2453 fn recall_memory_type_param_lists_two_level_filter() {
2454 let params_schema = serde_json::to_string(&schemars::schema_for!(RecallParams))
2455 .expect("RecallParams schema serializes");
2456 assert!(
2457 params_schema.contains("Two-level filter"),
2458 "RecallParams.memory_type must advertise the two-level filter, got schema: {params_schema}"
2459 );
2460 assert!(
2461 params_schema.contains("profile"),
2462 "RecallParams.memory_type must mention profile alias"
2463 );
2464 assert!(
2465 params_schema.contains("knowledge"),
2466 "RecallParams.memory_type must mention knowledge alias"
2467 );
2468 }
2469
2470 #[test]
2475 fn test_create_entity_params_minimal() {
2476 let json = r#"{"name": "Alice", "entity_type": "person"}"#;
2477 let params: CreateEntityParams = serde_json::from_str(json).unwrap();
2478 assert_eq!(params.name, "Alice");
2479 assert_eq!(params.entity_type, "person");
2480 assert!(params.domain.is_none());
2481 assert!(params.confidence.is_none());
2482 }
2483
2484 #[test]
2485 fn test_create_entity_params_full() {
2486 let json = r#"{
2487 "name": "PostgreSQL",
2488 "entity_type": "tool",
2489 "domain": "origin",
2490 "confidence": 0.9
2491 }"#;
2492 let params: CreateEntityParams = serde_json::from_str(json).unwrap();
2493 assert_eq!(params.name, "PostgreSQL");
2494 assert_eq!(params.entity_type, "tool");
2495 assert_eq!(params.domain.as_deref(), Some("origin"));
2496 assert_eq!(params.confidence, Some(0.9));
2497 }
2498
2499 #[test]
2500 fn test_create_entity_params_missing_name_fails() {
2501 let json = r#"{"entity_type": "person"}"#;
2502 let result = serde_json::from_str::<CreateEntityParams>(json);
2503 assert!(result.is_err());
2504 }
2505
2506 #[test]
2507 fn test_create_entity_params_missing_type_fails() {
2508 let json = r#"{"name": "Alice"}"#;
2509 let result = serde_json::from_str::<CreateEntityParams>(json);
2510 assert!(result.is_err());
2511 }
2512
2513 #[test]
2514 fn test_create_entity_request_body_shape() {
2515 let server = make_server(TransportMode::Stdio, "claude", None);
2516 let params = CreateEntityParams {
2517 name: "Origin".into(),
2518 entity_type: "project".into(),
2519 domain: Some("origin".into()),
2520 confidence: Some(0.95),
2521 };
2522 let source_agent = server.resolve_source_agent(None);
2523 let req = CreateEntityRequest {
2524 name: params.name,
2525 entity_type: params.entity_type,
2526 domain: params.domain,
2527 source_agent,
2528 confidence: params.confidence,
2529 };
2530 let json = serde_json::to_value(&req).unwrap();
2531 assert_eq!(json["name"], "Origin");
2532 assert_eq!(json["entity_type"], "project");
2533 assert_eq!(json["domain"], "origin");
2534 assert_eq!(json["source_agent"], "claude");
2535 assert!(json["confidence"].as_f64().unwrap() > 0.94);
2536 }
2537
2538 #[test]
2541 fn test_create_relation_params() {
2542 let json = r#"{
2543 "from_entity": "Alice",
2544 "to_entity": "Origin",
2545 "relation_type": "works_on"
2546 }"#;
2547 let params: CreateRelationParams = serde_json::from_str(json).unwrap();
2548 assert_eq!(params.from_entity, "Alice");
2549 assert_eq!(params.to_entity, "Origin");
2550 assert_eq!(params.relation_type, "works_on");
2551 }
2552
2553 #[test]
2554 fn test_create_relation_params_missing_field_fails() {
2555 let json = r#"{"from_entity": "Alice", "to_entity": "Origin"}"#;
2556 let result = serde_json::from_str::<CreateRelationParams>(json);
2557 assert!(result.is_err());
2558 }
2559
2560 #[test]
2561 fn test_create_relation_request_body_shape() {
2562 let server = make_server(TransportMode::Stdio, "claude", None);
2563 let params = CreateRelationParams {
2564 from_entity: "Alice".into(),
2565 to_entity: "Origin".into(),
2566 relation_type: "prefers".into(),
2567 };
2568 let source_agent = server.resolve_source_agent(None);
2569 let req = CreateRelationRequest {
2570 from_entity: params.from_entity,
2571 to_entity: params.to_entity,
2572 relation_type: params.relation_type,
2573 source_agent,
2574 };
2575 let json = serde_json::to_value(&req).unwrap();
2576 assert_eq!(json["from_entity"], "Alice");
2577 assert_eq!(json["to_entity"], "Origin");
2578 assert_eq!(json["relation_type"], "prefers");
2579 assert_eq!(json["source_agent"], "claude");
2580 }
2581
2582 #[test]
2585 fn test_create_page_params_minimal() {
2586 let json = r#"{"title": "Origin daemon", "content": "Body text."}"#;
2587 let params: CreatePageParams = serde_json::from_str(json).unwrap();
2588 assert_eq!(params.title, "Origin daemon");
2589 assert_eq!(params.content, "Body text.");
2590 assert!(params.summary.is_none());
2591 assert!(params.entity_id.is_none());
2592 assert!(params.domain.is_none());
2593 assert!(params.source_memory_ids.is_empty());
2594 }
2595
2596 #[test]
2597 fn test_create_page_params_full() {
2598 let json = r##"{
2599 "title": "Origin daemon",
2600 "content": "Markdown body with [[wikilinks]].",
2601 "summary": "The headless HTTP daemon at the heart of Origin.",
2602 "entity_id": "ent_origin",
2603 "domain": "origin",
2604 "source_memory_ids": ["mem_1", "mem_2"]
2605 }"##;
2606 let params: CreatePageParams = serde_json::from_str(json).unwrap();
2607 assert_eq!(params.title, "Origin daemon");
2608 assert_eq!(
2609 params.summary.as_deref(),
2610 Some("The headless HTTP daemon at the heart of Origin.")
2611 );
2612 assert_eq!(params.entity_id.as_deref(), Some("ent_origin"));
2613 assert_eq!(params.domain.as_deref(), Some("origin"));
2614 assert_eq!(params.source_memory_ids, vec!["mem_1", "mem_2"]);
2615 }
2616
2617 #[test]
2618 fn test_create_page_params_missing_required_fails() {
2619 let json = r#"{"title": "Only title"}"#;
2620 let result = serde_json::from_str::<CreatePageParams>(json);
2621 assert!(result.is_err());
2622 }
2623
2624 #[test]
2625 fn test_create_page_request_body_shape() {
2626 let params = CreatePageParams {
2627 title: "Page".into(),
2628 content: "Body".into(),
2629 summary: Some("S".into()),
2630 entity_id: Some("ent_1".into()),
2631 domain: Some("origin".into()),
2632 source_memory_ids: vec!["mem_1".into()],
2633 };
2634 let req = CreateConceptRequest {
2635 title: params.title,
2636 content: params.content,
2637 summary: params.summary,
2638 entity_id: params.entity_id,
2639 domain: params.domain,
2640 source_memory_ids: params.source_memory_ids,
2641 };
2642 let json = serde_json::to_value(&req).unwrap();
2643 assert_eq!(json["title"], "Page");
2644 assert_eq!(json["content"], "Body");
2645 assert_eq!(json["summary"], "S");
2646 assert_eq!(json["entity_id"], "ent_1");
2647 assert_eq!(json["domain"], "origin");
2648 assert_eq!(json["source_memory_ids"], serde_json::json!(["mem_1"]));
2649 }
2650
2651 #[test]
2654 fn test_delete_page_params() {
2655 let json = r#"{"page_id": "page_abc"}"#;
2656 let params: DeletePageParams = serde_json::from_str(json).unwrap();
2657 assert_eq!(params.page_id, "page_abc");
2658 }
2659
2660 #[test]
2661 fn test_delete_page_params_missing_fails() {
2662 let json = r#"{}"#;
2663 let result = serde_json::from_str::<DeletePageParams>(json);
2664 assert!(result.is_err());
2665 }
2666
2667 #[tokio::test]
2668 async fn test_delete_page_blocked_on_http_transport() {
2669 let server = make_server(TransportMode::Http, "agent", None);
2670 let result = server.delete_page_impl("page_123").await.unwrap();
2671 let content = &result.content[0];
2672 match content.raw {
2673 rmcp::model::RawContent::Text(ref tc) => {
2674 assert!(tc.text.contains("not available over remote connections"));
2675 }
2676 _ => panic!("expected text content"),
2677 }
2678 }
2679
2680 #[tokio::test]
2681 async fn test_delete_page_allowed_on_stdio_transport() {
2682 let server = make_server(TransportMode::Stdio, "agent", None);
2684 let result = server.delete_page_impl("page_123").await.unwrap();
2685 assert!(
2686 result.is_error.unwrap_or(false),
2687 "should fail with connection error, not transport block"
2688 );
2689 }
2690
2691 #[test]
2694 fn test_get_page_params() {
2695 let json = r#"{"page_id": "page_abc"}"#;
2696 let params: GetPageParams = serde_json::from_str(json).unwrap();
2697 assert_eq!(params.page_id, "page_abc");
2698 }
2699
2700 #[test]
2701 fn test_get_page_params_missing_fails() {
2702 let json = r#"{}"#;
2703 let result = serde_json::from_str::<GetPageParams>(json);
2704 assert!(result.is_err());
2705 }
2706
2707 #[test]
2710 fn test_list_memories_params_empty() {
2711 let json = r#"{}"#;
2712 let params: ListMemoriesParams = serde_json::from_str(json).unwrap();
2713 assert!(params.memory_type.is_none());
2714 assert!(params.domain.is_none());
2715 assert!(params.limit.is_none());
2716 }
2717
2718 #[test]
2719 fn test_list_memories_params_full() {
2720 let json = r#"{"memory_type": "decision", "domain": "origin", "limit": 50}"#;
2721 let params: ListMemoriesParams = serde_json::from_str(json).unwrap();
2722 assert_eq!(params.memory_type.as_deref(), Some("decision"));
2723 assert_eq!(params.domain.as_deref(), Some("origin"));
2724 assert_eq!(params.limit, Some(50));
2725 }
2726
2727 #[test]
2728 fn test_list_memories_params_limit_as_string() {
2729 let json = r#"{"limit": "25"}"#;
2731 let params: ListMemoriesParams = serde_json::from_str(json).unwrap();
2732 assert_eq!(params.limit, Some(25));
2733 }
2734
2735 #[test]
2736 fn test_list_memories_request_body_shape() {
2737 let params = ListMemoriesParams {
2738 memory_type: Some("fact".into()),
2739 domain: None,
2740 limit: Some(10),
2741 };
2742 let req = ListMemoriesRequest {
2743 memory_type: params.memory_type,
2744 domain: params.domain,
2745 limit: params.limit.unwrap_or(100),
2746 };
2747 let json = serde_json::to_value(&req).unwrap();
2748 assert_eq!(json["memory_type"], "fact");
2749 assert!(json["domain"].is_null());
2750 assert_eq!(json["limit"], 10);
2751 }
2752
2753 #[test]
2754 fn test_list_memories_request_default_limit() {
2755 let params = ListMemoriesParams {
2756 memory_type: None,
2757 domain: None,
2758 limit: None,
2759 };
2760 let req = ListMemoriesRequest {
2761 memory_type: params.memory_type,
2762 domain: params.domain,
2763 limit: params.limit.unwrap_or(100),
2764 };
2765 assert_eq!(req.limit, 100);
2766 }
2767
2768 #[test]
2771 fn test_update_page_params_minimal() {
2772 let json =
2773 r#"{"page_id": "page_abc", "content": "fresh body", "source_memory_ids": ["mem_1"]}"#;
2774 let params: UpdatePageParams = serde_json::from_str(json).unwrap();
2775 assert_eq!(params.page_id, "page_abc");
2776 assert_eq!(params.content, "fresh body");
2777 assert_eq!(params.source_memory_ids, vec!["mem_1"]);
2778 assert!(params.summary.is_none());
2779 }
2780
2781 #[test]
2782 fn test_update_page_params_with_summary() {
2783 let json = r#"{
2784 "page_id": "page_abc",
2785 "content": "body",
2786 "source_memory_ids": ["mem_1", "mem_2"],
2787 "summary": "Refreshed claim."
2788 }"#;
2789 let params: UpdatePageParams = serde_json::from_str(json).unwrap();
2790 assert_eq!(params.summary.as_deref(), Some("Refreshed claim."));
2791 assert_eq!(params.source_memory_ids.len(), 2);
2792 }
2793
2794 #[test]
2795 fn test_update_page_params_missing_required_fails() {
2796 let json = r#"{"page_id": "page_abc", "content": "body"}"#;
2799 let result = serde_json::from_str::<UpdatePageParams>(json);
2800 assert!(result.is_err());
2801 }
2802
2803 #[test]
2804 fn test_update_page_request_body_shape() {
2805 let params = UpdatePageParams {
2806 page_id: "page_abc".into(),
2807 content: "Body".into(),
2808 source_memory_ids: vec!["mem_1".into()],
2809 summary: Some("S".into()),
2810 };
2811 let req = origin_types::requests::RefreshPageRequest {
2812 content: params.content,
2813 source_memory_ids: params.source_memory_ids,
2814 summary: params.summary,
2815 };
2816 let json = serde_json::to_value(&req).unwrap();
2817 assert_eq!(json["content"], "Body");
2818 assert_eq!(json["source_memory_ids"], serde_json::json!(["mem_1"]));
2819 assert_eq!(json["summary"], "S");
2820 assert!(json.get("page_id").is_none());
2822 }
2823
2824 #[test]
2827 fn new_crud_tools_are_registered() {
2828 let descriptions = tool_descriptions();
2829 for name in [
2830 "create_entity",
2831 "create_relation",
2832 "create_page",
2833 "update_page",
2834 "delete_page",
2835 "get_page",
2836 "get_page_links",
2837 "list_memories",
2838 "search_pages",
2839 "list_pages_recent",
2840 ] {
2841 assert!(
2842 descriptions.contains_key(name),
2843 "tool `{name}` must be registered, got: {:?}",
2844 descriptions.keys().collect::<Vec<_>>()
2845 );
2846 }
2847 }
2848
2849 #[test]
2850 fn capture_memory_type_schema_lists_every_canonical_type() {
2851 let params_schema = serde_json::to_string(&schemars::schema_for!(CaptureParams))
2852 .expect("CaptureParams schema serializes");
2853 for ty in origin_types::MemoryType::all_values() {
2854 assert!(
2855 params_schema.contains(ty),
2856 "CaptureParams.memory_type schema must list canonical type \"{ty}\", got: {params_schema}"
2857 );
2858 }
2859 }
2860
2861 #[test]
2862 fn recall_memory_type_schema_lists_every_canonical_type() {
2863 let params_schema = serde_json::to_string(&schemars::schema_for!(RecallParams))
2864 .expect("RecallParams schema serializes");
2865 for ty in origin_types::MemoryType::all_values() {
2866 assert!(
2867 params_schema.contains(ty),
2868 "RecallParams.memory_type schema must list canonical type \"{ty}\", got: {params_schema}"
2869 );
2870 }
2871 }
2872
2873 #[test]
2874 fn create_entity_schema_documents_name_and_type() {
2875 let schema = serde_json::to_string(&schemars::schema_for!(CreateEntityParams))
2876 .expect("CreateEntityParams schema serializes");
2877 assert!(
2878 schema.contains("Canonical entity name"),
2879 "schema must describe `name` field"
2880 );
2881 assert!(
2882 schema.contains("Entity category"),
2883 "schema must describe `entity_type` field"
2884 );
2885 }
2886
2887 #[test]
2888 fn create_page_schema_documents_traceability() {
2889 let schema = serde_json::to_string(&schemars::schema_for!(CreatePageParams))
2890 .expect("CreatePageParams schema serializes");
2891 assert!(
2892 schema.contains("traceability"),
2893 "schema must spell out why source_memory_ids matter"
2894 );
2895 }
2896
2897 #[test]
2898 fn delete_page_tool_is_marked_destructive() {
2899 let server = make_server(TransportMode::Stdio, "test", None);
2900 let tool = server
2901 .tool_router
2902 .list_all()
2903 .into_iter()
2904 .find(|t| t.name == "delete_page")
2905 .expect("delete_page registered");
2906 let ann = tool.annotations.as_ref().expect("annotations present");
2907 assert_eq!(
2908 ann.destructive_hint,
2909 Some(true),
2910 "delete_page must declare destructive_hint=true"
2911 );
2912 }
2913
2914 #[test]
2917 fn test_search_pages_params_minimal() {
2918 let json = r#"{"query": "mutex deadlock"}"#;
2919 let params: SearchPagesParams = serde_json::from_str(json).unwrap();
2920 assert_eq!(params.query, "mutex deadlock");
2921 assert!(params.limit.is_none());
2922 }
2923
2924 #[test]
2925 fn test_search_pages_params_full() {
2926 let json = r#"{"query": "distill architecture", "limit": 5}"#;
2927 let params: SearchPagesParams = serde_json::from_str(json).unwrap();
2928 assert_eq!(params.query, "distill architecture");
2929 assert_eq!(params.limit, Some(5));
2930 }
2931
2932 #[test]
2933 fn test_search_pages_params_missing_query_fails() {
2934 let json = r#"{"limit": 10}"#;
2935 let result = serde_json::from_str::<SearchPagesParams>(json);
2936 assert!(result.is_err());
2937 }
2938
2939 #[test]
2940 fn test_search_pages_params_limit_as_string() {
2941 let json = r#"{"query": "x", "limit": "3"}"#;
2942 let params: SearchPagesParams = serde_json::from_str(json).unwrap();
2943 assert_eq!(params.limit, Some(3));
2944 }
2945
2946 #[test]
2947 fn test_search_pages_request_body_shape() {
2948 let params = SearchPagesParams {
2949 query: "mutex".into(),
2950 limit: Some(7),
2951 };
2952 let req = SearchPagesRequest {
2953 query: params.query,
2954 limit: params.limit,
2955 };
2956 let json = serde_json::to_value(&req).unwrap();
2957 assert_eq!(json["query"], "mutex");
2958 assert_eq!(json["limit"], 7);
2959 }
2960
2961 #[test]
2964 fn test_list_pages_recent_params_empty() {
2965 let json = r#"{}"#;
2966 let params: ListPagesRecentParams = serde_json::from_str(json).unwrap();
2967 assert!(params.limit.is_none());
2968 assert!(params.since_ms.is_none());
2969 }
2970
2971 #[test]
2972 fn test_list_pages_recent_params_full() {
2973 let json = r#"{"limit": 20, "since_ms": 1715000000000}"#;
2974 let params: ListPagesRecentParams = serde_json::from_str(json).unwrap();
2975 assert_eq!(params.limit, Some(20));
2976 assert_eq!(params.since_ms, Some(1715000000000));
2977 }
2978
2979 #[test]
2980 fn test_list_pages_recent_params_string_numbers() {
2981 let json = r#"{"limit": "15", "since_ms": "1715000000000"}"#;
2982 let params: ListPagesRecentParams = serde_json::from_str(json).unwrap();
2983 assert_eq!(params.limit, Some(15));
2984 assert_eq!(params.since_ms, Some(1715000000000));
2985 }
2986
2987 #[test]
2988 fn list_pages_recent_url_construction() {
2989 assert_eq!(build_recent_pages_path(None, None), "/api/pages/recent");
2992 assert_eq!(
2993 build_recent_pages_path(Some(5), None),
2994 "/api/pages/recent?limit=5"
2995 );
2996 assert_eq!(
2997 build_recent_pages_path(None, Some(123)),
2998 "/api/pages/recent?since_ms=123"
2999 );
3000 assert_eq!(
3001 build_recent_pages_path(Some(10), Some(456)),
3002 "/api/pages/recent?limit=10&since_ms=456"
3003 );
3004 assert_eq!(
3006 build_recent_pages_path(None, Some(-1)),
3007 "/api/pages/recent?since_ms=-1"
3008 );
3009 }
3010
3011 #[test]
3012 fn search_pages_and_list_pages_recent_are_read_only() {
3013 let server = make_server(TransportMode::Stdio, "test", None);
3014 for name in ["search_pages", "list_pages_recent"] {
3015 let tool = server
3016 .tool_router
3017 .list_all()
3018 .into_iter()
3019 .find(|t| t.name == name)
3020 .unwrap_or_else(|| panic!("`{name}` registered"));
3021 let ann = tool.annotations.as_ref().expect("annotations present");
3022 assert_eq!(
3023 ann.read_only_hint,
3024 Some(true),
3025 "`{name}` must declare read_only_hint=true"
3026 );
3027 }
3028 }
3029}