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