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