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