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 creation_kind: None,
1140 workspace: None,
1141 };
1142 let resp: CreatePageResponse = match self.client.post("/api/pages", &req).await {
1143 Ok(r) => r,
1144 Err(e) => return Ok(tool_error(e, "create_page")),
1145 };
1146 let mut text = format!("Created page {}", resp.id);
1147 for w in &resp.warnings {
1148 text.push_str(&format!("\nwarning: {w}"));
1149 }
1150 Ok(CallToolResult::success(vec![Content::text(text)]))
1151 }
1152
1153 pub async fn update_page_impl(
1154 &self,
1155 params: UpdatePageParams,
1156 ) -> Result<CallToolResult, McpError> {
1157 if self.transport == TransportMode::Http {
1158 return Ok(CallToolResult::error(vec![Content::text(
1159 "Update operations are not available over remote connections. \
1160 Use local MCP on the machine running Origin to update pages."
1161 .to_string(),
1162 )]));
1163 }
1164 let req = origin_types::requests::RefreshPageRequest {
1165 content: params.content,
1166 source_memory_ids: params.source_memory_ids,
1167 summary: params.summary,
1168 };
1169 let path = format!("/api/pages/{}", params.page_id);
1170 let _: origin_types::responses::SuccessResponse = match self.client.put(&path, &req).await {
1174 Ok(r) => r,
1175 Err(e) => return Ok(tool_error(e, "update_page")),
1176 };
1177 Ok(CallToolResult::success(vec![Content::text(format!(
1178 "Refreshed page {}",
1179 params.page_id
1180 ))]))
1181 }
1182
1183 pub async fn delete_page_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
1184 if self.transport == TransportMode::Http {
1185 return Ok(CallToolResult::error(vec![Content::text(
1186 "Delete operations are not available over remote connections. \
1187 Use local MCP on the machine running Origin to delete pages."
1188 .to_string(),
1189 )]));
1190 }
1191
1192 let path = format!("/api/pages/{}", page_id);
1193 let resp: serde_json::Value = match self.client.delete(&path).await {
1194 Ok(r) => r,
1195 Err(e) => return Ok(tool_error(e, "delete_page")),
1196 };
1197 let status = resp
1198 .get("status")
1199 .and_then(|v| v.as_str())
1200 .unwrap_or("deleted");
1201 Ok(CallToolResult::success(vec![Content::text(format!(
1202 "Page {} {}",
1203 page_id, status
1204 ))]))
1205 }
1206
1207 pub async fn get_page_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
1208 let path = format!("/api/pages/{}", page_id);
1209 let resp: serde_json::Value = match self.client.get(&path).await {
1210 Ok(r) => r,
1211 Err(e) => return Ok(tool_error(e, "get_page")),
1212 };
1213 let pretty = serde_json::to_string_pretty(&resp).unwrap_or_else(|_| resp.to_string());
1214 Ok(CallToolResult::success(vec![Content::text(pretty)]))
1215 }
1216
1217 pub async fn get_page_links_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
1218 let path = format!("/api/pages/{}/links", page_id);
1219 let resp: origin_types::responses::PageLinksResponse = match self.client.get(&path).await {
1221 Ok(r) => r,
1222 Err(e) => return Ok(tool_error(e, "get_page_links")),
1223 };
1224 let pretty = serde_json::to_string_pretty(&resp).unwrap_or_else(|_| String::new());
1225 Ok(CallToolResult::success(vec![Content::text(pretty)]))
1226 }
1227
1228 pub async fn get_page_sources_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
1229 let path = format!("/api/pages/{}/sources", page_id);
1230 let resp: Vec<PageSourceWithMemory> = match self.client.get(&path).await {
1232 Ok(r) => r,
1233 Err(e) => return Ok(tool_error(e, "get_page_sources")),
1234 };
1235 let pretty = serde_json::to_string_pretty(&resp)
1236 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1237 Ok(CallToolResult::success(vec![Content::text(format!(
1238 "{} sources\n{}",
1239 resp.len(),
1240 pretty
1241 ))]))
1242 }
1243
1244 pub async fn get_memory_revisions_impl(
1245 &self,
1246 memory_id: &str,
1247 ) -> Result<CallToolResult, McpError> {
1248 let path = format!("/api/memory/{}/revisions", memory_id);
1249 let resp: ListMemoryRevisionsResponse = match self.client.get(&path).await {
1250 Ok(r) => r,
1251 Err(e) => return Ok(tool_error(e, "get_memory_revisions")),
1252 };
1253 let pretty = serde_json::to_string_pretty(&resp)
1254 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1255 Ok(CallToolResult::success(vec![Content::text(format!(
1256 "chain depth {}\n{}",
1257 resp.chain_depth, pretty
1258 ))]))
1259 }
1260
1261 pub async fn get_page_revisions_impl(&self, page_id: &str) -> Result<CallToolResult, McpError> {
1262 let path = format!("/api/pages/{}/revisions", page_id);
1263 let resp: ListPageRevisionsResponse = match self.client.get(&path).await {
1264 Ok(r) => r,
1265 Err(e) => return Ok(tool_error(e, "get_page_revisions")),
1266 };
1267 let pretty = serde_json::to_string_pretty(&resp)
1268 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1269 Ok(CallToolResult::success(vec![Content::text(format!(
1270 "version {} ({} entries)\n{}",
1271 resp.current_version,
1272 resp.entries.len(),
1273 pretty
1274 ))]))
1275 }
1276
1277 pub async fn list_memories_impl(
1278 &self,
1279 params: ListMemoriesParams,
1280 ) -> Result<CallToolResult, McpError> {
1281 let space_arg = effective_space(¶ms.space);
1282 let req = ListMemoriesRequest {
1283 memory_type: params.memory_type,
1284 space: space_arg,
1285 limit: params.limit.unwrap_or(100),
1286 confirmed: None,
1287 };
1288 let resp: ListMemoriesResponse = match self.client.post("/api/memory/list", &req).await {
1289 Ok(r) => r,
1290 Err(e) => return Ok(tool_error(e, "list_memories")),
1291 };
1292 let pretty = serde_json::to_string_pretty(&resp.memories)
1293 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1294 Ok(CallToolResult::success(vec![Content::text(format!(
1295 "{} memories\n{}",
1296 resp.memories.len(),
1297 pretty
1298 ))]))
1299 }
1300
1301 pub async fn search_pages_impl(
1302 &self,
1303 params: SearchPagesParams,
1304 ) -> Result<CallToolResult, McpError> {
1305 let req = SearchPagesRequest {
1306 query: params.query,
1307 limit: params.limit,
1308 page_type: params.page_type,
1309 };
1310 let resp: SearchPagesResponse = match self.client.post("/api/pages/search", &req).await {
1311 Ok(r) => r,
1312 Err(e) => return Ok(tool_error(e, "search_pages")),
1313 };
1314 let pretty = serde_json::to_string_pretty(&resp.pages)
1315 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1316 Ok(CallToolResult::success(vec![Content::text(format!(
1317 "{} pages\n{}",
1318 resp.pages.len(),
1319 pretty
1320 ))]))
1321 }
1322
1323 pub async fn list_pages_recent_impl(
1324 &self,
1325 params: ListPagesRecentParams,
1326 ) -> Result<CallToolResult, McpError> {
1327 let path = build_recent_pages_path(params.limit, params.since_ms);
1328 let resp: Vec<RecentActivityItem> = match self.client.get(&path).await {
1329 Ok(r) => r,
1330 Err(e) => return Ok(tool_error(e, "list_pages_recent")),
1331 };
1332 let pretty = serde_json::to_string_pretty(&resp)
1333 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1334 Ok(CallToolResult::success(vec![Content::text(format!(
1335 "{} recent pages\n{}",
1336 resp.len(),
1337 pretty
1338 ))]))
1339 }
1340
1341 pub async fn list_spaces_impl(
1342 &self,
1343 _params: ListSpacesParams,
1344 ) -> Result<CallToolResult, McpError> {
1345 let resp: Vec<Space> = match self.client.get("/api/spaces").await {
1346 Ok(r) => r,
1347 Err(e) => return Ok(tool_error(e, "list_spaces")),
1348 };
1349 let pretty = serde_json::to_string_pretty(&resp)
1350 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1351 Ok(CallToolResult::success(vec![Content::text(format!(
1352 "{} spaces\n{}",
1353 resp.len(),
1354 pretty
1355 ))]))
1356 }
1357
1358 pub async fn list_refinements_impl(
1359 &self,
1360 params: ListRefinementsParams,
1361 ) -> Result<CallToolResult, McpError> {
1362 let mut path = String::from("/api/refinery/queue");
1363 let mut q: Vec<String> = Vec::new();
1364 if let Some(a) = params.action.as_deref() {
1365 q.push(format!("action={}", url_encode_simple(a)));
1366 }
1367 if let Some(l) = params.limit {
1368 q.push(format!("limit={l}"));
1369 }
1370 if !q.is_empty() {
1371 path.push('?');
1372 path.push_str(&q.join("&"));
1373 }
1374
1375 let resp: ListRefinementsResponse = match self.client.get(&path).await {
1376 Ok(v) => v,
1377 Err(e) => return Ok(tool_error(e, "list_refinements")),
1378 };
1379
1380 let pretty = serde_json::to_string_pretty(&resp.proposals)
1381 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1382 Ok(CallToolResult::success(vec![Content::text(format!(
1383 "{} pending review proposals\n{}",
1384 resp.proposals.len(),
1385 pretty
1386 ))]))
1387 }
1388
1389 pub async fn reject_refinement_impl(
1390 &self,
1391 params: RejectRefinementParams,
1392 ) -> Result<CallToolResult, McpError> {
1393 if self.transport == TransportMode::Http {
1394 return Ok(CallToolResult::error(vec![Content::text(
1395 "Review proposal operations are not available over remote connections. \
1396 Use local MCP on the machine running Origin to reject proposals."
1397 .to_string(),
1398 )]));
1399 }
1400 let path = format!(
1401 "/api/refinery/queue/{}/reject",
1402 url_encode_simple(¶ms.id)
1403 );
1404 let resp: RejectRefinementResponse =
1405 match self.client.post(&path, &serde_json::json!({})).await {
1406 Ok(v) => v,
1407 Err(e) => return Ok(tool_error(e, "reject_refinement")),
1408 };
1409
1410 Ok(CallToolResult::success(vec![Content::text(format!(
1411 "Review proposal {} dismissed.",
1412 resp.id
1413 ))]))
1414 }
1415
1416 pub async fn accept_refinement_impl(
1417 &self,
1418 params: AcceptRefinementParams,
1419 ) -> Result<CallToolResult, McpError> {
1420 if self.transport == TransportMode::Http {
1421 return Ok(CallToolResult::error(vec![Content::text(
1422 "Review proposal operations are not available over remote connections. \
1423 Use local MCP on the machine running Origin to accept proposals."
1424 .to_string(),
1425 )]));
1426 }
1427 let path = format!(
1428 "/api/refinery/queue/{}/accept",
1429 url_encode_simple(¶ms.id)
1430 );
1431 let resp: AcceptRefinementResponse =
1432 match self.client.post(&path, &serde_json::json!({})).await {
1433 Ok(v) => v,
1434 Err(e) => return Ok(tool_error(e, "accept_refinement")),
1435 };
1436
1437 Ok(CallToolResult::success(vec![Content::text(format!(
1438 "Review proposal {} accepted (action={}).",
1439 resp.id, resp.action_applied
1440 ))]))
1441 }
1442
1443 pub async fn list_nurture_impl(
1444 &self,
1445 params: ListNurtureParams,
1446 ) -> Result<CallToolResult, McpError> {
1447 let space_arg = effective_space(¶ms.space);
1448 let mut path = String::from("/api/memory/nurture");
1449 let mut q: Vec<String> = Vec::new();
1450 if let Some(l) = params.limit {
1451 q.push(format!("limit={}", l.clamp(1, 500)));
1452 }
1453 if let Some(s) = space_arg.as_deref().filter(|s| !s.is_empty()) {
1454 q.push(format!("space={}", url_encode_simple(s)));
1455 }
1456 if !q.is_empty() {
1457 path.push('?');
1458 path.push_str(&q.join("&"));
1459 }
1460
1461 let resp: origin_types::responses::NurtureCardsResponse = match self.client.get(&path).await
1462 {
1463 Ok(v) => v,
1464 Err(e) => return Ok(tool_error(e, "list_nurture")),
1465 };
1466
1467 let pretty = serde_json::to_string_pretty(&resp.cards)
1468 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1469 Ok(CallToolResult::success(vec![Content::text(format!(
1470 "{} nurture cards\n{}",
1471 resp.cards.len(),
1472 pretty
1473 ))]))
1474 }
1475
1476 pub async fn list_entity_suggestions_impl(
1477 &self,
1478 _params: ListEntitySuggestionsParams,
1479 ) -> Result<CallToolResult, McpError> {
1480 let resp: Vec<origin_types::entities::EntitySuggestion> =
1481 match self.client.get("/api/memory/entity-suggestions").await {
1482 Ok(v) => v,
1483 Err(e) => return Ok(tool_error(e, "list_entity_suggestions")),
1484 };
1485 let pretty = serde_json::to_string_pretty(&resp)
1486 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1487 Ok(CallToolResult::success(vec![Content::text(format!(
1488 "{} entity suggestion(s)\n{}",
1489 resp.len(),
1490 pretty
1491 ))]))
1492 }
1493
1494 pub async fn accept_revision_impl(
1495 &self,
1496 req: AcceptRevisionRequest,
1497 ) -> Result<CallToolResult, McpError> {
1498 if self.transport == TransportMode::Http {
1499 return Ok(CallToolResult::error(vec![Content::text(
1500 "Revision operations are not available over remote connections. \
1501 Use local MCP on the machine running Origin to accept memory revisions."
1502 .to_string(),
1503 )]));
1504 }
1505 let path = format!("/api/memory/revision/{}/accept", req.target_source_id);
1506 let response = match self
1507 .client
1508 .post_empty::<RevisionAcceptResponse>(&path)
1509 .await
1510 {
1511 Ok(r) => r,
1512 Err(e) => return Ok(tool_error(e, "accept_revision")),
1513 };
1514 let pretty = serde_json::to_string_pretty(&response)
1515 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1516 Ok(CallToolResult::success(vec![Content::text(pretty)]))
1517 }
1518
1519 pub async fn dismiss_revision_impl(
1520 &self,
1521 req: DismissRevisionRequest,
1522 ) -> Result<CallToolResult, McpError> {
1523 if self.transport == TransportMode::Http {
1524 return Ok(CallToolResult::error(vec![Content::text(
1525 "Revision operations are not available over remote connections. \
1526 Use local MCP on the machine running Origin to dismiss memory revisions."
1527 .to_string(),
1528 )]));
1529 }
1530 let path = format!("/api/memory/revision/{}/dismiss", req.target_source_id);
1531 let response = match self
1532 .client
1533 .post_empty::<RevisionDismissResponse>(&path)
1534 .await
1535 {
1536 Ok(r) => r,
1537 Err(e) => return Ok(tool_error(e, "dismiss_revision")),
1538 };
1539 let pretty = serde_json::to_string_pretty(&response)
1540 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1541 Ok(CallToolResult::success(vec![Content::text(pretty)]))
1542 }
1543
1544 pub async fn dismiss_contradiction_impl(
1545 &self,
1546 req: DismissContradictionRequest,
1547 ) -> Result<CallToolResult, McpError> {
1548 if self.transport == TransportMode::Http {
1549 return Ok(CallToolResult::error(vec![Content::text(
1550 "Contradiction operations are not available over remote connections. \
1551 Use local MCP on the machine running Origin to dismiss contradictions."
1552 .to_string(),
1553 )]));
1554 }
1555 let path = format!("/api/memory/contradiction/{}/dismiss", req.source_id);
1556 let response = match self
1557 .client
1558 .post_empty::<ContradictionDismissResponse>(&path)
1559 .await
1560 {
1561 Ok(r) => r,
1562 Err(e) => return Ok(tool_error(e, "dismiss_contradiction")),
1563 };
1564 let pretty = serde_json::to_string_pretty(&response)
1565 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1566 Ok(CallToolResult::success(vec![Content::text(pretty)]))
1567 }
1568
1569 pub async fn list_pending_imports_impl(
1570 &self,
1571 _params: ListPendingImportsParams,
1572 ) -> Result<CallToolResult, McpError> {
1573 let resp: Vec<origin_types::import::PendingImport> =
1574 match self.client.get("/api/import/state").await {
1575 Ok(v) => v,
1576 Err(e) => return Ok(tool_error(e, "list_pending_imports")),
1577 };
1578 let pretty = serde_json::to_string_pretty(&resp)
1579 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1580 Ok(CallToolResult::success(vec![Content::text(format!(
1581 "{} pending import(s)\n{}",
1582 resp.len(),
1583 pretty
1584 ))]))
1585 }
1586
1587 pub async fn list_rejections_impl(
1588 &self,
1589 params: ListRejectionsParams,
1590 ) -> Result<CallToolResult, McpError> {
1591 let mut path = String::from("/api/memory/rejections");
1592 let mut q: Vec<String> = Vec::new();
1593 if let Some(l) = params.limit {
1594 q.push(format!("limit={}", l.clamp(1, 500)));
1595 }
1596 if let Some(r) = params.reason.as_deref().filter(|s| !s.is_empty()) {
1597 q.push(format!("reason={}", url_encode_simple(r)));
1598 }
1599 if !q.is_empty() {
1600 path.push('?');
1601 path.push_str(&q.join("&"));
1602 }
1603
1604 let resp: Vec<origin_types::memory::RejectionRecord> = match self.client.get(&path).await {
1605 Ok(v) => v,
1606 Err(e) => return Ok(tool_error(e, "list_rejections")),
1607 };
1608
1609 let pretty = serde_json::to_string_pretty(&resp)
1610 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1611 Ok(CallToolResult::success(vec![Content::text(format!(
1612 "{} rejection(s)\n{}",
1613 resp.len(),
1614 pretty
1615 ))]))
1616 }
1617
1618 pub async fn list_pending_revisions_impl(
1619 &self,
1620 params: ListPendingRevisionsParams,
1621 ) -> Result<CallToolResult, McpError> {
1622 let path = match params.limit {
1623 Some(l) => format!("/api/memory/pending-revisions?limit={}", l.clamp(1, 500)),
1624 None => "/api/memory/pending-revisions".to_string(),
1625 };
1626 let resp: Vec<origin_types::responses::PendingRevisionItem> =
1627 match self.client.get(&path).await {
1628 Ok(v) => v,
1629 Err(e) => return Ok(tool_error(e, "list_pending_revisions")),
1630 };
1631 let pretty = serde_json::to_string_pretty(&resp)
1632 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1633 Ok(CallToolResult::success(vec![Content::text(format!(
1634 "{} pending revision(s)\n{}",
1635 resp.len(),
1636 pretty
1637 ))]))
1638 }
1639
1640 pub async fn list_orphan_links_impl(
1641 &self,
1642 params: ListOrphanLinksParams,
1643 ) -> Result<CallToolResult, McpError> {
1644 let path = match params.min_count {
1645 Some(n) => format!("/api/pages/orphan-links?min_count={}", n.max(1)),
1646 None => "/api/pages/orphan-links".to_string(),
1647 };
1648 let resp: origin_types::responses::OrphanLinksResponse = match self.client.get(&path).await
1649 {
1650 Ok(v) => v,
1651 Err(e) => return Ok(tool_error(e, "list_orphan_links")),
1652 };
1653 let pretty = serde_json::to_string_pretty(&resp)
1654 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
1655 Ok(CallToolResult::success(vec![Content::text(format!(
1656 "{} orphan link(s)\n{}",
1657 resp.orphan_labels.len(),
1658 pretty
1659 ))]))
1660 }
1661}
1662
1663fn build_recent_pages_path(limit: Option<usize>, since_ms: Option<i64>) -> String {
1667 let mut path = String::from("/api/pages/recent");
1668 let mut q: Vec<String> = Vec::new();
1669 if let Some(l) = limit {
1670 q.push(format!("limit={}", l));
1671 }
1672 if let Some(s) = since_ms {
1673 q.push(format!("since_ms={}", s));
1674 }
1675 if !q.is_empty() {
1676 path.push('?');
1677 path.push_str(&q.join("&"));
1678 }
1679 path
1680}
1681
1682fn url_encode_simple(s: &str) -> String {
1685 s.chars()
1686 .flat_map(|c| match c {
1687 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
1688 vec![c]
1689 }
1690 _ => format!("%{:02X}", c as u32).chars().collect(),
1691 })
1692 .collect()
1693}
1694
1695#[tool_router]
1698impl OriginMcpServer {
1699 pub fn new(
1700 client: OriginClient,
1701 transport: TransportMode,
1702 agent_name: String,
1703 user_id: Option<String>,
1704 ) -> Self {
1705 Self {
1706 tool_router: Self::tool_router(),
1707 client,
1708 transport,
1709 agent_name,
1710 client_name: std::sync::Arc::new(std::sync::Mutex::new(None)),
1711 user_id,
1712 }
1713 }
1714
1715 #[tool(
1718 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.",
1719 annotations(
1720 title = "Capture",
1721 read_only_hint = false,
1722 destructive_hint = false,
1723 idempotent_hint = false,
1724 open_world_hint = false
1725 )
1726 )]
1727 async fn capture(
1728 &self,
1729 Parameters(params): Parameters<CaptureParams>,
1730 ) -> Result<CallToolResult, McpError> {
1731 self.capture_impl(params).await
1732 }
1733
1734 #[tool(
1735 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.",
1736 annotations(title = "Recall", read_only_hint = true, open_world_hint = false)
1737 )]
1738 async fn recall(
1739 &self,
1740 Parameters(params): Parameters<RecallParams>,
1741 ) -> Result<CallToolResult, McpError> {
1742 self.recall_impl(params).await
1743 }
1744
1745 #[tool(
1746 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.",
1747 annotations(title = "Context", read_only_hint = true, open_world_hint = false)
1748 )]
1749 async fn context(
1750 &self,
1751 Parameters(params): Parameters<ContextParams>,
1752 ) -> Result<CallToolResult, McpError> {
1753 self.context_impl(params).await
1754 }
1755
1756 #[tool(
1757 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.",
1758 annotations(title = "Doctor", read_only_hint = true, open_world_hint = false)
1759 )]
1760 async fn doctor(&self) -> Result<CallToolResult, McpError> {
1761 self.doctor_impl().await
1762 }
1763
1764 #[tool(
1765 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.",
1766 annotations(
1767 title = "Forget",
1768 read_only_hint = false,
1769 destructive_hint = true,
1770 idempotent_hint = true,
1771 open_world_hint = false
1772 )
1773 )]
1774 async fn forget(
1775 &self,
1776 Parameters(params): Parameters<ForgetParams>,
1777 ) -> Result<CallToolResult, McpError> {
1778 self.forget_impl(¶ms.memory_id).await
1779 }
1780
1781 #[tool(
1782 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.",
1783 annotations(
1784 title = "Distill",
1785 read_only_hint = false,
1786 destructive_hint = false,
1787 idempotent_hint = true,
1788 open_world_hint = false
1789 )
1790 )]
1791 async fn distill(
1792 &self,
1793 Parameters(params): Parameters<DistillParams>,
1794 ) -> Result<CallToolResult, McpError> {
1795 self.distill_impl(params).await
1796 }
1797
1798 #[tool(
1799 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.",
1800 annotations(title = "List pending", read_only_hint = true, open_world_hint = false)
1801 )]
1802 async fn list_pending(
1803 &self,
1804 Parameters(params): Parameters<ListPendingParams>,
1805 ) -> Result<CallToolResult, McpError> {
1806 self.list_pending_impl(params).await
1807 }
1808
1809 #[tool(
1810 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`.",
1811 annotations(
1812 title = "Confirm memory",
1813 read_only_hint = false,
1814 destructive_hint = false,
1815 idempotent_hint = true,
1816 open_world_hint = false
1817 )
1818 )]
1819 async fn confirm_memory(
1820 &self,
1821 Parameters(params): Parameters<ConfirmMemoryParams>,
1822 ) -> Result<CallToolResult, McpError> {
1823 self.confirm_memory_impl(¶ms.memory_id).await
1824 }
1825
1826 #[tool(
1829 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.",
1830 annotations(
1831 title = "Create entity",
1832 read_only_hint = false,
1833 destructive_hint = false,
1834 idempotent_hint = false,
1835 open_world_hint = false
1836 )
1837 )]
1838 async fn create_entity(
1839 &self,
1840 Parameters(params): Parameters<CreateEntityParams>,
1841 ) -> Result<CallToolResult, McpError> {
1842 self.create_entity_impl(params).await
1843 }
1844
1845 #[tool(
1846 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.",
1847 annotations(
1848 title = "Create relation",
1849 read_only_hint = false,
1850 destructive_hint = false,
1851 idempotent_hint = false,
1852 open_world_hint = false
1853 )
1854 )]
1855 async fn create_relation(
1856 &self,
1857 Parameters(params): Parameters<CreateRelationParams>,
1858 ) -> Result<CallToolResult, McpError> {
1859 self.create_relation_impl(params).await
1860 }
1861
1862 #[tool(
1863 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.",
1864 annotations(
1865 title = "Create observation",
1866 read_only_hint = false,
1867 destructive_hint = false,
1868 idempotent_hint = false,
1869 open_world_hint = false
1870 )
1871 )]
1872 async fn create_observation(
1873 &self,
1874 Parameters(params): Parameters<CreateObservationParams>,
1875 ) -> Result<CallToolResult, McpError> {
1876 self.create_observation_impl(params).await
1877 }
1878
1879 #[tool(
1880 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).",
1881 annotations(
1882 title = "Confirm entity",
1883 read_only_hint = false,
1884 destructive_hint = false,
1885 idempotent_hint = true,
1886 open_world_hint = false
1887 )
1888 )]
1889 async fn confirm_entity(
1890 &self,
1891 Parameters(params): Parameters<ConfirmEntityParams>,
1892 ) -> Result<CallToolResult, McpError> {
1893 self.confirm_entity_impl(params).await
1894 }
1895
1896 #[tool(
1897 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).",
1898 annotations(
1899 title = "Update observation",
1900 read_only_hint = false,
1901 destructive_hint = false,
1902 idempotent_hint = true,
1903 open_world_hint = false
1904 )
1905 )]
1906 async fn update_observation(
1907 &self,
1908 Parameters(params): Parameters<UpdateObservationParams>,
1909 ) -> Result<CallToolResult, McpError> {
1910 self.update_observation_impl(params).await
1911 }
1912
1913 #[tool(
1914 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).",
1915 annotations(
1916 title = "Confirm observation",
1917 read_only_hint = false,
1918 destructive_hint = false,
1919 idempotent_hint = true,
1920 open_world_hint = false
1921 )
1922 )]
1923 async fn confirm_observation(
1924 &self,
1925 Parameters(params): Parameters<ConfirmObservationParams>,
1926 ) -> Result<CallToolResult, McpError> {
1927 self.confirm_observation_impl(params).await
1928 }
1929
1930 #[tool(
1931 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).",
1932 annotations(
1933 title = "Delete observation",
1934 read_only_hint = false,
1935 destructive_hint = true,
1936 idempotent_hint = true,
1937 open_world_hint = false
1938 )
1939 )]
1940 async fn delete_observation(
1941 &self,
1942 Parameters(params): Parameters<DeleteObservationParams>,
1943 ) -> Result<CallToolResult, McpError> {
1944 self.delete_observation_impl(params).await
1945 }
1946
1947 #[tool(
1948 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.",
1949 annotations(
1950 title = "Create page",
1951 read_only_hint = false,
1952 destructive_hint = false,
1953 idempotent_hint = false,
1954 open_world_hint = false
1955 )
1956 )]
1957 async fn create_page(
1958 &self,
1959 Parameters(params): Parameters<CreatePageParams>,
1960 ) -> Result<CallToolResult, McpError> {
1961 self.create_page_impl(params).await
1962 }
1963
1964 #[tool(
1965 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).",
1966 annotations(
1967 title = "Refresh page",
1968 read_only_hint = false,
1969 destructive_hint = false,
1970 idempotent_hint = false,
1971 open_world_hint = false
1972 )
1973 )]
1974 async fn update_page(
1975 &self,
1976 Parameters(params): Parameters<UpdatePageParams>,
1977 ) -> Result<CallToolResult, McpError> {
1978 self.update_page_impl(params).await
1979 }
1980
1981 #[tool(
1982 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.",
1983 annotations(
1984 title = "Delete page",
1985 read_only_hint = false,
1986 destructive_hint = true,
1987 idempotent_hint = true,
1988 open_world_hint = false
1989 )
1990 )]
1991 async fn delete_page(
1992 &self,
1993 Parameters(params): Parameters<DeletePageParams>,
1994 ) -> Result<CallToolResult, McpError> {
1995 self.delete_page_impl(¶ms.page_id).await
1996 }
1997
1998 #[tool(
1999 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.",
2000 annotations(title = "Get page", read_only_hint = true, open_world_hint = false)
2001 )]
2002 async fn get_page(
2003 &self,
2004 Parameters(params): Parameters<GetPageParams>,
2005 ) -> Result<CallToolResult, McpError> {
2006 self.get_page_impl(¶ms.page_id).await
2007 }
2008
2009 #[tool(
2010 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.",
2011 annotations(
2012 title = "Get page links",
2013 read_only_hint = true,
2014 destructive_hint = false,
2015 idempotent_hint = true,
2016 open_world_hint = false
2017 )
2018 )]
2019 async fn get_page_links(
2020 &self,
2021 Parameters(params): Parameters<GetPageLinksParams>,
2022 ) -> Result<CallToolResult, McpError> {
2023 self.get_page_links_impl(¶ms.page_id).await
2024 }
2025
2026 #[tool(
2027 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.",
2028 annotations(
2029 title = "Get page sources",
2030 read_only_hint = true,
2031 destructive_hint = false,
2032 idempotent_hint = true,
2033 open_world_hint = false
2034 )
2035 )]
2036 async fn get_page_sources(
2037 &self,
2038 Parameters(params): Parameters<GetPageSourcesParams>,
2039 ) -> Result<CallToolResult, McpError> {
2040 self.get_page_sources_impl(¶ms.page_id).await
2041 }
2042
2043 #[tool(
2044 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.",
2045 annotations(
2046 title = "Get memory revisions",
2047 read_only_hint = true,
2048 destructive_hint = false,
2049 idempotent_hint = true,
2050 open_world_hint = false
2051 )
2052 )]
2053 async fn get_memory_revisions(
2054 &self,
2055 Parameters(params): Parameters<GetMemoryRevisionsParams>,
2056 ) -> Result<CallToolResult, McpError> {
2057 self.get_memory_revisions_impl(¶ms.memory_id).await
2058 }
2059
2060 #[tool(
2061 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.",
2062 annotations(
2063 title = "Get page revisions",
2064 read_only_hint = true,
2065 destructive_hint = false,
2066 idempotent_hint = true,
2067 open_world_hint = false
2068 )
2069 )]
2070 async fn get_page_revisions(
2071 &self,
2072 Parameters(params): Parameters<GetPageRevisionsParams>,
2073 ) -> Result<CallToolResult, McpError> {
2074 self.get_page_revisions_impl(¶ms.page_id).await
2075 }
2076
2077 #[tool(
2078 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.",
2079 annotations(
2080 title = "List memories",
2081 read_only_hint = true,
2082 open_world_hint = false
2083 )
2084 )]
2085 async fn list_memories(
2086 &self,
2087 Parameters(params): Parameters<ListMemoriesParams>,
2088 ) -> Result<CallToolResult, McpError> {
2089 self.list_memories_impl(params).await
2090 }
2091
2092 #[tool(
2093 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.",
2094 annotations(title = "Search pages", read_only_hint = true, open_world_hint = false)
2095 )]
2096 async fn search_pages(
2097 &self,
2098 Parameters(params): Parameters<SearchPagesParams>,
2099 ) -> Result<CallToolResult, McpError> {
2100 self.search_pages_impl(params).await
2101 }
2102
2103 #[tool(
2104 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.",
2105 annotations(title = "Recent pages", read_only_hint = true, open_world_hint = false)
2106 )]
2107 async fn list_pages_recent(
2108 &self,
2109 Parameters(params): Parameters<ListPagesRecentParams>,
2110 ) -> Result<CallToolResult, McpError> {
2111 self.list_pages_recent_impl(params).await
2112 }
2113
2114 #[tool(
2115 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.",
2116 annotations(title = "List spaces", read_only_hint = true, open_world_hint = false)
2117 )]
2118 async fn list_spaces(
2119 &self,
2120 Parameters(params): Parameters<ListSpacesParams>,
2121 ) -> Result<CallToolResult, McpError> {
2122 self.list_spaces_impl(params).await
2123 }
2124
2125 #[tool(
2128 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.",
2129 annotations(
2130 title = "List review proposals",
2131 read_only_hint = true,
2132 open_world_hint = false
2133 )
2134 )]
2135 async fn list_refinements(
2136 &self,
2137 Parameters(params): Parameters<ListRefinementsParams>,
2138 ) -> Result<CallToolResult, McpError> {
2139 self.list_refinements_impl(params).await
2140 }
2141
2142 #[tool(
2143 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).",
2144 annotations(
2145 title = "Reject review proposal",
2146 read_only_hint = false,
2147 destructive_hint = false,
2148 idempotent_hint = true,
2149 open_world_hint = false
2150 )
2151 )]
2152 async fn reject_refinement(
2153 &self,
2154 Parameters(params): Parameters<RejectRefinementParams>,
2155 ) -> Result<CallToolResult, McpError> {
2156 self.reject_refinement_impl(params).await
2157 }
2158
2159 #[tool(
2160 description = "Apply a review queue proposal using sensible defaults. \
2161 entity_merge: existing entity wins as canonical. \
2162 relation_conflict: new relation supersedes. \
2163 detect_contradiction: previously-stored memory flagged for revision. \
2164 Returns 422 for suggest_entity (no producer) and dedup_merge (deprecated). \
2165 Not available over remote HTTP MCP transport (local stdio only).",
2166 annotations(
2167 title = "Accept review proposal",
2168 read_only_hint = false,
2169 destructive_hint = false,
2170 idempotent_hint = true,
2171 open_world_hint = false
2172 )
2173 )]
2174 async fn accept_refinement(
2175 &self,
2176 Parameters(params): Parameters<AcceptRefinementParams>,
2177 ) -> Result<CallToolResult, McpError> {
2178 self.accept_refinement_impl(params).await
2179 }
2180
2181 #[tool(
2184 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).",
2185 annotations(
2186 title = "List nurture cards",
2187 read_only_hint = true,
2188 idempotent_hint = true,
2189 open_world_hint = false
2190 )
2191 )]
2192 async fn list_nurture(
2193 &self,
2194 Parameters(params): Parameters<ListNurtureParams>,
2195 ) -> Result<CallToolResult, McpError> {
2196 self.list_nurture_impl(params).await
2197 }
2198
2199 #[tool(
2200 description = "List entity-suggestion proposals from the daemon review queue \
2201 (action='suggest_entity'). Use when the user asks 'what entities \
2202 does the daemon want to create' or wants to triage merge-vs-create \
2203 decisions. Returns id, proposed entity_name, source_ids, confidence. \
2204 Pair with PR2's approve/dismiss verbs once they land.",
2205 annotations(
2206 title = "List entity suggestions",
2207 read_only_hint = true,
2208 idempotent_hint = true,
2209 open_world_hint = false
2210 )
2211 )]
2212 async fn list_entity_suggestions(
2213 &self,
2214 Parameters(params): Parameters<ListEntitySuggestionsParams>,
2215 ) -> Result<CallToolResult, McpError> {
2216 self.list_entity_suggestions_impl(params).await
2217 }
2218
2219 #[tool(
2220 description = "Accept a pending memory revision. Replaces the target memory's content \
2221 with the proposed revision content and removes the revision row from the \
2222 pending list. Returns the consumed revision id. Returns an error if no \
2223 pending revision exists for that target. Not available over remote HTTP MCP transport (local stdio only).",
2224 annotations(
2225 title = "Accept revision",
2226 read_only_hint = false,
2227 destructive_hint = false,
2228 idempotent_hint = false,
2229 open_world_hint = false
2230 )
2231 )]
2232 async fn accept_revision(
2233 &self,
2234 Parameters(req): Parameters<AcceptRevisionRequest>,
2235 ) -> Result<CallToolResult, McpError> {
2236 self.accept_revision_impl(req).await
2237 }
2238
2239 #[tool(
2240 description = "Dismiss a pending memory revision. Deletes the revision row; the original \
2241 memory is unchanged. Returns an error if no pending revision exists for \
2242 that target. Not available over remote HTTP MCP transport (local stdio only).",
2243 annotations(
2244 title = "Dismiss revision",
2245 read_only_hint = false,
2246 destructive_hint = false,
2247 idempotent_hint = false,
2248 open_world_hint = false
2249 )
2250 )]
2251 async fn dismiss_revision(
2252 &self,
2253 Parameters(req): Parameters<DismissRevisionRequest>,
2254 ) -> Result<CallToolResult, McpError> {
2255 self.dismiss_revision_impl(req).await
2256 }
2257
2258 #[tool(
2259 description = "Dismiss all awaiting-review contradiction flags for a memory. Idempotent. \
2260 Returns wrote:true even if no rows matched. Not available over remote HTTP MCP transport (local stdio only).",
2261 annotations(
2262 title = "Dismiss contradiction",
2263 read_only_hint = false,
2264 destructive_hint = false,
2265 idempotent_hint = true,
2266 open_world_hint = false
2267 )
2268 )]
2269 async fn dismiss_contradiction(
2270 &self,
2271 Parameters(req): Parameters<DismissContradictionRequest>,
2272 ) -> Result<CallToolResult, McpError> {
2273 self.dismiss_contradiction_impl(req).await
2274 }
2275
2276 #[tool(
2277 description = "List in-flight chat-history imports awaiting processing or completion. \
2278 Use when the user asks 'what imports are running', 'is my Claude.ai \
2279 export done', or to surface import progress. Returns id, vendor, \
2280 stage, source path, processed/total conversation counts.",
2281 annotations(
2282 title = "List pending imports",
2283 read_only_hint = true,
2284 idempotent_hint = true,
2285 open_world_hint = false
2286 )
2287 )]
2288 async fn list_pending_imports(
2289 &self,
2290 Parameters(params): Parameters<ListPendingImportsParams>,
2291 ) -> Result<CallToolResult, McpError> {
2292 self.list_pending_imports_impl(params).await
2293 }
2294
2295 #[tool(
2296 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').",
2297 annotations(
2298 title = "List rejections",
2299 read_only_hint = true,
2300 idempotent_hint = true,
2301 open_world_hint = false
2302 )
2303 )]
2304 async fn list_rejections(
2305 &self,
2306 Parameters(params): Parameters<ListRejectionsParams>,
2307 ) -> Result<CallToolResult, McpError> {
2308 self.list_rejections_impl(params).await
2309 }
2310
2311 #[tool(
2312 description = "List memories awaiting human accept/dismiss because a newer version \
2313 was proposed (Protected tier supersede). Use when the user asks \
2314 'what revisions are pending', 'show me memories awaiting approval'. \
2315 Each item carries target_source_id (the memory being revised: pass \
2316 THIS to accept_pending_revision in PR2) and revision_content for \
2317 display. Optional `limit` caps results (default 50, max 500).",
2318 annotations(
2319 title = "List pending revisions",
2320 read_only_hint = true,
2321 idempotent_hint = true,
2322 open_world_hint = false
2323 )
2324 )]
2325 async fn list_pending_revisions(
2326 &self,
2327 Parameters(params): Parameters<ListPendingRevisionsParams>,
2328 ) -> Result<CallToolResult, McpError> {
2329 self.list_pending_revisions_impl(params).await
2330 }
2331
2332 #[tool(
2333 description = "List wiki-link labels that appear in page bodies but have no matching \
2334 page title. Use when the user asks 'what links are broken', 'orphan links', \
2335 or wants to find knowledge gaps. Returns label names and reference counts. \
2336 Optional `min_count` filters to labels referenced at least N times \
2337 (default 1, minimum 1).",
2338 annotations(
2339 title = "List orphan links",
2340 read_only_hint = true,
2341 idempotent_hint = true,
2342 open_world_hint = false
2343 )
2344 )]
2345 async fn list_orphan_links(
2346 &self,
2347 Parameters(params): Parameters<ListOrphanLinksParams>,
2348 ) -> Result<CallToolResult, McpError> {
2349 self.list_orphan_links_impl(params).await
2350 }
2351}
2352
2353fn strip_space_from_tool_schema(mut tool: Tool) -> Tool {
2362 let mut schema = (*tool.input_schema).clone();
2363 if let Some(props) = schema.get_mut("properties").and_then(|v| v.as_object_mut()) {
2364 props.remove("space");
2365 }
2366 if let Some(required) = schema.get_mut("required").and_then(|v| v.as_array_mut()) {
2367 required.retain(|v| v.as_str() != Some("space"));
2368 }
2369 tool.input_schema = std::sync::Arc::new(schema);
2370 tool
2371}
2372
2373#[tool_handler]
2376impl ServerHandler for OriginMcpServer {
2377 async fn list_tools(
2378 &self,
2379 _request: Option<PaginatedRequestParams>,
2380 _context: RequestContext<RoleServer>,
2381 ) -> Result<ListToolsResult, McpError> {
2382 let tools = Self::tool_router().list_all();
2383 let tools = if crate::lock_state::is_locked() {
2384 tools
2385 .into_iter()
2386 .map(strip_space_from_tool_schema)
2387 .collect()
2388 } else {
2389 tools
2390 };
2391 Ok(ListToolsResult {
2392 tools,
2393 meta: None,
2394 next_cursor: None,
2395 })
2396 }
2397
2398 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
2399 if let Some(client_info) = context.peer.peer_info() {
2401 let name = &client_info.client_info.name;
2402 if !name.is_empty() {
2403 if let Ok(mut guard) = self.client_name.lock() {
2404 tracing::info!("MCP client identified: {}", name);
2405 *guard = Some(name.clone());
2406 }
2407 }
2408 }
2409 }
2410
2411 fn get_info(&self) -> InitializeResult {
2412 InitializeResult::new(
2413 ServerCapabilities::builder()
2414 .enable_tools()
2415 .build(),
2416 )
2417 .with_server_info(
2418 Implementation::new("origin-mcp", env!("CARGO_PKG_VERSION"))
2419 )
2420 .with_instructions(
2421 "Origin is your personal memory layer — a local knowledge base that persists across sessions and tools.\n\
2422 Think of yourself as a curator, not a logger. Store insights, not conversation artifacts.\n\n\
2423 Origin is cumulative: each memory you store can be recalled, linked, and distilled into knowledge over time. \
2424 It's also shared across all the user's tools: what you write, other agents (Claude Desktop, Claude Code, \
2425 ChatGPT, Cursor, etc.) will read later. Write for any future reader, not just this conversation.\n\n\
2426 FIRST THING EVERY SESSION: Call context to load the user's identity, preferences, goals, and\n\
2427 topic-relevant memories. This is how you know who you're talking to. Use the result to model how the \
2428 user thinks — their preferences, corrections, and past decisions tell you how they want to be helped, \
2429 not just what they already know.\n\n\
2430 STORE PROACTIVELY — don't wait for the user to ask.\n\
2431 - The user states a preference (\"I use X because...\", \"I prefer Y over Z\")\n\
2432 - The user makes a decision (\"going with approach A\", \"switching to B\")\n\
2433 - The user corrects you or prior info (\"actually, it's C, not D\") — store the correction so it sticks\n\
2434 - The user shares a durable fact about themselves, their work, or people/projects/tools they care about — \
2435 anchor it to the entity\n\n\
2436 If the user asks explicitly (\"remember this\", \"save this\", \"don't forget\"), that's a floor — you \
2437 should have already stored it.\n\n\
2438 WHEN NOT TO STORE:\n\
2439 - Conversation filler (\"ok\", \"thanks\", \"let's move on\")\n\
2440 - Things the user can trivially re-derive (file paths, recent git history)\n\
2441 - Anything already stored — recall first if unsure\n\
2442 - Tool output or command results (file contents, git history, build logs) — these are derivable\n\
2443 - General world facts or documentation that aren't personal to this user (e.g., \"Rust has a borrow \
2444 checker\", \"PostgreSQL supports JSONB\") — those are not memory material.\n\
2445 - Your own inferences about the user that they didn't express. Store what they said; infer from that \
2446 when responding.\n\n\
2447 CONTENT QUALITY — this is where you make the biggest difference:\n\
2448 - Specific beats vague: \"prefers Rust for CLI tools because of compile-time safety\" > \"likes Rust\"\n\
2449 - Include the WHY: the backend can classify \"dark mode\" as a preference, but only you know\n\
2450 \"switched to dark mode because of migraines from bright screens\"\n\
2451 - Name the entities: mention people, projects, tools by name — this powers the knowledge graph\n\
2452 - Atomic: one idea per memory — \"prefers TDD\" and \"uses pytest\" should be two memories, not one\n\
2453 - Declarative, not narrative: \"User prefers X because Y\" — not \"User said today they prefer X\". \
2454 Memories outlive the conversation that produced them.\n\n\
2455 MEMORY TYPES — omit and trust the backend.\n\n\
2456 By default, do NOT set memory_type. The backend auto-classifies into identity / preference / \
2457 decision / lesson / gotcha / fact with more context than you have. Agents that over-specify \
2458 types tend to pick wrong.\n\n\
2459 Opt-in specification:\n\
2460 - \"profile\" — you're sure it's about the user (identity / preference)\n\
2461 - \"knowledge\" — you're sure it's about the world (decision / lesson / gotcha / fact)\n\
2462 - Precise type — only if you're confident and the distinction matters.\n\n\
2463 EXCEPTION — decisions carry structured fields (alternatives considered, reversibility, domain) \
2464 that power the Decision Log view. Set memory_type=\"decision\" explicitly ONLY when the user \
2465 articulated alternatives weighed AND the reasoning for the choice. A bare \"I'm switching to Cursor\" \
2466 is just a preference change — omit the type. \"Switching to Cursor over VSCode because of better \
2467 Claude integration, and we can always go back\" — that's a decision.\n\n\
2468 RECALL vs CONTEXT:\n\
2469 - context: broad orientation, session start, topic shifts, \"catch me up\"\n\
2470 - recall: specific lookup (\"what's Alice's role?\", \"database preferences\", \"our auth decision\")\n\n\
2471 The backend handles classification, entity extraction, structured fields, quality scoring,\n\
2472 and dedup — you don't need to replicate that logic. Focus on what only you know:\n\
2473 the conversational context, why something matters, and what the user actually cares about."
2474 )
2475 }
2476}
2477
2478#[cfg(test)]
2479mod tests {
2480 use super::*;
2481 use crate::client::OriginClient;
2482 use crate::types::{
2483 ChatContextRequest, ChatContextResponse, SearchMemoryRequest, SearchResult,
2484 StoreMemoryRequest, StoreMemoryResponse,
2485 };
2486
2487 fn make_server(
2488 transport: TransportMode,
2489 agent_name: &str,
2490 user_id: Option<&str>,
2491 ) -> OriginMcpServer {
2492 let client = OriginClient::new("http://127.0.0.1:19999".into());
2493 OriginMcpServer::new(
2494 client,
2495 transport,
2496 agent_name.into(),
2497 user_id.map(String::from),
2498 )
2499 }
2500
2501 #[test]
2504 fn test_http_mode_prefers_param_over_agent_name() {
2505 let server = make_server(TransportMode::Http, "claude.ai", None);
2506 let result = server.resolve_source_agent(Some("user-provided".into()));
2508 assert_eq!(result, Some("user-provided".into()));
2509 }
2510
2511 #[test]
2512 fn test_http_mode_sets_source_agent_when_none() {
2513 let server = make_server(TransportMode::Http, "chatgpt", None);
2514 let result = server.resolve_source_agent(None);
2515 assert_eq!(result, Some("chatgpt".into()));
2516 }
2517
2518 #[test]
2519 fn test_stdio_mode_passes_through_source_agent() {
2520 let server = make_server(TransportMode::Stdio, "ignored", None);
2521 let result = server.resolve_source_agent(Some("user-provided".into()));
2522 assert_eq!(result, Some("user-provided".into()));
2523 }
2524
2525 #[test]
2526 fn test_stdio_mode_falls_back_to_agent_name() {
2527 let server = make_server(TransportMode::Stdio, "fallback", None);
2528 let result = server.resolve_source_agent(None);
2530 assert_eq!(result, Some("fallback".into()));
2531 }
2532
2533 #[test]
2534 fn test_http_mode_resolves_configured_user_id_for_local_use() {
2535 let server = make_server(TransportMode::Http, "agent", Some("lucian"));
2536 let result = server.resolve_user_id(None);
2537 assert_eq!(result, Some("lucian".into()));
2538 }
2539
2540 #[test]
2541 fn test_transport_mode_equality() {
2542 assert_eq!(TransportMode::Stdio, TransportMode::Stdio);
2543 assert_eq!(TransportMode::Http, TransportMode::Http);
2544 assert_ne!(TransportMode::Stdio, TransportMode::Http);
2545 }
2546
2547 #[test]
2550 fn test_capture_params_minimal() {
2551 let json = r#"{"content": "Lucian prefers dark mode"}"#;
2552 let params: CaptureParams = serde_json::from_str(json).unwrap();
2553 assert_eq!(params.content, "Lucian prefers dark mode");
2554 assert!(params.memory_type.is_none());
2555 assert!(params.space.is_none());
2556 assert!(params.entity.is_none());
2557 assert!(params.confidence.is_none());
2558 assert!(params.supersedes.is_none());
2559 }
2560
2561 #[test]
2562 fn test_capture_params_full() {
2563 let json = r#"{
2564 "content": "We chose PostgreSQL over MongoDB",
2565 "memory_type": "decision",
2566 "space": "origin",
2567 "entity": "PostgreSQL",
2568 "confidence": 0.95,
2569 "supersedes": "mem_abc123"
2570 }"#;
2571 let params: CaptureParams = serde_json::from_str(json).unwrap();
2572 assert_eq!(params.content, "We chose PostgreSQL over MongoDB");
2573 assert_eq!(params.memory_type.as_deref(), Some("decision"));
2574 assert_eq!(params.space.as_deref(), Some("origin"));
2575 assert_eq!(params.entity.as_deref(), Some("PostgreSQL"));
2576 assert_eq!(params.confidence, Some(0.95));
2577 assert_eq!(params.supersedes.as_deref(), Some("mem_abc123"));
2578 }
2579
2580 #[test]
2581 fn test_capture_params_missing_content_fails() {
2582 let json = r#"{"memory_type": "fact"}"#;
2583 let result = serde_json::from_str::<CaptureParams>(json);
2584 assert!(result.is_err());
2585 }
2586
2587 #[test]
2590 fn test_recall_params_minimal() {
2591 let json = r#"{"query": "what does Alice work on?"}"#;
2592 let params: RecallParams = serde_json::from_str(json).unwrap();
2593 assert_eq!(params.query, "what does Alice work on?");
2594 assert!(params.limit.is_none());
2595 assert!(
2596 params.rerank.is_none(),
2597 "rerank omitted must remain None so the daemon receives default false"
2598 );
2599 }
2600
2601 #[test]
2602 fn test_recall_params_full() {
2603 let json = r#"{
2604 "query": "database preferences",
2605 "limit": 5,
2606 "memory_type": "decision",
2607 "space": "origin",
2608 "rerank": true
2609 }"#;
2610 let params: RecallParams = serde_json::from_str(json).unwrap();
2611 assert_eq!(params.query, "database preferences");
2612 assert_eq!(params.limit, Some(5));
2613 assert_eq!(params.memory_type.as_deref(), Some("decision"));
2614 assert_eq!(params.space.as_deref(), Some("origin"));
2615 assert_eq!(params.rerank, Some(true));
2616 }
2617
2618 #[test]
2619 fn test_recall_params_limit_as_string() {
2620 let json = r#"{"query": "test", "limit": "10"}"#;
2621 let params: RecallParams = serde_json::from_str(json).unwrap();
2622 assert_eq!(params.limit, Some(10));
2623 }
2624
2625 #[test]
2626 fn test_recall_params_missing_query_fails() {
2627 let json = r#"{"limit": 5}"#;
2628 let result = serde_json::from_str::<RecallParams>(json);
2629 assert!(result.is_err());
2630 }
2631
2632 #[test]
2635 fn test_context_params_empty() {
2636 let json = r#"{}"#;
2637 let params: ContextParams = serde_json::from_str(json).unwrap();
2638 assert!(params.topic.is_none());
2639 assert!(params.limit.is_none());
2640 assert!(params.space.is_none());
2641 }
2642
2643 #[test]
2644 fn test_context_params_full() {
2645 let json = r#"{"topic": "project Origin architecture", "limit": 30, "space": "work"}"#;
2646 let params: ContextParams = serde_json::from_str(json).unwrap();
2647 assert_eq!(params.topic.as_deref(), Some("project Origin architecture"));
2648 assert_eq!(params.limit, Some(30));
2649 assert_eq!(params.space.as_deref(), Some("work"));
2650 }
2651
2652 #[test]
2653 fn test_context_params_limit_as_string() {
2654 let json = r#"{"limit": "20"}"#;
2655 let params: ContextParams = serde_json::from_str(json).unwrap();
2656 assert_eq!(params.limit, Some(20));
2657 }
2658
2659 #[test]
2660 fn legacy_domain_alias_still_deserializes() {
2661 let json = r#"{"topic": "project work", "domain": "work"}"#;
2664 let params: ContextParams =
2665 serde_json::from_str(json).expect("legacy 'domain' key must deserialize");
2666 assert_eq!(
2667 params.space.as_deref(),
2668 Some("work"),
2669 "alias must map domain → space"
2670 );
2671 }
2672
2673 #[test]
2674 fn store_memory_request_serialization_excludes_user_id() {
2675 let req = StoreMemoryRequest {
2676 content: "test content".into(),
2677 memory_type: None,
2678 space: None,
2679 source_agent: Some("test-agent".into()),
2680 title: None,
2681 confidence: None,
2682 supersedes: None,
2683 entity: None,
2684 entity_id: None,
2685 structured_fields: None,
2686 retrieval_cue: None,
2687 };
2688 let json = serde_json::to_value(&req).unwrap();
2689 let obj = json.as_object().unwrap();
2690 assert!(
2691 !obj.contains_key("user_id"),
2692 "user_id must not be on the wire; got: {:?}",
2693 obj.keys().collect::<Vec<_>>()
2694 );
2695 }
2696
2697 #[test]
2698 fn capture_success_message_is_terse() {
2699 let resp = StoreMemoryResponse {
2700 source_id: "mem_abc".into(),
2701 chunks_created: 3,
2702 memory_type: "fact".into(),
2703 entity_id: Some("ent_xyz".into()),
2704 quality: Some("high".into()),
2705 warnings: vec![],
2706 extraction_method: "llm".into(),
2707 enrichment: String::new(),
2708 hint: String::new(),
2709 triggered_revisions: vec![],
2710 auto_superseded: vec![],
2711 };
2712 let msg = format_capture_success(&resp);
2713 assert_eq!(msg, "Stored mem_abc");
2714 assert!(!msg.contains("chunks"));
2715 assert!(!msg.contains("quality"));
2716 assert!(!msg.contains("entity"));
2717 }
2718
2719 #[test]
2720 fn capture_success_message_surfaces_warnings() {
2721 let resp = StoreMemoryResponse {
2722 source_id: "mem_abc".into(),
2723 chunks_created: 1,
2724 memory_type: "decision".into(),
2725 entity_id: None,
2726 quality: None,
2727 warnings: vec!["decision memory missing required 'claim' field".into()],
2728 extraction_method: "agent".into(),
2729 enrichment: String::new(),
2730 hint: String::new(),
2731 triggered_revisions: vec![],
2732 auto_superseded: vec![],
2733 };
2734 let msg = format_capture_success(&resp);
2735 assert!(msg.starts_with("Stored mem_abc"));
2736 assert!(msg.contains("Warnings:"));
2737 assert!(msg.contains("decision memory missing required 'claim' field"));
2738 }
2739
2740 #[test]
2741 fn format_capture_success_surfaces_triggered_revisions() {
2742 let resp = StoreMemoryResponse {
2743 source_id: "mem_new".into(),
2744 chunks_created: 1,
2745 memory_type: "fact".into(),
2746 entity_id: None,
2747 quality: None,
2748 warnings: vec![],
2749 extraction_method: "agent".into(),
2750 enrichment: String::new(),
2751 hint: String::new(),
2752 triggered_revisions: vec!["mem_protected_target".to_string()],
2753 auto_superseded: vec![],
2754 };
2755 let out = format_capture_success(&resp);
2756 assert!(out.contains("Triggered revisions"));
2757 assert!(out.contains("mem_protected_target"));
2758 assert!(out.contains("accept_revision"));
2759 assert!(out.contains("dismiss_revision"));
2760 }
2761
2762 #[test]
2763 fn format_capture_success_omits_section_when_empty() {
2764 let resp = StoreMemoryResponse {
2765 source_id: "mem_new".into(),
2766 chunks_created: 1,
2767 memory_type: "fact".into(),
2768 entity_id: None,
2769 quality: None,
2770 warnings: vec![],
2771 extraction_method: "agent".into(),
2772 enrichment: String::new(),
2773 hint: String::new(),
2774 triggered_revisions: vec![],
2775 auto_superseded: vec![],
2776 };
2777 let out = format_capture_success(&resp);
2778 assert!(!out.contains("Triggered revisions"));
2779 }
2780
2781 #[test]
2782 fn format_capture_success_surfaces_auto_superseded() {
2783 let resp = StoreMemoryResponse {
2784 source_id: "mem_new".into(),
2785 chunks_created: 1,
2786 memory_type: "fact".into(),
2787 entity_id: None,
2788 quality: None,
2789 warnings: vec![],
2790 extraction_method: "agent".into(),
2791 enrichment: String::new(),
2792 hint: String::new(),
2793 triggered_revisions: vec![],
2794 auto_superseded: vec!["mem_old_xyz".to_string()],
2795 };
2796 let out = format_capture_success(&resp);
2797 assert!(out.contains("Auto-superseded"));
2798 assert!(out.contains("mem_old_xyz"));
2799 assert!(out.contains("no action needed"));
2800 }
2801
2802 #[test]
2803 fn format_capture_success_omits_auto_superseded_when_empty() {
2804 let resp = StoreMemoryResponse {
2805 source_id: "mem_new".into(),
2806 chunks_created: 1,
2807 memory_type: "fact".into(),
2808 entity_id: None,
2809 quality: None,
2810 warnings: vec![],
2811 extraction_method: "agent".into(),
2812 enrichment: String::new(),
2813 hint: String::new(),
2814 triggered_revisions: vec![],
2815 auto_superseded: vec![],
2816 };
2817 let out = format_capture_success(&resp);
2818 assert!(!out.contains("Auto-superseded"));
2819 }
2820
2821 #[test]
2822 fn doctor_local_memory_message_sets_expectations() {
2823 let msg = format_doctor_message(&serde_json::json!({
2824 "setup_completed": true,
2825 "mode": "basic-memory",
2826 "anthropic_key_configured": false,
2827 "local_model_selected": null,
2828 "local_model_loaded": null,
2829 "local_model_cached": false
2830 }));
2831
2832 assert!(msg.contains("Mode: Local Memory"));
2833 assert!(msg.contains("On-device model: not selected"));
2834 assert!(msg.contains("Distill cycles: off"));
2835 assert!(msg.contains("Local memory works now: capture, recall, and context are available"));
2836 assert!(msg.contains("origin model install"));
2837 assert!(msg.contains("origin key set anthropic"));
2838 }
2839
2840 #[test]
2841 fn doctor_on_device_model_message_shows_loaded_model() {
2842 let msg = format_doctor_message(&serde_json::json!({
2843 "setup_completed": true,
2844 "mode": "local-model",
2845 "anthropic_key_configured": false,
2846 "local_model_selected": "qwen3-1.7b",
2847 "local_model_loaded": "qwen3-1.7b",
2848 "local_model_cached": true
2849 }));
2850
2851 assert!(msg.contains("Mode: On-device Model"), "{msg}");
2852 assert!(
2853 msg.contains("On-device model: qwen3-1.7b (downloaded, loaded)"),
2854 "{msg}"
2855 );
2856 assert!(msg.contains("Distill cycles: enabled"), "{msg}");
2857 assert!(!msg.contains("Local memory works now"));
2858 }
2859
2860 #[test]
2861 fn doctor_unconfigured_message_names_three_setup_paths() {
2862 let msg = format_doctor_message(&serde_json::json!({
2863 "setup_completed": false,
2864 "mode": "unknown",
2865 "anthropic_key_configured": false,
2866 "local_model_selected": null,
2867 "local_model_loaded": null,
2868 "local_model_cached": false
2869 }));
2870
2871 assert!(msg.contains("Setup: not completed"));
2872 assert!(msg.contains("Run `origin setup`"));
2873 assert!(msg.contains("Local Memory, On-device Model, or Anthropic Key"));
2874 }
2875
2876 #[test]
2877 fn search_memory_request_serialization_excludes_entity() {
2878 let req = SearchMemoryRequest {
2879 query: "test".into(),
2880 limit: 10,
2881 memory_type: None,
2882 space: None,
2883 source_agent: None,
2884 rerank: false,
2885 };
2886 let json = serde_json::to_value(&req).unwrap();
2887 let obj = json.as_object().unwrap();
2888 assert!(
2889 !obj.contains_key("entity"),
2890 "entity must not be on the wire; got keys: {:?}",
2891 obj.keys().collect::<Vec<_>>()
2892 );
2893 }
2894
2895 #[test]
2896 fn chat_context_request_serialization_includes_domain() {
2897 #[allow(deprecated)]
2898 let req = ChatContextRequest {
2899 query: None,
2900 conversation_id: Some("topic".into()),
2901 max_chunks: 20,
2902 relevance_threshold: None,
2903 include_goals: true,
2904 space: Some("work".into()),
2905 };
2906 let json = serde_json::to_value(&req).unwrap();
2907 assert_eq!(json["space"], serde_json::json!("work"));
2908 assert_eq!(json["conversation_id"], serde_json::json!("topic"));
2909 }
2910
2911 #[test]
2912 fn chat_context_response_deserializes_with_profile_and_knowledge() {
2913 let json = r#"{
2914 "context": "user is Lucian, prefers Rust",
2915 "profile": {
2916 "narrative": "n",
2917 "identity": ["rust"],
2918 "preferences": [],
2919 "goals": []
2920 },
2921 "knowledge": {
2922 "pages": [],
2923 "decisions": [],
2924 "relevant_memories": [],
2925 "graph_context": []
2926 },
2927 "took_ms": 42.0,
2928 "token_estimates": {
2929 "tier1_identity": 10,
2930 "tier2_project": 20,
2931 "tier3_relevant": 30,
2932 "total": 60
2933 }
2934 }"#;
2935 let parsed: ChatContextResponse = serde_json::from_str(json).unwrap();
2936 assert_eq!(parsed.context, "user is Lucian, prefers Rust");
2937 assert_eq!(parsed.profile.identity, vec!["rust"]);
2938 assert_eq!(parsed.token_estimates.total, 60);
2939 }
2940
2941 #[test]
2942 fn capture_params_structured_fields_schema_is_object() {
2943 use schemars::schema_for;
2944
2945 let schema = schema_for!(CaptureParams);
2946 let json = serde_json::to_value(&schema).unwrap();
2947 let sf_schema = json
2948 .pointer("/properties/structured_fields")
2949 .expect("structured_fields property in schema");
2950 let type_val = sf_schema
2951 .pointer("/type")
2952 .unwrap_or(&serde_json::Value::Null);
2953 let type_str = match type_val {
2954 serde_json::Value::String(s) => s.clone(),
2955 serde_json::Value::Array(arr) => arr
2956 .iter()
2957 .filter_map(|v| v.as_str())
2958 .collect::<Vec<_>>()
2959 .join(","),
2960 other => panic!(
2961 "structured_fields schema lacks type constraint; got: {:?}",
2962 other
2963 ),
2964 };
2965 assert!(
2966 type_str.contains("object"),
2967 "expected object type, got: {}",
2968 type_str
2969 );
2970 }
2971
2972 #[test]
2975 fn test_forget_params() {
2976 let json = r#"{"memory_id": "mem_abc123"}"#;
2977 let params: ForgetParams = serde_json::from_str(json).unwrap();
2978 assert_eq!(params.memory_id, "mem_abc123");
2979 }
2980
2981 #[test]
2982 fn test_forget_params_missing_id_fails() {
2983 let json = r#"{}"#;
2984 let result = serde_json::from_str::<ForgetParams>(json);
2985 assert!(result.is_err());
2986 }
2987
2988 #[test]
2991 fn test_store_request_includes_new_fields() {
2992 let req = StoreMemoryRequest {
2993 content: "test".into(),
2994 memory_type: Some("decision".into()),
2995 space: None,
2996 source_agent: Some("claude".into()),
2997 title: None,
2998 confidence: Some(0.9),
2999 supersedes: Some("old_id".into()),
3000 entity: Some("PostgreSQL".into()),
3001 entity_id: None,
3002 structured_fields: None,
3003 retrieval_cue: None,
3004 };
3005 let json = serde_json::to_value(&req).unwrap();
3006 assert_eq!(json["entity"], "PostgreSQL");
3007 assert_eq!(json["supersedes"], "old_id");
3008 assert!(json["confidence"].as_f64().unwrap() > 0.89);
3009 assert_eq!(json["source_agent"], "claude");
3010 assert!(json.get("user_id").is_none());
3011 }
3012
3013 #[test]
3014 fn test_store_request_minimal() {
3015 let req = StoreMemoryRequest {
3016 content: "hello".into(),
3017 memory_type: Some("fact".into()),
3018 space: None,
3019 source_agent: None,
3020 title: None,
3021 confidence: None,
3022 supersedes: None,
3023 entity: None,
3024 entity_id: None,
3025 structured_fields: None,
3026 retrieval_cue: None,
3027 };
3028 let json = serde_json::to_value(&req).unwrap();
3029 assert_eq!(json["content"], "hello");
3030 assert_eq!(json["memory_type"], "fact");
3031 assert!(json.get("user_id").is_none());
3032 }
3033
3034 #[test]
3037 fn test_store_response_with_new_fields() {
3038 let json = r#"{
3039 "source_id": "mem_xyz",
3040 "chunks_created": 2,
3041 "memory_type": "fact",
3042 "entity_id": "ent_abc",
3043 "quality": "high",
3044 "warnings": ["decision memory missing claim"],
3045 "extraction_method": "agent"
3046 }"#;
3047 let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
3048 assert_eq!(resp.source_id, "mem_xyz");
3049 assert_eq!(resp.chunks_created, 2);
3050 assert_eq!(resp.memory_type, "fact");
3051 assert_eq!(resp.entity_id.as_deref(), Some("ent_abc"));
3052 assert_eq!(resp.quality.as_deref(), Some("high"));
3053 assert_eq!(resp.warnings, vec!["decision memory missing claim"]);
3054 assert_eq!(resp.extraction_method, "agent");
3055 }
3056
3057 #[test]
3058 fn test_store_response_backward_compat_no_new_fields() {
3059 let json = r#"{
3061 "source_id": "mem_old",
3062 "chunks_created": 1,
3063 "memory_type": "fact"
3064 }"#;
3065 let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
3066 assert_eq!(resp.source_id, "mem_old");
3067 assert_eq!(resp.chunks_created, 1);
3068 assert_eq!(resp.memory_type, "fact");
3069 assert!(resp.entity_id.is_none());
3070 assert!(resp.quality.is_none());
3071 assert!(resp.warnings.is_empty());
3072 assert_eq!(resp.extraction_method, "unknown");
3073 }
3074
3075 #[test]
3076 fn test_store_response_with_warnings_and_extraction_method() {
3077 let json = r#"{
3078 "source_id": "mem_xyz",
3079 "chunks_created": 1,
3080 "memory_type": "decision",
3081 "warnings": ["decision memory missing required 'claim' field"],
3082 "extraction_method": "llm"
3083 }"#;
3084 let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
3085 assert_eq!(resp.memory_type, "decision");
3086 assert_eq!(
3087 resp.warnings,
3088 vec!["decision memory missing required 'claim' field"]
3089 );
3090 assert_eq!(resp.extraction_method, "llm");
3091 }
3092
3093 #[test]
3096 fn test_search_result_with_new_fields() {
3097 let json = r#"{
3098 "id": "1",
3099 "content": "We chose Postgres",
3100 "source": "memory",
3101 "source_id": "mem_1",
3102 "title": "DB decision",
3103 "url": null,
3104 "chunk_index": 0,
3105 "last_modified": 1711000000,
3106 "score": 0.95,
3107 "chunk_type": "memory",
3108 "language": "en",
3109 "semantic_unit": "sentence",
3110 "memory_type": "decision",
3111 "space": "origin",
3112 "source_agent": "claude",
3113 "confidence": 0.9,
3114 "confirmed": true,
3115 "stability": "standard",
3116 "supersedes": "mem_0",
3117 "summary": "DB choice",
3118 "entity_id": "ent_pg",
3119 "entity_name": "PostgreSQL",
3120 "quality": "high",
3121 "is_archived": false,
3122 "is_recap": false,
3123 "source_text": "We chose Postgres",
3124 "raw_score": 0.42
3125 }"#;
3126 let result: SearchResult = serde_json::from_str(json).unwrap();
3127 assert_eq!(result.chunk_type.as_deref(), Some("memory"));
3128 assert_eq!(result.language.as_deref(), Some("en"));
3129 assert_eq!(result.semantic_unit.as_deref(), Some("sentence"));
3130 assert_eq!(result.stability.as_deref(), Some("standard"));
3131 assert_eq!(result.supersedes.as_deref(), Some("mem_0"));
3132 assert_eq!(result.summary.as_deref(), Some("DB choice"));
3133 assert_eq!(result.entity_id.as_deref(), Some("ent_pg"));
3134 assert_eq!(result.entity_name.as_deref(), Some("PostgreSQL"));
3135 assert_eq!(result.quality.as_deref(), Some("high"));
3136 assert!(!result.is_archived);
3137 assert!(!result.is_recap);
3138 assert_eq!(result.source_text.as_deref(), Some("We chose Postgres"));
3139 assert!((result.raw_score - 0.42).abs() < f32::EPSILON);
3140 }
3141
3142 #[test]
3143 fn test_search_result_backward_compat_no_new_fields() {
3144 let json = r#"{
3146 "id": "1",
3147 "content": "test",
3148 "source": "memory",
3149 "source_id": "mem_1",
3150 "title": "test",
3151 "url": null,
3152 "chunk_index": 0,
3153 "last_modified": 1711000000,
3154 "score": 0.8,
3155 "memory_type": "fact",
3156 "space": null,
3157 "source_agent": null,
3158 "confidence": null,
3159 "confirmed": null
3160 }"#;
3161 let result: SearchResult = serde_json::from_str(json).unwrap();
3162 assert!(result.entity_id.is_none());
3163 assert!(result.entity_name.is_none());
3164 assert!(result.quality.is_none());
3165 assert!(!result.is_archived);
3166 assert!(!result.is_recap);
3167 assert!(result.structured_fields.is_none());
3168 assert!(result.retrieval_cue.is_none());
3169 assert_eq!(result.raw_score, 0.0);
3170 }
3171
3172 #[test]
3173 fn test_search_result_with_structured_fields_and_retrieval_cue() {
3174 let json = r#"{
3175 "id": "1",
3176 "content": "Lucian prefers dark mode",
3177 "source": "memory",
3178 "source_id": "mem_1",
3179 "title": "Dark mode preference",
3180 "url": null,
3181 "chunk_index": 0,
3182 "last_modified": 1711000000,
3183 "score": 0.92,
3184 "memory_type": "preference",
3185 "space": null,
3186 "source_agent": null,
3187 "confidence": null,
3188 "confirmed": null,
3189 "structured_fields": "{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}",
3190 "retrieval_cue": "What UI theme does Lucian prefer?"
3191 }"#;
3192 let result: SearchResult = serde_json::from_str(json).unwrap();
3193 assert_eq!(
3194 result.structured_fields.as_deref(),
3195 Some("{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}")
3196 );
3197 assert_eq!(
3198 result.retrieval_cue.as_deref(),
3199 Some("What UI theme does Lucian prefer?")
3200 );
3201 assert!(!result.is_archived);
3202 assert!(!result.is_recap);
3203 assert_eq!(result.raw_score, 0.0);
3204 }
3205
3206 #[test]
3207 fn test_search_result_knowledge_graph_source() {
3208 let json = r#"{
3210 "id": "obs_1",
3211 "content": "Prefers Rust over Go",
3212 "source": "knowledge_graph",
3213 "source_id": "ent_lucian",
3214 "title": "Lucian",
3215 "url": null,
3216 "chunk_index": 0,
3217 "last_modified": 1711000000,
3218 "score": 1.14,
3219 "memory_type": null,
3220 "space": null,
3221 "source_agent": null,
3222 "confidence": null,
3223 "confirmed": null,
3224 "entity_id": "ent_lucian",
3225 "entity_name": "Lucian"
3226 }"#;
3227 let result: SearchResult = serde_json::from_str(json).unwrap();
3228 assert_eq!(result.source, "knowledge_graph");
3229 assert_eq!(result.entity_id.as_deref(), Some("ent_lucian"));
3230 assert_eq!(result.entity_name.as_deref(), Some("Lucian"));
3231 assert!(!result.is_archived);
3232 assert!(!result.is_recap);
3233 assert_eq!(result.raw_score, 0.0);
3234 }
3235
3236 #[tokio::test]
3239 async fn test_forget_blocked_on_http_transport() {
3240 let server = make_server(TransportMode::Http, "agent", None);
3241 let result = server.forget_impl("mem_123").await.unwrap();
3242 let content = &result.content[0];
3244 match content.raw {
3245 rmcp::model::RawContent::Text(ref tc) => {
3246 assert!(tc.text.contains("not available over remote connections"));
3247 }
3248 _ => panic!("expected text content"),
3249 }
3250 }
3251
3252 #[tokio::test]
3253 async fn test_forget_allowed_on_stdio_transport() {
3254 let server = make_server(TransportMode::Stdio, "agent", None);
3259 let result = server.forget_impl("mem_123").await.unwrap();
3260 assert!(
3261 result.is_error.unwrap_or(false),
3262 "should fail with connection error, not transport block"
3263 );
3264 }
3265
3266 #[tokio::test]
3269 async fn test_accept_revision_blocked_on_http_transport() {
3270 let server = make_server(TransportMode::Http, "agent", None);
3271 let req = AcceptRevisionRequest {
3272 target_source_id: "mem_x".into(),
3273 };
3274 let result = server.accept_revision_impl(req).await.unwrap();
3275 let content = &result.content[0];
3276 match content.raw {
3277 rmcp::model::RawContent::Text(ref tc) => {
3278 assert!(tc.text.contains("not available over remote connections"));
3279 }
3280 _ => panic!("expected text content"),
3281 }
3282 }
3283
3284 #[tokio::test]
3285 async fn test_accept_revision_allowed_on_stdio_transport() {
3286 let server = make_server(TransportMode::Stdio, "agent", None);
3287 let req = AcceptRevisionRequest {
3288 target_source_id: "mem_x".into(),
3289 };
3290 let result = server.accept_revision_impl(req).await.unwrap();
3291 assert!(
3292 result.is_error.unwrap_or(false),
3293 "should fail with connection error, not transport block"
3294 );
3295 }
3296
3297 #[tokio::test]
3298 async fn test_dismiss_revision_blocked_on_http_transport() {
3299 let server = make_server(TransportMode::Http, "agent", None);
3300 let req = DismissRevisionRequest {
3301 target_source_id: "mem_x".into(),
3302 };
3303 let result = server.dismiss_revision_impl(req).await.unwrap();
3304 let content = &result.content[0];
3305 match content.raw {
3306 rmcp::model::RawContent::Text(ref tc) => {
3307 assert!(tc.text.contains("not available over remote connections"));
3308 }
3309 _ => panic!("expected text content"),
3310 }
3311 }
3312
3313 #[tokio::test]
3314 async fn test_dismiss_revision_allowed_on_stdio_transport() {
3315 let server = make_server(TransportMode::Stdio, "agent", None);
3316 let req = DismissRevisionRequest {
3317 target_source_id: "mem_x".into(),
3318 };
3319 let result = server.dismiss_revision_impl(req).await.unwrap();
3320 assert!(
3321 result.is_error.unwrap_or(false),
3322 "should fail with connection error, not transport block"
3323 );
3324 }
3325
3326 #[tokio::test]
3327 async fn test_dismiss_contradiction_blocked_on_http_transport() {
3328 let server = make_server(TransportMode::Http, "agent", None);
3329 let req = DismissContradictionRequest {
3330 source_id: "mem_x".into(),
3331 };
3332 let result = server.dismiss_contradiction_impl(req).await.unwrap();
3333 let content = &result.content[0];
3334 match content.raw {
3335 rmcp::model::RawContent::Text(ref tc) => {
3336 assert!(tc.text.contains("not available over remote connections"));
3337 }
3338 _ => panic!("expected text content"),
3339 }
3340 }
3341
3342 #[tokio::test]
3343 async fn test_dismiss_contradiction_allowed_on_stdio_transport() {
3344 let server = make_server(TransportMode::Stdio, "agent", None);
3345 let req = DismissContradictionRequest {
3346 source_id: "mem_x".into(),
3347 };
3348 let result = server.dismiss_contradiction_impl(req).await.unwrap();
3349 assert!(
3350 result.is_error.unwrap_or(false),
3351 "should fail with connection error, not transport block"
3352 );
3353 }
3354
3355 #[tokio::test]
3356 async fn test_confirm_entity_blocked_on_http_transport() {
3357 let server = make_server(TransportMode::Http, "agent", None);
3358 let params = ConfirmEntityParams {
3359 entity_id: "ent_x".into(),
3360 confirmed: true,
3361 };
3362 let result = server.confirm_entity_impl(params).await.unwrap();
3363 let content = &result.content[0];
3364 match content.raw {
3365 rmcp::model::RawContent::Text(ref tc) => {
3366 assert!(tc.text.contains("not available over remote connections"));
3367 }
3368 _ => panic!("expected text content"),
3369 }
3370 }
3371
3372 #[tokio::test]
3373 async fn test_confirm_entity_allowed_on_stdio_transport() {
3374 let server = make_server(TransportMode::Stdio, "agent", None);
3375 let params = ConfirmEntityParams {
3376 entity_id: "ent_x".into(),
3377 confirmed: true,
3378 };
3379 let result = server.confirm_entity_impl(params).await.unwrap();
3380 assert!(
3381 result.is_error.unwrap_or(false),
3382 "should fail with connection error, not transport block"
3383 );
3384 }
3385
3386 #[tokio::test]
3387 async fn test_confirm_observation_blocked_on_http_transport() {
3388 let server = make_server(TransportMode::Http, "agent", None);
3389 let params = ConfirmObservationParams {
3390 observation_id: "obs_x".into(),
3391 confirmed: true,
3392 };
3393 let result = server.confirm_observation_impl(params).await.unwrap();
3394 let content = &result.content[0];
3395 match content.raw {
3396 rmcp::model::RawContent::Text(ref tc) => {
3397 assert!(tc.text.contains("not available over remote connections"));
3398 }
3399 _ => panic!("expected text content"),
3400 }
3401 }
3402
3403 #[tokio::test]
3404 async fn test_confirm_observation_allowed_on_stdio_transport() {
3405 let server = make_server(TransportMode::Stdio, "agent", None);
3406 let params = ConfirmObservationParams {
3407 observation_id: "obs_x".into(),
3408 confirmed: true,
3409 };
3410 let result = server.confirm_observation_impl(params).await.unwrap();
3411 assert!(
3412 result.is_error.unwrap_or(false),
3413 "should fail with connection error, not transport block"
3414 );
3415 }
3416
3417 #[tokio::test]
3418 async fn test_update_observation_blocked_on_http_transport() {
3419 let server = make_server(TransportMode::Http, "agent", None);
3420 let params = UpdateObservationParams {
3421 observation_id: "obs_x".into(),
3422 content: "new content".into(),
3423 };
3424 let result = server.update_observation_impl(params).await.unwrap();
3425 let content = &result.content[0];
3426 match content.raw {
3427 rmcp::model::RawContent::Text(ref tc) => {
3428 assert!(tc.text.contains("not available over remote connections"));
3429 }
3430 _ => panic!("expected text content"),
3431 }
3432 }
3433
3434 #[tokio::test]
3435 async fn test_update_observation_allowed_on_stdio_transport() {
3436 let server = make_server(TransportMode::Stdio, "agent", None);
3437 let params = UpdateObservationParams {
3438 observation_id: "obs_x".into(),
3439 content: "new content".into(),
3440 };
3441 let result = server.update_observation_impl(params).await.unwrap();
3442 assert!(
3443 result.is_error.unwrap_or(false),
3444 "should fail with connection error, not transport block"
3445 );
3446 }
3447
3448 #[tokio::test]
3449 async fn test_update_page_blocked_on_http_transport() {
3450 let server = make_server(TransportMode::Http, "agent", None);
3451 let params = UpdatePageParams {
3452 page_id: "page_x".into(),
3453 content: "body".into(),
3454 source_memory_ids: vec!["mem_a".into()],
3455 summary: None,
3456 };
3457 let result = server.update_page_impl(params).await.unwrap();
3458 let content = &result.content[0];
3459 match content.raw {
3460 rmcp::model::RawContent::Text(ref tc) => {
3461 assert!(tc.text.contains("not available over remote connections"));
3462 }
3463 _ => panic!("expected text content"),
3464 }
3465 }
3466
3467 #[tokio::test]
3468 async fn test_update_page_allowed_on_stdio_transport() {
3469 let server = make_server(TransportMode::Stdio, "agent", None);
3470 let params = UpdatePageParams {
3471 page_id: "page_x".into(),
3472 content: "body".into(),
3473 source_memory_ids: vec!["mem_a".into()],
3474 summary: None,
3475 };
3476 let result = server.update_page_impl(params).await.unwrap();
3477 assert!(
3478 result.is_error.unwrap_or(false),
3479 "should fail with connection error, not transport block"
3480 );
3481 }
3482
3483 #[tokio::test]
3486 async fn test_reject_refinement_blocked_on_http_transport() {
3487 let server = make_server(TransportMode::Http, "agent", None);
3488 let params = RejectRefinementParams {
3489 id: "merge_abc_def".into(),
3490 };
3491 let result = server.reject_refinement_impl(params).await.unwrap();
3492 let content = &result.content[0];
3493 match content.raw {
3494 rmcp::model::RawContent::Text(ref tc) => {
3495 assert!(tc.text.contains("not available over remote connections"));
3496 }
3497 _ => panic!("expected text content"),
3498 }
3499 }
3500
3501 #[tokio::test]
3502 async fn test_reject_refinement_allowed_on_stdio_transport() {
3503 let server = make_server(TransportMode::Stdio, "agent", None);
3504 let params = RejectRefinementParams {
3505 id: "merge_abc_def".into(),
3506 };
3507 let result = server.reject_refinement_impl(params).await.unwrap();
3508 assert!(
3509 result.is_error.unwrap_or(false),
3510 "should fail with connection error, not transport block"
3511 );
3512 }
3513
3514 #[tokio::test]
3515 async fn test_accept_refinement_blocked_on_http_transport() {
3516 let server = make_server(TransportMode::Http, "agent", None);
3517 let params = AcceptRefinementParams {
3518 id: "merge_abc_def".into(),
3519 };
3520 let result = server.accept_refinement_impl(params).await.unwrap();
3521 let content = &result.content[0];
3522 match content.raw {
3523 rmcp::model::RawContent::Text(ref tc) => {
3524 assert!(tc.text.contains("not available over remote connections"));
3525 }
3526 _ => panic!("expected text content"),
3527 }
3528 }
3529
3530 #[tokio::test]
3531 async fn test_accept_refinement_allowed_on_stdio_transport() {
3532 let server = make_server(TransportMode::Stdio, "agent", None);
3533 let params = AcceptRefinementParams {
3534 id: "merge_abc_def".into(),
3535 };
3536 let result = server.accept_refinement_impl(params).await.unwrap();
3537 assert!(
3538 result.is_error.unwrap_or(false),
3539 "should fail with connection error, not transport block"
3540 );
3541 }
3542
3543 #[test]
3546 fn test_context_request_default_limit() {
3547 let params = ContextParams {
3548 topic: Some("test".into()),
3549 limit: None,
3550 space: None,
3551 };
3552 #[allow(deprecated)]
3553 let req = ChatContextRequest {
3554 query: None,
3555 conversation_id: params.topic,
3556 max_chunks: params.limit.unwrap_or(20),
3557 relevance_threshold: None,
3558 include_goals: true,
3559 space: params.space,
3560 };
3561 assert_eq!(req.max_chunks, 20);
3562 }
3563
3564 #[test]
3565 fn test_context_request_custom_limit() {
3566 let params = ContextParams {
3567 topic: None,
3568 limit: Some(5),
3569 space: Some("work".into()),
3570 };
3571 #[allow(deprecated)]
3572 let req = ChatContextRequest {
3573 query: None,
3574 conversation_id: params.topic,
3575 max_chunks: params.limit.unwrap_or(20),
3576 relevance_threshold: None,
3577 include_goals: true,
3578 space: params.space,
3579 };
3580 assert_eq!(req.max_chunks, 5);
3581 assert_eq!(req.space.as_deref(), Some("work"));
3582 }
3583
3584 #[test]
3585 fn test_context_maps_topic_to_conversation_id() {
3586 let params = ContextParams {
3587 topic: Some("project Origin".into()),
3588 limit: None,
3589 space: None,
3590 };
3591 #[allow(deprecated)]
3592 let req = ChatContextRequest {
3593 query: None,
3594 conversation_id: params.topic.clone(),
3595 max_chunks: params.limit.unwrap_or(20),
3596 relevance_threshold: None,
3597 include_goals: true,
3598 space: params.space,
3599 };
3600 assert_eq!(req.conversation_id.as_deref(), Some("project Origin"));
3601 }
3602
3603 #[test]
3606 fn test_capture_constructs_store_request_with_entity() {
3607 let server = make_server(TransportMode::Stdio, "claude", None);
3608 let params = CaptureParams {
3609 content: "Alice manages the frontend team".into(),
3610 memory_type: Some("fact".into()),
3611 space: Some("work".into()),
3612 entity: Some("Alice".into()),
3613 confidence: Some(0.9),
3614 supersedes: None,
3615 structured_fields: None,
3616 retrieval_cue: None,
3617 };
3618
3619 let source_agent = server.resolve_source_agent(None);
3621
3622 let req = StoreMemoryRequest {
3623 content: params.content,
3624 memory_type: params.memory_type,
3625 space: params.space,
3626 source_agent,
3627 title: None,
3628 confidence: params.confidence,
3629 supersedes: params.supersedes,
3630 entity: params.entity,
3631 entity_id: None,
3632 structured_fields: params.structured_fields.map(serde_json::Value::Object),
3633 retrieval_cue: params.retrieval_cue,
3634 };
3635
3636 let json = serde_json::to_value(&req).unwrap();
3637 assert_eq!(json["content"], "Alice manages the frontend team");
3638 assert_eq!(json["memory_type"], "fact");
3639 assert_eq!(json["space"], "work");
3640 assert_eq!(json["entity"], "Alice");
3641 assert!(json["confidence"].as_f64().unwrap() > 0.89);
3642 assert_eq!(json["source_agent"], "claude");
3644 }
3645
3646 #[test]
3647 fn test_remember_http_mode_injects_agent() {
3648 let server = make_server(TransportMode::Http, "claude.ai", Some("lucian"));
3649 let source_agent = server.resolve_source_agent(None);
3650
3651 assert_eq!(source_agent, Some("claude.ai".into()));
3652 }
3653
3654 #[test]
3657 fn test_recall_constructs_search_request() {
3658 let params = RecallParams {
3659 query: "database choices".into(),
3660 limit: Some(5),
3661 memory_type: Some("decision".into()),
3662 space: None,
3663 rerank: None,
3664 };
3665
3666 let req = SearchMemoryRequest {
3667 query: params.query,
3668 limit: params.limit.unwrap_or(10),
3669 memory_type: params.memory_type,
3670 space: params.space,
3671 source_agent: None,
3672 rerank: params.rerank.unwrap_or(false),
3673 };
3674
3675 let json = serde_json::to_value(&req).unwrap();
3676 assert_eq!(json["query"], "database choices");
3677 assert_eq!(json["limit"], 5);
3678 assert_eq!(json["memory_type"], "decision");
3679 assert!(json.get("entity").is_none());
3680 assert!(json["space"].is_null());
3681 assert!(json["source_agent"].is_null());
3682 assert_eq!(json["rerank"], false);
3683 }
3684
3685 #[test]
3686 fn test_recall_forwards_rerank_flag() {
3687 let params = RecallParams {
3690 query: "database choices".into(),
3691 limit: None,
3692 memory_type: None,
3693 space: None,
3694 rerank: Some(true),
3695 };
3696
3697 let req = SearchMemoryRequest {
3698 query: params.query,
3699 limit: params.limit.unwrap_or(10),
3700 memory_type: params.memory_type,
3701 space: params.space,
3702 source_agent: None,
3703 rerank: params.rerank.unwrap_or(false),
3704 };
3705
3706 assert!(
3707 req.rerank,
3708 "RecallParams.rerank=Some(true) must flow through to SearchMemoryRequest.rerank=true"
3709 );
3710 let json = serde_json::to_value(&req).unwrap();
3711 assert_eq!(json["rerank"], true);
3712 }
3713
3714 #[test]
3715 fn test_recall_params_schema_advertises_rerank() {
3716 let params_schema = serde_json::to_string(&schemars::schema_for!(RecallParams))
3720 .expect("RecallParams schema serializes");
3721 assert!(
3722 params_schema.contains("rerank"),
3723 "RecallParams schema must advertise the `rerank` field, got: {params_schema}"
3724 );
3725 assert!(
3726 params_schema.contains("cross-encoder"),
3727 "RecallParams.rerank description must mention cross-encoder so models understand the tradeoff, got: {params_schema}"
3728 );
3729 }
3730
3731 #[test]
3739 fn test_capture_passes_through_all_canonical_types() {
3740 for t in origin_types::MemoryType::all_values() {
3741 let params = CaptureParams {
3742 content: "test".into(),
3743 memory_type: Some((*t).to_string()),
3744 space: None,
3745 entity: None,
3746 confidence: None,
3747 supersedes: None,
3748 structured_fields: None,
3749 retrieval_cue: None,
3750 };
3751 assert_eq!(params.memory_type.as_deref(), Some(*t));
3752 }
3753 }
3754
3755 #[test]
3759 fn test_capture_passes_through_legacy_goal_alias() {
3760 let params = CaptureParams {
3761 content: "test".into(),
3762 memory_type: Some("goal".into()),
3763 space: None,
3764 entity: None,
3765 confidence: None,
3766 supersedes: None,
3767 structured_fields: None,
3768 retrieval_cue: None,
3769 };
3770 assert_eq!(params.memory_type.as_deref(), Some("goal"));
3771 }
3772
3773 #[test]
3776 fn test_capture_params_with_structured_fields_and_cue() {
3777 let json = r#"{
3778 "content": "Lucian prefers dark mode",
3779 "structured_fields": {"theme":"dark"},
3780 "retrieval_cue": "What theme does Lucian prefer?"
3781 }"#;
3782 let params: CaptureParams = serde_json::from_str(json).unwrap();
3783 let structured_fields = params.structured_fields.expect("structured_fields");
3784 assert_eq!(
3785 structured_fields.get("theme"),
3786 Some(&serde_json::Value::String("dark".into()))
3787 );
3788 assert_eq!(
3789 params.retrieval_cue.as_deref(),
3790 Some("What theme does Lucian prefer?")
3791 );
3792 }
3793
3794 #[test]
3795 fn test_store_request_with_structured_fields() {
3796 let req = StoreMemoryRequest {
3797 content: "test".into(),
3798 memory_type: Some("fact".into()),
3799 space: None,
3800 source_agent: None,
3801 title: None,
3802 confidence: None,
3803 supersedes: None,
3804 entity: None,
3805 entity_id: None,
3806 structured_fields: Some(serde_json::json!({"key":"val"})),
3807 retrieval_cue: Some("What is the key?".into()),
3808 };
3809 let json = serde_json::to_value(&req).unwrap();
3810 assert_eq!(json["structured_fields"], serde_json::json!({"key":"val"}));
3811 assert_eq!(json["retrieval_cue"], "What is the key?");
3812 }
3813
3814 #[test]
3817 fn test_chat_context_response() {
3818 let json = r#"{
3819 "context": "User prefers dark mode. Works on Origin project.",
3820 "profile": {
3821 "narrative": "narrative",
3822 "identity": [],
3823 "preferences": [],
3824 "goals": []
3825 },
3826 "knowledge": {
3827 "pages": [],
3828 "decisions": [],
3829 "relevant_memories": [],
3830 "graph_context": []
3831 },
3832 "took_ms": 12.5,
3833 "token_estimates": {
3834 "tier1_identity": 1,
3835 "tier2_project": 2,
3836 "tier3_relevant": 3,
3837 "total": 6
3838 }
3839 }"#;
3840 let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
3841 assert!(!resp.context.is_empty());
3842 assert!(resp.profile.identity.is_empty());
3843 assert_eq!(resp.took_ms, 12.5);
3844 assert_eq!(resp.token_estimates.total, 6);
3845 }
3846
3847 #[test]
3848 fn test_chat_context_response_empty() {
3849 let json = r#"{
3850 "context": "",
3851 "profile": {
3852 "narrative": "",
3853 "identity": [],
3854 "preferences": [],
3855 "goals": []
3856 },
3857 "knowledge": {
3858 "pages": [],
3859 "decisions": [],
3860 "relevant_memories": [],
3861 "graph_context": []
3862 },
3863 "took_ms": 1.0,
3864 "token_estimates": {
3865 "tier1_identity": 0,
3866 "tier2_project": 0,
3867 "tier3_relevant": 0,
3868 "total": 0
3869 }
3870 }"#;
3871 let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
3872 assert!(resp.context.is_empty());
3873 }
3874
3875 fn server_instructions() -> String {
3882 let s = make_server(TransportMode::Stdio, "test", None);
3883 s.get_info()
3884 .instructions
3885 .expect("server must ship with_instructions")
3886 }
3887
3888 #[test]
3889 fn instructions_mention_cumulative_knowledge() {
3890 assert!(
3891 server_instructions().contains("cumulative"),
3892 "with_instructions must describe Origin as cumulative"
3893 );
3894 }
3895
3896 #[test]
3897 fn instructions_mention_shared_across_tools() {
3898 assert!(
3899 server_instructions().contains("shared across all"),
3900 "with_instructions must tell agents the store is shared across tools"
3901 );
3902 }
3903
3904 #[test]
3905 fn instructions_mention_how_user_thinks() {
3906 assert!(
3907 server_instructions().contains("how the user thinks"),
3908 "with_instructions must frame context as modeling how the user thinks"
3909 );
3910 }
3911
3912 #[test]
3913 fn instructions_use_proactive_framing() {
3914 assert!(
3915 server_instructions().contains("STORE PROACTIVELY"),
3916 "with_instructions must use STORE PROACTIVELY framing (not passive WHEN TO STORE)"
3917 );
3918 }
3919
3920 #[test]
3921 fn instructions_ban_tool_output_storage() {
3922 assert!(
3923 server_instructions().contains("Tool output or command results"),
3924 "with_instructions must explicitly rule out tool output as storage material"
3925 );
3926 }
3927
3928 #[test]
3929 fn instructions_ban_ghost_inferences() {
3930 assert!(
3931 server_instructions().contains("Your own inferences"),
3932 "with_instructions must rule out storing agent's own inferences user didn't express"
3933 );
3934 }
3935
3936 #[test]
3937 fn instructions_call_out_atomic_memory() {
3938 assert!(
3939 server_instructions().contains("Atomic: one idea per memory"),
3940 "with_instructions must call out the atomic-memory rule explicitly by name"
3941 );
3942 }
3943
3944 #[test]
3945 fn instructions_specify_declarative_writing() {
3946 assert!(
3947 server_instructions().contains("Declarative, not narrative"),
3948 "with_instructions must require declarative (not narrative) writing style"
3949 );
3950 }
3951
3952 #[test]
3953 fn instructions_default_to_omit_memory_type() {
3954 let i = server_instructions();
3955 assert!(
3956 i.contains("omit and trust the backend"),
3957 "with_instructions must default agents to omitting memory_type"
3958 );
3959 assert!(
3960 i.contains("do NOT set memory_type"),
3961 "with_instructions must explicitly say do NOT set memory_type by default"
3962 );
3963 }
3964
3965 #[test]
3966 fn instructions_list_every_canonical_memory_type() {
3967 let i = server_instructions();
3968 for ty in origin_types::MemoryType::all_values() {
3969 assert!(
3970 contains_word(&i, ty),
3971 "with_instructions must list canonical memory type \"{ty}\" so MCP clients see the full vocabulary",
3972 );
3973 }
3974 }
3975
3976 #[test]
3977 fn instructions_omit_legacy_goal_type() {
3978 let i = server_instructions();
3979 assert!(
3985 !contains_word(&i, "goal"),
3986 "with_instructions must not advertise legacy \"goal\" memory_type"
3987 );
3988 }
3989
3990 fn contains_word(haystack: &str, needle: &str) -> bool {
3995 haystack
3996 .split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
3997 .any(|tok| tok == needle)
3998 }
3999
4000 #[test]
4001 fn instructions_carve_out_decisions_for_decision_log() {
4002 let i = server_instructions();
4003 assert!(
4004 i.contains("Decision Log"),
4005 "with_instructions must name the Decision Log as the reason for explicit decision typing"
4006 );
4007 assert!(
4008 i.contains("memory_type=\"decision\""),
4009 "with_instructions must tell agents to set memory_type=\"decision\" explicitly for decisions"
4010 );
4011 }
4012
4013 fn tool_descriptions() -> std::collections::HashMap<String, String> {
4016 let server = make_server(TransportMode::Stdio, "test", None);
4017 server
4018 .tool_router
4019 .list_all()
4020 .into_iter()
4021 .filter_map(|t| {
4022 let desc = t.description.as_ref()?.to_string();
4023 Some((t.name.to_string(), desc))
4024 })
4025 .collect()
4026 }
4027
4028 #[test]
4029 fn capture_description_calls_out_atomic() {
4030 let descriptions = tool_descriptions();
4031 let capture = descriptions.get("capture").expect("capture tool exists");
4032 assert!(
4033 capture.contains("Each call is one atomic idea"),
4034 "capture description must call out atomic-per-call explicitly, got: {capture}"
4035 );
4036 }
4037
4038 #[test]
4039 fn context_description_frames_modeling_user() {
4040 let descriptions = tool_descriptions();
4041 let ctx = descriptions.get("context").expect("context tool exists");
4042 assert!(
4043 ctx.contains("how the user thinks"),
4044 "context description must frame the result as modeling how the user thinks, got: {ctx}"
4045 );
4046 }
4047
4048 #[test]
4049 fn doctor_description_mentions_setup_mode() {
4050 let descriptions = tool_descriptions();
4051 let status = descriptions.get("doctor").expect("doctor tool exists");
4052 assert!(
4053 status.contains("Local Memory"),
4054 "doctor description must mention setup modes, got: {status}"
4055 );
4056 assert!(
4057 status.contains("On-device Model"),
4058 "doctor description must mention on-device setup, got: {status}"
4059 );
4060 assert!(
4061 status.contains("not part of the memory loop"),
4062 "doctor description must frame itself as diagnostic-only, got: {status}"
4063 );
4064 }
4065
4066 #[test]
4067 fn recall_memory_type_param_lists_two_level_filter() {
4068 let params_schema = serde_json::to_string(&schemars::schema_for!(RecallParams))
4069 .expect("RecallParams schema serializes");
4070 assert!(
4071 params_schema.contains("Two-level filter"),
4072 "RecallParams.memory_type must advertise the two-level filter, got schema: {params_schema}"
4073 );
4074 assert!(
4075 params_schema.contains("profile"),
4076 "RecallParams.memory_type must mention profile alias"
4077 );
4078 assert!(
4079 params_schema.contains("knowledge"),
4080 "RecallParams.memory_type must mention knowledge alias"
4081 );
4082 }
4083
4084 #[test]
4089 fn test_create_entity_params_minimal() {
4090 let json = r#"{"name": "Alice", "entity_type": "person"}"#;
4091 let params: CreateEntityParams = serde_json::from_str(json).unwrap();
4092 assert_eq!(params.name, "Alice");
4093 assert_eq!(params.entity_type, "person");
4094 assert!(params.space.is_none());
4095 assert!(params.confidence.is_none());
4096 }
4097
4098 #[test]
4099 fn test_create_entity_params_full() {
4100 let json = r#"{
4101 "name": "PostgreSQL",
4102 "entity_type": "tool",
4103 "space": "origin",
4104 "confidence": 0.9
4105 }"#;
4106 let params: CreateEntityParams = serde_json::from_str(json).unwrap();
4107 assert_eq!(params.name, "PostgreSQL");
4108 assert_eq!(params.entity_type, "tool");
4109 assert_eq!(params.space.as_deref(), Some("origin"));
4110 assert_eq!(params.confidence, Some(0.9));
4111 }
4112
4113 #[test]
4114 fn test_create_entity_params_missing_name_fails() {
4115 let json = r#"{"entity_type": "person"}"#;
4116 let result = serde_json::from_str::<CreateEntityParams>(json);
4117 assert!(result.is_err());
4118 }
4119
4120 #[test]
4121 fn test_create_entity_params_missing_type_fails() {
4122 let json = r#"{"name": "Alice"}"#;
4123 let result = serde_json::from_str::<CreateEntityParams>(json);
4124 assert!(result.is_err());
4125 }
4126
4127 #[test]
4128 fn test_create_entity_request_body_shape() {
4129 let server = make_server(TransportMode::Stdio, "claude", None);
4130 let params = CreateEntityParams {
4131 name: "Origin".into(),
4132 entity_type: "project".into(),
4133 space: Some("origin".into()),
4134 confidence: Some(0.95),
4135 };
4136 let source_agent = server.resolve_source_agent(None);
4137 let req = CreateEntityRequest {
4138 name: params.name,
4139 entity_type: params.entity_type,
4140 space: params.space,
4141 source_agent,
4142 confidence: params.confidence,
4143 };
4144 let json = serde_json::to_value(&req).unwrap();
4145 assert_eq!(json["name"], "Origin");
4146 assert_eq!(json["entity_type"], "project");
4147 assert_eq!(json["space"], "origin");
4148 assert_eq!(json["source_agent"], "claude");
4149 assert!(json["confidence"].as_f64().unwrap() > 0.94);
4150 }
4151
4152 #[test]
4155 fn test_create_relation_params() {
4156 let json = r#"{
4157 "from_entity": "Alice",
4158 "to_entity": "Origin",
4159 "relation_type": "works_on"
4160 }"#;
4161 let params: CreateRelationParams = serde_json::from_str(json).unwrap();
4162 assert_eq!(params.from_entity, "Alice");
4163 assert_eq!(params.to_entity, "Origin");
4164 assert_eq!(params.relation_type, "works_on");
4165 }
4166
4167 #[test]
4168 fn test_create_relation_params_missing_field_fails() {
4169 let json = r#"{"from_entity": "Alice", "to_entity": "Origin"}"#;
4170 let result = serde_json::from_str::<CreateRelationParams>(json);
4171 assert!(result.is_err());
4172 }
4173
4174 #[test]
4175 fn test_create_relation_request_body_shape() {
4176 let server = make_server(TransportMode::Stdio, "claude", None);
4177 let params = CreateRelationParams {
4178 from_entity: "Alice".into(),
4179 to_entity: "Origin".into(),
4180 relation_type: "prefers".into(),
4181 };
4182 let source_agent = server.resolve_source_agent(None);
4183 let req = CreateRelationRequest {
4184 from_entity: params.from_entity,
4185 to_entity: params.to_entity,
4186 relation_type: params.relation_type,
4187 source_agent,
4188 confidence: None,
4189 explanation: None,
4190 source_memory_id: None,
4191 };
4192 let json = serde_json::to_value(&req).unwrap();
4193 assert_eq!(json["from_entity"], "Alice");
4194 assert_eq!(json["to_entity"], "Origin");
4195 assert_eq!(json["relation_type"], "prefers");
4196 assert_eq!(json["source_agent"], "claude");
4197 }
4198
4199 #[test]
4202 fn test_create_page_params_minimal() {
4203 let json = r#"{"title": "Origin daemon", "content": "Body text."}"#;
4204 let params: CreatePageParams = serde_json::from_str(json).unwrap();
4205 assert_eq!(params.title, "Origin daemon");
4206 assert_eq!(params.content, "Body text.");
4207 assert!(params.summary.is_none());
4208 assert!(params.entity_id.is_none());
4209 assert!(params.space.is_none());
4210 assert!(params.source_memory_ids.is_empty());
4211 }
4212
4213 #[test]
4214 fn test_create_page_params_full() {
4215 let json = r##"{
4216 "title": "Origin daemon",
4217 "content": "Markdown body with [[wikilinks]].",
4218 "summary": "The headless HTTP daemon at the heart of Origin.",
4219 "entity_id": "ent_origin",
4220 "space": "origin",
4221 "source_memory_ids": ["mem_1", "mem_2"]
4222 }"##;
4223 let params: CreatePageParams = serde_json::from_str(json).unwrap();
4224 assert_eq!(params.title, "Origin daemon");
4225 assert_eq!(
4226 params.summary.as_deref(),
4227 Some("The headless HTTP daemon at the heart of Origin.")
4228 );
4229 assert_eq!(params.entity_id.as_deref(), Some("ent_origin"));
4230 assert_eq!(params.space.as_deref(), Some("origin"));
4231 assert_eq!(params.source_memory_ids, vec!["mem_1", "mem_2"]);
4232 }
4233
4234 #[test]
4235 fn test_create_page_params_missing_required_fails() {
4236 let json = r#"{"title": "Only title"}"#;
4237 let result = serde_json::from_str::<CreatePageParams>(json);
4238 assert!(result.is_err());
4239 }
4240
4241 #[test]
4242 fn test_create_page_request_body_shape() {
4243 let params = CreatePageParams {
4244 title: "Page".into(),
4245 content: "Body".into(),
4246 summary: Some("S".into()),
4247 entity_id: Some("ent_1".into()),
4248 space: Some("origin".into()),
4249 source_memory_ids: vec!["mem_1".into()],
4250 };
4251 let req = CreateConceptRequest {
4252 title: params.title,
4253 content: params.content,
4254 summary: params.summary,
4255 entity_id: params.entity_id,
4256 space: params.space,
4257 source_memory_ids: params.source_memory_ids,
4258 creation_kind: None,
4259 workspace: None,
4260 };
4261 let json = serde_json::to_value(&req).unwrap();
4262 assert_eq!(json["title"], "Page");
4263 assert_eq!(json["content"], "Body");
4264 assert_eq!(json["summary"], "S");
4265 assert_eq!(json["entity_id"], "ent_1");
4266 assert_eq!(json["space"], "origin");
4267 assert_eq!(json["source_memory_ids"], serde_json::json!(["mem_1"]));
4268 }
4269
4270 #[test]
4273 fn test_delete_page_params() {
4274 let json = r#"{"page_id": "page_abc"}"#;
4275 let params: DeletePageParams = serde_json::from_str(json).unwrap();
4276 assert_eq!(params.page_id, "page_abc");
4277 }
4278
4279 #[test]
4280 fn test_delete_page_params_missing_fails() {
4281 let json = r#"{}"#;
4282 let result = serde_json::from_str::<DeletePageParams>(json);
4283 assert!(result.is_err());
4284 }
4285
4286 #[tokio::test]
4287 async fn test_delete_page_blocked_on_http_transport() {
4288 let server = make_server(TransportMode::Http, "agent", None);
4289 let result = server.delete_page_impl("page_123").await.unwrap();
4290 let content = &result.content[0];
4291 match content.raw {
4292 rmcp::model::RawContent::Text(ref tc) => {
4293 assert!(tc.text.contains("not available over remote connections"));
4294 }
4295 _ => panic!("expected text content"),
4296 }
4297 }
4298
4299 #[tokio::test]
4300 async fn test_delete_page_allowed_on_stdio_transport() {
4301 let server = make_server(TransportMode::Stdio, "agent", None);
4303 let result = server.delete_page_impl("page_123").await.unwrap();
4304 assert!(
4305 result.is_error.unwrap_or(false),
4306 "should fail with connection error, not transport block"
4307 );
4308 }
4309
4310 #[tokio::test]
4311 async fn delete_observation_refuses_http_transport() {
4312 let server = make_server(TransportMode::Http, "agent", None);
4313 let params = DeleteObservationParams {
4314 observation_id: "obs_123".to_string(),
4315 };
4316 let result = server.delete_observation_impl(params).await.unwrap();
4317 let content = &result.content[0];
4318 match content.raw {
4319 rmcp::model::RawContent::Text(ref tc) => {
4320 assert!(tc.text.contains("not available over remote connections"));
4321 }
4322 _ => panic!("expected text content"),
4323 }
4324 }
4325
4326 #[test]
4329 fn test_get_page_params() {
4330 let json = r#"{"page_id": "page_abc"}"#;
4331 let params: GetPageParams = serde_json::from_str(json).unwrap();
4332 assert_eq!(params.page_id, "page_abc");
4333 }
4334
4335 #[test]
4336 fn test_get_page_params_missing_fails() {
4337 let json = r#"{}"#;
4338 let result = serde_json::from_str::<GetPageParams>(json);
4339 assert!(result.is_err());
4340 }
4341
4342 #[test]
4345 fn test_list_memories_params_empty() {
4346 let json = r#"{}"#;
4347 let params: ListMemoriesParams = serde_json::from_str(json).unwrap();
4348 assert!(params.memory_type.is_none());
4349 assert!(params.space.is_none());
4350 assert!(params.limit.is_none());
4351 }
4352
4353 #[test]
4354 fn test_list_memories_params_full() {
4355 let json = r#"{"memory_type": "decision", "space": "origin", "limit": 50}"#;
4356 let params: ListMemoriesParams = serde_json::from_str(json).unwrap();
4357 assert_eq!(params.memory_type.as_deref(), Some("decision"));
4358 assert_eq!(params.space.as_deref(), Some("origin"));
4359 assert_eq!(params.limit, Some(50));
4360 }
4361
4362 #[test]
4363 fn test_list_memories_params_limit_as_string() {
4364 let json = r#"{"limit": "25"}"#;
4366 let params: ListMemoriesParams = serde_json::from_str(json).unwrap();
4367 assert_eq!(params.limit, Some(25));
4368 }
4369
4370 #[test]
4371 fn test_list_memories_request_body_shape() {
4372 let params = ListMemoriesParams {
4373 memory_type: Some("fact".into()),
4374 space: None,
4375 limit: Some(10),
4376 };
4377 let req = ListMemoriesRequest {
4378 memory_type: params.memory_type,
4379 space: params.space,
4380 limit: params.limit.unwrap_or(100),
4381 confirmed: None,
4382 };
4383 let json = serde_json::to_value(&req).unwrap();
4384 assert_eq!(json["memory_type"], "fact");
4385 assert!(json["space"].is_null());
4386 assert_eq!(json["limit"], 10);
4387 }
4388
4389 #[test]
4390 fn test_list_memories_request_default_limit() {
4391 let params = ListMemoriesParams {
4392 memory_type: None,
4393 space: None,
4394 limit: None,
4395 };
4396 let req = ListMemoriesRequest {
4397 memory_type: params.memory_type,
4398 space: params.space,
4399 limit: params.limit.unwrap_or(100),
4400 confirmed: None,
4401 };
4402 assert_eq!(req.limit, 100);
4403 }
4404
4405 #[test]
4408 fn test_update_page_params_minimal() {
4409 let json =
4410 r#"{"page_id": "page_abc", "content": "fresh body", "source_memory_ids": ["mem_1"]}"#;
4411 let params: UpdatePageParams = serde_json::from_str(json).unwrap();
4412 assert_eq!(params.page_id, "page_abc");
4413 assert_eq!(params.content, "fresh body");
4414 assert_eq!(params.source_memory_ids, vec!["mem_1"]);
4415 assert!(params.summary.is_none());
4416 }
4417
4418 #[test]
4419 fn test_update_page_params_with_summary() {
4420 let json = r#"{
4421 "page_id": "page_abc",
4422 "content": "body",
4423 "source_memory_ids": ["mem_1", "mem_2"],
4424 "summary": "Refreshed claim."
4425 }"#;
4426 let params: UpdatePageParams = serde_json::from_str(json).unwrap();
4427 assert_eq!(params.summary.as_deref(), Some("Refreshed claim."));
4428 assert_eq!(params.source_memory_ids.len(), 2);
4429 }
4430
4431 #[test]
4432 fn test_update_page_params_missing_required_fails() {
4433 let json = r#"{"page_id": "page_abc", "content": "body"}"#;
4436 let result = serde_json::from_str::<UpdatePageParams>(json);
4437 assert!(result.is_err());
4438 }
4439
4440 #[test]
4441 fn test_update_page_request_body_shape() {
4442 let params = UpdatePageParams {
4443 page_id: "page_abc".into(),
4444 content: "Body".into(),
4445 source_memory_ids: vec!["mem_1".into()],
4446 summary: Some("S".into()),
4447 };
4448 let req = origin_types::requests::RefreshPageRequest {
4449 content: params.content,
4450 source_memory_ids: params.source_memory_ids,
4451 summary: params.summary,
4452 };
4453 let json = serde_json::to_value(&req).unwrap();
4454 assert_eq!(json["content"], "Body");
4455 assert_eq!(json["source_memory_ids"], serde_json::json!(["mem_1"]));
4456 assert_eq!(json["summary"], "S");
4457 assert!(json.get("page_id").is_none());
4459 }
4460
4461 #[test]
4464 fn new_crud_tools_are_registered() {
4465 let descriptions = tool_descriptions();
4466 for name in [
4467 "create_entity",
4468 "create_relation",
4469 "create_observation",
4470 "confirm_entity",
4471 "update_observation",
4472 "confirm_observation",
4473 "delete_observation",
4474 "create_page",
4475 "update_page",
4476 "delete_page",
4477 "get_page",
4478 "get_page_links",
4479 "list_memories",
4480 "search_pages",
4481 "list_pages_recent",
4482 "list_spaces",
4483 ] {
4484 assert!(
4485 descriptions.contains_key(name),
4486 "tool `{name}` must be registered, got: {:?}",
4487 descriptions.keys().collect::<Vec<_>>()
4488 );
4489 }
4490 }
4491
4492 #[test]
4493 fn capture_memory_type_schema_lists_every_canonical_type() {
4494 let params_schema = serde_json::to_string(&schemars::schema_for!(CaptureParams))
4495 .expect("CaptureParams schema serializes");
4496 for ty in origin_types::MemoryType::all_values() {
4497 assert!(
4498 params_schema.contains(ty),
4499 "CaptureParams.memory_type schema must list canonical type \"{ty}\", got: {params_schema}"
4500 );
4501 }
4502 }
4503
4504 #[test]
4505 fn recall_memory_type_schema_lists_every_canonical_type() {
4506 let params_schema = serde_json::to_string(&schemars::schema_for!(RecallParams))
4507 .expect("RecallParams schema serializes");
4508 for ty in origin_types::MemoryType::all_values() {
4509 assert!(
4510 params_schema.contains(ty),
4511 "RecallParams.memory_type schema must list canonical type \"{ty}\", got: {params_schema}"
4512 );
4513 }
4514 }
4515
4516 #[test]
4517 fn create_entity_schema_documents_name_and_type() {
4518 let schema = serde_json::to_string(&schemars::schema_for!(CreateEntityParams))
4519 .expect("CreateEntityParams schema serializes");
4520 assert!(
4521 schema.contains("Canonical entity name"),
4522 "schema must describe `name` field"
4523 );
4524 assert!(
4525 schema.contains("Entity category"),
4526 "schema must describe `entity_type` field"
4527 );
4528 }
4529
4530 #[test]
4531 fn create_page_schema_documents_traceability() {
4532 let schema = serde_json::to_string(&schemars::schema_for!(CreatePageParams))
4533 .expect("CreatePageParams schema serializes");
4534 assert!(
4535 schema.contains("traceability"),
4536 "schema must spell out why source_memory_ids matter"
4537 );
4538 }
4539
4540 #[test]
4541 fn delete_page_tool_is_marked_destructive() {
4542 let server = make_server(TransportMode::Stdio, "test", None);
4543 let tool = server
4544 .tool_router
4545 .list_all()
4546 .into_iter()
4547 .find(|t| t.name == "delete_page")
4548 .expect("delete_page registered");
4549 let ann = tool.annotations.as_ref().expect("annotations present");
4550 assert_eq!(
4551 ann.destructive_hint,
4552 Some(true),
4553 "delete_page must declare destructive_hint=true"
4554 );
4555 }
4556
4557 #[test]
4560 fn test_search_pages_params_minimal() {
4561 let json = r#"{"query": "mutex deadlock"}"#;
4562 let params: SearchPagesParams = serde_json::from_str(json).unwrap();
4563 assert_eq!(params.query, "mutex deadlock");
4564 assert!(params.limit.is_none());
4565 }
4566
4567 #[test]
4568 fn test_search_pages_params_full() {
4569 let json = r#"{"query": "distill architecture", "limit": 5}"#;
4570 let params: SearchPagesParams = serde_json::from_str(json).unwrap();
4571 assert_eq!(params.query, "distill architecture");
4572 assert_eq!(params.limit, Some(5));
4573 }
4574
4575 #[test]
4576 fn test_search_pages_params_missing_query_fails() {
4577 let json = r#"{"limit": 10}"#;
4578 let result = serde_json::from_str::<SearchPagesParams>(json);
4579 assert!(result.is_err());
4580 }
4581
4582 #[test]
4583 fn test_search_pages_params_limit_as_string() {
4584 let json = r#"{"query": "x", "limit": "3"}"#;
4585 let params: SearchPagesParams = serde_json::from_str(json).unwrap();
4586 assert_eq!(params.limit, Some(3));
4587 }
4588
4589 #[test]
4590 fn test_search_pages_request_body_shape() {
4591 let params = SearchPagesParams {
4592 query: "mutex".into(),
4593 limit: Some(7),
4594 page_type: None,
4595 };
4596 let req = SearchPagesRequest {
4597 query: params.query,
4598 limit: params.limit,
4599 page_type: params.page_type,
4600 };
4601 let json = serde_json::to_value(&req).unwrap();
4602 assert_eq!(json["query"], "mutex");
4603 assert_eq!(json["limit"], 7);
4604 }
4605
4606 #[test]
4609 fn test_list_pages_recent_params_empty() {
4610 let json = r#"{}"#;
4611 let params: ListPagesRecentParams = serde_json::from_str(json).unwrap();
4612 assert!(params.limit.is_none());
4613 assert!(params.since_ms.is_none());
4614 }
4615
4616 #[test]
4617 fn test_list_pages_recent_params_full() {
4618 let json = r#"{"limit": 20, "since_ms": 1715000000000}"#;
4619 let params: ListPagesRecentParams = serde_json::from_str(json).unwrap();
4620 assert_eq!(params.limit, Some(20));
4621 assert_eq!(params.since_ms, Some(1715000000000));
4622 }
4623
4624 #[test]
4625 fn test_list_pages_recent_params_string_numbers() {
4626 let json = r#"{"limit": "15", "since_ms": "1715000000000"}"#;
4627 let params: ListPagesRecentParams = serde_json::from_str(json).unwrap();
4628 assert_eq!(params.limit, Some(15));
4629 assert_eq!(params.since_ms, Some(1715000000000));
4630 }
4631
4632 #[test]
4633 fn list_pages_recent_url_construction() {
4634 assert_eq!(build_recent_pages_path(None, None), "/api/pages/recent");
4637 assert_eq!(
4638 build_recent_pages_path(Some(5), None),
4639 "/api/pages/recent?limit=5"
4640 );
4641 assert_eq!(
4642 build_recent_pages_path(None, Some(123)),
4643 "/api/pages/recent?since_ms=123"
4644 );
4645 assert_eq!(
4646 build_recent_pages_path(Some(10), Some(456)),
4647 "/api/pages/recent?limit=10&since_ms=456"
4648 );
4649 assert_eq!(
4651 build_recent_pages_path(None, Some(-1)),
4652 "/api/pages/recent?since_ms=-1"
4653 );
4654 }
4655
4656 #[test]
4657 fn search_pages_and_list_pages_recent_are_read_only() {
4658 let server = make_server(TransportMode::Stdio, "test", None);
4659 for name in ["search_pages", "list_pages_recent"] {
4660 let tool = server
4661 .tool_router
4662 .list_all()
4663 .into_iter()
4664 .find(|t| t.name == name)
4665 .unwrap_or_else(|| panic!("`{name}` registered"));
4666 let ann = tool.annotations.as_ref().expect("annotations present");
4667 assert_eq!(
4668 ann.read_only_hint,
4669 Some(true),
4670 "`{name}` must declare read_only_hint=true"
4671 );
4672 }
4673 }
4674
4675 #[test]
4676 fn accept_refinement_response_typed_deserialize() {
4677 let raw = r#"{"id":"ref_xyz","action_applied":"entity_merge"}"#;
4678 let parsed: AcceptRefinementResponse = serde_json::from_str(raw).unwrap();
4679 assert_eq!(parsed.id, "ref_xyz");
4680 assert_eq!(parsed.action_applied, "entity_merge");
4681 }
4682
4683 #[test]
4684 fn accept_refinement_response_rejects_extra_envelope() {
4685 let wrong = r#"{"data":{"id":"ref_xyz","action_applied":"entity_merge"}}"#;
4689 let result: Result<AcceptRefinementResponse, _> = serde_json::from_str(wrong);
4690 assert!(
4691 result.is_err(),
4692 "envelope-wrapped response must fail typed deserialize"
4693 );
4694 }
4695
4696 #[test]
4699 fn distill_params_deserializes_force() {
4700 let p: DistillParams =
4701 serde_json::from_str(r#"{"target":"page_xyz","force":true}"#).unwrap();
4702 assert_eq!(p.target.as_deref(), Some("page_xyz"));
4703 assert_eq!(p.force, Some(true));
4704 }
4705
4706 #[test]
4707 fn distill_params_defaults_force_to_none() {
4708 let p: DistillParams = serde_json::from_str(r#"{"target":"foo"}"#).unwrap();
4709 assert_eq!(p.force, None);
4710 }
4711
4712 #[test]
4715 fn locked_overrides_inbound_space() {
4716 let _guard = crate::lock_state::ENV_LOCK.lock().unwrap();
4717 std::env::set_var("ORIGIN_SPACE", "career");
4718 crate::lock_state::init_from_env();
4719
4720 let inbound = Some("ideas".to_string());
4721 let resolved = effective_space(&inbound);
4722 assert_eq!(resolved.as_deref(), Some("career"));
4723 }
4724
4725 #[test]
4726 fn unlocked_passes_inbound_through() {
4727 let _guard = crate::lock_state::ENV_LOCK.lock().unwrap();
4728 std::env::remove_var("ORIGIN_SPACE");
4729 crate::lock_state::init_from_env();
4730
4731 let inbound = Some("ideas".to_string());
4732 let resolved = effective_space(&inbound);
4733 assert_eq!(resolved.as_deref(), Some("ideas"));
4734 }
4735
4736 #[test]
4737 fn locked_with_no_inbound_yields_locked() {
4738 let _guard = crate::lock_state::ENV_LOCK.lock().unwrap();
4739 std::env::set_var("ORIGIN_SPACE", "career");
4740 crate::lock_state::init_from_env();
4741
4742 let inbound: Option<String> = None;
4743 let resolved = effective_space(&inbound);
4744 assert_eq!(resolved.as_deref(), Some("career"));
4745 }
4746
4747 #[test]
4748 fn unlocked_with_no_inbound_yields_none() {
4749 let _guard = crate::lock_state::ENV_LOCK.lock().unwrap();
4750 std::env::remove_var("ORIGIN_SPACE");
4751 crate::lock_state::init_from_env();
4752
4753 let inbound: Option<String> = None;
4754 let resolved = effective_space(&inbound);
4755 assert_eq!(resolved, None);
4756 }
4757
4758 #[test]
4762 fn capture_schema_has_space_in_raw_router() {
4763 let tools = OriginMcpServer::tool_router().list_all();
4764 let capture = tools
4765 .into_iter()
4766 .find(|t| t.name == "capture")
4767 .expect("capture tool registered");
4768 let props = capture
4769 .input_schema
4770 .get("properties")
4771 .and_then(|v| v.as_object())
4772 .expect("capture has properties");
4773 assert!(
4774 props.contains_key("space"),
4775 "baseline: capture schema must have space before gating"
4776 );
4777 }
4778
4779 #[test]
4781 fn capture_tool_schema_omits_space_when_locked() {
4782 let _guard = crate::lock_state::ENV_LOCK.lock().unwrap();
4783 std::env::set_var("ORIGIN_SPACE", "career");
4784 crate::lock_state::init_from_env();
4785
4786 let tools = OriginMcpServer::tool_router().list_all();
4787 let tools: Vec<_> = tools
4788 .into_iter()
4789 .map(strip_space_from_tool_schema)
4790 .collect();
4791 let capture = tools
4792 .iter()
4793 .find(|t| t.name == "capture")
4794 .expect("capture tool registered");
4795 let props = capture
4796 .input_schema
4797 .get("properties")
4798 .and_then(|v| v.as_object())
4799 .expect("capture has properties");
4800 assert!(
4801 !props.contains_key("space"),
4802 "space field must be omitted from capture schema when ORIGIN_SPACE is locked"
4803 );
4804
4805 std::env::remove_var("ORIGIN_SPACE");
4807 crate::lock_state::init_from_env();
4808 }
4809
4810 #[test]
4812 fn capture_tool_schema_includes_space_when_unlocked() {
4813 let _guard = crate::lock_state::ENV_LOCK.lock().unwrap();
4814 std::env::remove_var("ORIGIN_SPACE");
4815 crate::lock_state::init_from_env();
4816
4817 let tools = OriginMcpServer::tool_router().list_all();
4819 let capture = tools
4820 .iter()
4821 .find(|t| t.name == "capture")
4822 .expect("capture tool registered");
4823 let props = capture
4824 .input_schema
4825 .get("properties")
4826 .and_then(|v| v.as_object())
4827 .expect("capture has properties");
4828 assert!(
4829 props.contains_key("space"),
4830 "space field must be present in capture schema when ORIGIN_SPACE is not locked"
4831 );
4832 }
4833}