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