1use std::path::PathBuf;
15use std::sync::{Arc, Mutex, OnceLock};
16
17use rmcp::handler::server::wrapper::Parameters;
18use rmcp::model::{
19 CallToolRequestParams, CallToolResult, ContentBlock, InitializeRequestParams, InitializeResult,
20 ListToolsResult, PaginatedRequestParams, Tool,
21};
22use rmcp::service::RequestContext;
23use rmcp::{ErrorData as McpError, RoleServer, ServerHandler, tool, tool_handler, tool_router};
24
25use indexmap::IndexMap;
26
27use memstead_base::EntityId;
28use memstead_base::ops::SearchScope;
29use memstead_base::render::{render_entity_markdown, render_search_markdown};
30use memstead_base::vcs::{Actor, ClientId};
31use memstead_base::{
32 BootError, CreateEntityArgs, DeleteEntityArgs, Engine, EngineError, RelateAction,
33 RelateEntityArgs, RenameEntityArgs, UpdateEntityArgs,
34};
35use std::path::Path;
36
37use crate::tools::admin::{ChangesSinceParams, DiffParams, HealthParams};
38use crate::tools::graph::{EntityParams, OverviewParams, SchemaParams, SearchParams};
39use crate::tools::mutation::{
40 CheckParams, CreateParams, DeleteParams, RelateParams, RenameParams, UpdateParams,
41};
42
43#[derive(Clone)]
47pub struct FilesystemMcpServer {
48 engine: Arc<Mutex<Engine>>,
52 workspace_root: PathBuf,
57 client: Arc<OnceLock<ClientId>>,
61}
62
63impl FilesystemMcpServer {
64 pub fn from_workspace_root(workspace_root: &Path) -> Result<Self, BootError> {
73 let workspace_root = workspace_root.to_path_buf();
74 let engine = Engine::from_workspace_root(&workspace_root)?;
75 Ok(Self {
76 engine: Arc::new(Mutex::new(engine)),
77 workspace_root,
78 client: Arc::new(OnceLock::new()),
79 })
80 }
81
82 pub fn from_engine(engine: Engine, workspace_root: PathBuf) -> Self {
87 Self {
88 engine: Arc::new(Mutex::new(engine)),
89 workspace_root,
90 client: Arc::new(OnceLock::new()),
91 }
92 }
93
94 pub fn export_mem_to_bytes(&self) -> Result<Vec<u8>, memstead_base::EngineError> {
100 let engine = self
101 .engine
102 .lock()
103 .expect("filesystem MCP engine mutex poisoned");
104 let mem = engine
105 .mem_names()
106 .into_iter()
107 .next()
108 .map(String::from)
109 .ok_or_else(|| {
110 memstead_base::EngineError::InvalidInput("no mem to export".to_string())
111 })?;
112 engine.export_mem_to_bytes(&mem)
113 }
114
115 pub fn entity_count(&self) -> usize {
119 self.engine
120 .lock()
121 .expect("filesystem MCP engine mutex poisoned")
122 .status()
123 .entity_count
124 }
125
126 pub fn with_engine<R>(&self, f: impl FnOnce(&Engine) -> R) -> R {
132 let engine = self
133 .engine
134 .lock()
135 .expect("filesystem MCP engine mutex poisoned");
136 f(&engine)
137 }
138
139 fn actor_and_client(&self) -> (Actor, Option<ClientId>) {
140 match self.client.get() {
141 Some(c) => (Actor::Agent, Some(c.clone())),
142 None => (Actor::Agent, None),
143 }
144 }
145}
146
147fn tool_error(code: &str, message: &str) -> CallToolResult {
159 tool_error_with_details(code, message, None)
160}
161
162fn tool_error_with_details(
168 code: &str,
169 message: &str,
170 details: Option<serde_json::Value>,
171) -> CallToolResult {
172 let payload = match details {
173 Some(d) => serde_json::json!({ "code": code, "message": message, "details": d }),
174 None => serde_json::json!({ "code": code, "message": message }),
175 };
176 let text = format!("ERROR [{code}]: {message}");
177 let mut result = CallToolResult::error(vec![ContentBlock::text(text)]);
178 result.structured_content = Some(payload);
179 result
180}
181
182fn md_response(markdown: String) -> CallToolResult {
183 CallToolResult::success(vec![ContentBlock::text(markdown)])
184}
185
186fn md_with_structured(markdown: String, structured: serde_json::Value) -> CallToolResult {
192 let mut result = CallToolResult::success(vec![ContentBlock::text(markdown)]);
193 result.structured_content = Some(structured);
194 result
195}
196
197fn json_response<T: serde::Serialize>(data: &T) -> CallToolResult {
198 let value = serde_json::to_value(data).unwrap_or(serde_json::Value::Null);
199 let text = serde_json::to_string_pretty(&value).unwrap_or_default();
200 let mut result = CallToolResult::success(vec![ContentBlock::text(text)]);
201 result.structured_content = Some(value);
202 result
203}
204
205fn mem_is_durable(engine: &memstead_base::Engine, mem: &str) -> bool {
213 engine
214 .mounts()
215 .iter()
216 .find(|m| m.mem == mem)
217 .map(|m| m.storage.is_durable())
218 .unwrap_or(false)
219}
220
221fn resolve_role_lean(raw: Option<&str>) -> Result<memstead_base::vcs::Role, Box<CallToolResult>> {
239 match raw {
240 None => Ok(memstead_base::vcs::Role::Unspecified),
241 Some(s) => memstead_base::vcs::Role::from_wire(s).ok_or_else(|| {
242 let msg = format!(
243 "unknown role {s:?} — declarable roles: {}",
244 memstead_base::vcs::Role::DECLARABLE.join(", ")
245 );
246 Box::new(tool_error_with_details(
247 "INVALID_ROLE",
248 &msg,
249 Some(serde_json::json!({
250 "role": s,
251 "allowed": memstead_base::vcs::Role::DECLARABLE,
252 })),
253 ))
254 }),
255 }
256}
257
258fn reject_unsupported_params(params: &[(&str, bool)]) -> Option<CallToolResult> {
259 let dropped: Vec<&str> = params
260 .iter()
261 .filter_map(|(name, meaningful)| meaningful.then_some(*name))
262 .collect();
263 if dropped.is_empty() {
264 return None;
265 }
266 let msg = format!(
267 "the filesystem-mem surface does not implement: {}. These params were \
268 refused, not silently ignored — pass them only to the unified engine \
269 (mem-repo MCP / CLI), or omit them.",
270 dropped.join(", ")
271 );
272 Some(tool_error_with_details(
273 "UNSUPPORTED_PARAM",
274 &msg,
275 Some(serde_json::json!({ "params": dropped })),
276 ))
277}
278
279fn engine_op_error(err: EngineError) -> CallToolResult {
292 let display = err.to_string();
297 match err {
298 EngineError::UnknownType {
299 name,
300 schema_ref,
301 declared,
302 suggestion,
303 } => {
304 let hint = suggestion
305 .as_deref()
306 .map(|s| format!(". Did you mean '{s}'?"))
307 .unwrap_or_default();
308 tool_error(
309 "UNKNOWN_ENTITY_TYPE",
310 &format!(
311 "unknown entity type '{name}' in schema '{schema_ref}'. \
312 Declared types: [{}]{hint}",
313 declared.join(", ")
314 ),
315 )
316 }
317 EngineError::InvalidTitle(slug_err) => {
318 use memstead_base::SlugError;
319 let reason = slug_err.reason();
320 let details = match &slug_err {
321 SlugError::IdTooLong { input, length, max } => serde_json::json!({
322 "reason": reason,
323 "input": input,
324 "length": length,
325 "max": max,
326 }),
327 SlugError::TitleEmpty { input } => serde_json::json!({
328 "reason": reason,
329 "input": input,
330 }),
331 SlugError::TitleHasControlChars {
332 input,
333 control_chars,
334 proposed_slug,
335 } => {
336 let control_chars_str: Vec<String> = control_chars
337 .iter()
338 .map(|c| c.escape_default().to_string())
339 .collect();
340 serde_json::json!({
341 "reason": reason,
342 "input": input,
343 "control_chars": control_chars_str,
344 "proposed_slug": proposed_slug,
345 })
346 }
347 };
348 tool_error_with_details(
349 "INVALID_TITLE",
350 &format!("title is invalid: {slug_err}"),
351 Some(details),
352 )
353 }
354 e @ EngineError::AlreadyExists { .. } => tool_error_with_details(
355 "ENTITY_ALREADY_EXISTS",
356 &e.to_string(),
359 Some(e.details()),
360 ),
361 e @ (EngineError::ConstraintUnsatisfied { .. }
365 | EngineError::RequiredOutgoingUnsatisfied { .. }
366 | EngineError::SectionFormatRefused { .. }) => {
367 tool_error_with_details(e.code(), &display, Some(e.details()))
368 }
369 EngineError::NotFound { id } => {
370 tool_error("ENTITY_NOT_FOUND", &format!("entity not found: {id}"))
371 }
372 EngineError::HashMismatch {
373 id,
374 current,
375 is_stub,
376 } => {
377 let message = if is_stub {
384 format!(
385 "hash mismatch for {id} — entity is a stub (no content_hash); pass expected_hash: \"\" to operate on stubs"
386 )
387 } else {
388 format!("hash mismatch for {id}: current is {current}")
389 };
390 let payload = serde_json::json!({
391 "code": "HASH_MISMATCH",
392 "message": message.clone(),
393 "details": {
394 "id": id,
395 "current": current,
396 "is_stub": is_stub,
397 },
398 });
399 let text = format!("ERROR [HASH_MISMATCH]: {message}");
403 let mut result = CallToolResult::error(vec![ContentBlock::text(text)]);
404 result.structured_content = Some(payload);
405 result
406 }
407 EngineError::HasIncomingRefs { id, referrers } => {
408 let referrers_json: Vec<_> = referrers
409 .iter()
410 .map(|r| {
411 serde_json::json!({
412 "from_id": r.from_id,
413 "rel_types": r.rel_types,
414 "mem": r.mem,
415 "capability": "write",
416 })
417 })
418 .collect();
419 let message = display;
420 let payload = serde_json::json!({
421 "code": "HAS_INCOMING_REFS",
422 "message": message.clone(),
423 "details": { "id": id, "referrers": referrers_json },
424 });
425 let text = format!("ERROR [HAS_INCOMING_REFS]: {message}");
426 let mut result = CallToolResult::error(vec![ContentBlock::text(text)]);
427 result.structured_content = Some(payload);
428 result
429 }
430 EngineError::MemHasIncomingRefs { mem, referrers } => {
431 let referrers_json: Vec<_> = referrers
438 .iter()
439 .map(|r| {
440 serde_json::json!({
441 "from_id": r.from_id,
442 "rel_types": r.rel_types,
443 "mem": r.mem,
444 })
445 })
446 .collect();
447 let message = display;
448 let payload = serde_json::json!({
449 "code": "MEM_HAS_INCOMING_REFS",
450 "message": message.clone(),
451 "details": { "mem": mem, "referrers": referrers_json },
452 });
453 let text = format!("ERROR [MEM_HAS_INCOMING_REFS]: {message}");
454 let mut result = CallToolResult::error(vec![ContentBlock::text(text)]);
455 result.structured_content = Some(payload);
456 result
457 }
458 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => tool_error(
459 "CROSS_MEM_LINK_NOT_ALLOWED",
460 &format!(
461 "cross-mem link from `{from_mem}` to `{to_mem}` is not allowed by the workspace `[cross_mem_links]` policy"
462 ),
463 ),
464 EngineError::CrossMemTargetNotFound {
465 target_id,
466 target_mem,
467 } => tool_error(
468 "CROSS_MEM_TARGET_NOT_FOUND",
469 &format!(
470 "cross-mem target `{target_id}` is absent in read-only mem `{target_mem}` — auto-stub is unavailable across the read-only boundary"
471 ),
472 ),
473 EngineError::CrossMemEdgeNotDeclared {
474 source_schema,
475 target_schema,
476 rel_type,
477 from_id,
478 to_id,
479 } => {
480 let message = display;
481 let payload = serde_json::json!({
482 "code": "CROSS_MEM_EDGE_NOT_DECLARED",
483 "message": message.clone(),
484 "details": {
485 "source_schema": source_schema,
486 "target_schema": target_schema,
487 "rel_type": rel_type,
488 "from_id": from_id,
489 "to_id": to_id,
490 },
491 });
492 let text = format!("ERROR [CROSS_MEM_EDGE_NOT_DECLARED]: {message}");
493 let mut result = CallToolResult::error(vec![ContentBlock::text(text)]);
494 result.structured_content = Some(payload);
495 result
496 }
497 EngineError::RepairNotNeeded { id, recovery } => {
498 let message = display;
499 let payload = serde_json::json!({
500 "code": "REPAIR_NOT_NEEDED",
501 "message": message.clone(),
502 "details": { "id": id, "recovery": recovery },
503 });
504 let text = format!("ERROR [REPAIR_NOT_NEEDED]: {message}");
505 let mut result = CallToolResult::error(vec![ContentBlock::text(text)]);
506 result.structured_content = Some(payload);
507 result
508 }
509 EngineError::RenameNoOp { id, new_title } => tool_error(
510 "RENAME_NO_OP",
511 &format!(
512 "rename would not change the id of {id} — new title {new_title:?} produces the same slug"
513 ),
514 ),
515 EngineError::WikiLinkWithoutRelation { from_id, missing } => {
516 let message = display;
517 let payload = serde_json::json!({
518 "code": "WIKILINK_WITHOUT_RELATION",
519 "message": message.clone(),
520 "details": {
521 "from_id": from_id,
522 "missing": missing,
523 },
524 });
525 let text = format!("ERROR [WIKILINK_WITHOUT_RELATION]: {message}");
526 let mut result = CallToolResult::error(vec![ContentBlock::text(text)]);
527 result.structured_content = Some(payload);
528 result
529 }
530 EngineError::RelationHasBodyLinks {
531 from_id,
532 to_id,
533 rel_type,
534 body_links,
535 } => {
536 let message = display;
537 let payload = serde_json::json!({
538 "code": "RELATION_HAS_BODY_LINKS",
539 "message": message.clone(),
540 "details": {
541 "from_id": from_id,
542 "to_id": to_id,
543 "rel_type": rel_type,
544 "body_links": body_links,
545 },
546 });
547 let text = format!("ERROR [RELATION_HAS_BODY_LINKS]: {message}");
548 let mut result = CallToolResult::error(vec![ContentBlock::text(text)]);
549 result.structured_content = Some(payload);
550 result
551 }
552 EngineError::RenamePartialFailure {
553 committed_mems,
554 failed_mem,
555 failure_cause,
556 } => {
557 let message = format!(
558 "rename partial-failure: mem `{failed_mem}` aborted with cause {failure_cause:?} after {committed_mems:?} already committed — reload and retry, or reconcile manually"
559 );
560 let payload = serde_json::json!({
561 "code": "RENAME_PARTIAL_FAILURE",
562 "message": message.clone(),
563 "details": {
564 "committed_mems": committed_mems,
565 "failed_mem": failed_mem,
566 "failure_cause": failure_cause,
567 },
568 });
569 let text = format!("ERROR [RENAME_PARTIAL_FAILURE]: {message}");
570 let mut result = CallToolResult::error(vec![ContentBlock::text(text)]);
571 result.structured_content = Some(payload);
572 result
573 }
574 EngineError::RenameBlockedByCrossMemPolicy {
575 ref from_mem,
576 ref blocked_referrers,
577 } => {
578 let message = err.to_string();
579 let entries: Vec<_> = blocked_referrers
580 .iter()
581 .map(|r| {
582 serde_json::json!({
583 "from_mem": r.from_mem,
584 "to_mem": r.to_mem,
585 "count": r.count,
586 })
587 })
588 .collect();
589 let payload = serde_json::json!({
590 "code": "RENAME_BLOCKED_BY_CROSS_MEM_POLICY",
591 "message": message.clone(),
592 "details": {
593 "from_mem": from_mem,
594 "blocked_referrers": entries,
595 },
596 });
597 let text = format!("ERROR [RENAME_BLOCKED_BY_CROSS_MEM_POLICY]: {message}");
598 let mut result = CallToolResult::error(vec![ContentBlock::text(text)]);
599 result.structured_content = Some(payload);
600 result
601 }
602 EngineError::StubCannotRelate { id } => {
603 let message = format!(
604 "source entity {id} is a stub — promote it to a real entity via memstead_create first"
605 );
606 let payload = serde_json::json!({
607 "code": "STUB_CANNOT_RELATE",
608 "message": message.clone(),
609 "details": { "id": id },
610 });
611 let text = format!("ERROR [STUB_CANNOT_RELATE]: {message}");
612 let mut result = CallToolResult::error(vec![ContentBlock::text(text)]);
613 result.structured_content = Some(payload);
614 result
615 }
616 EngineError::StubNotUpdatable { id } => {
617 let message = format!(
618 "entity {id} is a stub — promote it to a real entity via memstead_create first"
619 );
620 let payload = serde_json::json!({
621 "code": "STUB_NOT_UPDATABLE",
622 "message": message.clone(),
623 "details": { "id": id },
624 });
625 let text = format!("ERROR [STUB_NOT_UPDATABLE]: {message}");
626 let mut result = CallToolResult::error(vec![ContentBlock::text(text)]);
627 result.structured_content = Some(payload);
628 result
629 }
630 EngineError::StubNotRenamable { id } => {
631 let message = format!(
632 "entity {id} is a stub — promote it to a real entity via memstead_create before renaming"
633 );
634 let payload = serde_json::json!({
635 "code": "STUB_NOT_RENAMABLE",
636 "message": message.clone(),
637 "details": { "id": id },
638 });
639 let text = format!("ERROR [STUB_NOT_RENAMABLE]: {message}");
640 let mut result = CallToolResult::error(vec![ContentBlock::text(text)]);
641 result.structured_content = Some(payload);
642 result
643 }
644 EngineError::InvalidEntityId { id, reason } => {
645 let message = format!("entity id '{id}' is malformed: {reason}");
646 tool_error_with_details(
647 "INVALID_ENTITY_ID",
648 &message,
649 Some(serde_json::json!({ "id": id, "reason": reason })),
650 )
651 }
652 EngineError::InvalidWikiLinkTarget {
653 raw,
654 suggested,
655 section,
656 link_source,
657 reason,
658 } => {
659 let message = format!(
660 "body wiki-link target '{raw}' in section '{section}' is not slug-form: {reason}"
661 );
662 tool_error_with_details(
663 "INVALID_WIKI_LINK_TARGET",
664 &message,
665 Some(serde_json::json!({
666 "raw": raw,
667 "suggested": suggested,
668 "section": section,
669 "source": link_source,
670 "reason": reason,
671 })),
672 )
673 }
674 EngineError::InvalidWikiLinkMem {
675 raw,
676 section,
677 reason,
678 } => {
679 let message = format!(
680 "body wiki-link mem prefix '{raw}' in section '{section}' is not a valid mem name: {reason}"
681 );
682 tool_error_with_details(
683 "INVALID_MEM_NAME",
684 &message,
685 Some(serde_json::json!({
686 "raw": raw,
687 "section": section,
688 "reason": reason,
689 })),
690 )
691 }
692 EngineError::ConflictingSectionModes { section, modes } => {
693 let message =
694 format!("section {section:?} appears in multiple mutation modes: {modes:?}");
695 tool_error_with_details(
696 "CONFLICTING_SECTION_MODES",
697 &message,
698 Some(serde_json::json!({ "section": section, "modes": modes })),
699 )
700 }
701 EngineError::RelationshipCycle {
702 rel_type,
703 from,
704 to,
705 existing_path,
706 path_truncated,
707 } => {
708 let existing_path_json: Vec<String> =
709 existing_path.iter().map(|id| id.to_string()).collect();
710 let message = format!(
711 "creating edge {rel_type} from '{from}' to '{to}' would close a cycle in the {rel_type} subgraph"
712 );
713 tool_error_with_details(
714 "RELATIONSHIP_CYCLE",
715 &message,
716 Some(serde_json::json!({
717 "rel_type": rel_type,
718 "from": from.to_string(),
719 "to": to.to_string(),
720 "existing_path": existing_path_json,
721 "path_truncated": path_truncated,
722 })),
723 )
724 }
725 EngineError::SetAndUnsetConflict { keys } => {
726 let message = format!("metadata keys appear in both set and unset: {keys:?}");
727 tool_error_with_details(
728 "SET_AND_UNSET_CONFLICT",
729 &message,
730 Some(serde_json::json!({ "keys": keys })),
731 )
732 }
733 EngineError::RequiredFieldUnset {
734 field,
735 entity_type,
736 field_description,
737 enum_values,
738 type_write_rules,
739 on_create,
740 missing,
741 } => {
742 let message = if on_create {
748 format!(
749 "required metadata field '{field}' not provided — type '{entity_type}' declares the field as required and has no default for it"
750 )
751 } else {
752 format!("cannot unset required field '{field}' for type '{entity_type}'")
753 };
754 let missing_json: Vec<_> = missing
757 .iter()
758 .map(|m| {
759 serde_json::json!({
760 "field": m.key,
761 "description": m.description,
762 "enum_values": m.enum_values,
763 "write_rules": type_write_rules,
764 })
765 })
766 .collect();
767 tool_error_with_details(
768 "REQUIRED_FIELD_UNSET",
769 &message,
770 Some(serde_json::json!({
771 "field": field,
772 "entity_type": entity_type,
773 "field_description": field_description,
774 "enum_values": enum_values,
775 "type_write_rules": type_write_rules,
776 "missing": missing_json,
777 })),
778 )
779 }
780 EngineError::MissingRequiredSection {
781 entity_type,
782 missing_count,
783 sections,
784 type_guidance,
785 } => {
786 let message =
787 format!("missing {missing_count} required section(s) for type '{entity_type}'");
788 let sections_json: Vec<_> = sections
789 .iter()
790 .map(|s| {
791 serde_json::json!({
792 "entity_type": s.entity_type,
793 "key": s.key,
794 "heading": s.heading,
795 "write_rules": s.write_rules,
796 })
797 })
798 .collect();
799 tool_error_with_details(
800 "MISSING_REQUIRED_SECTION",
801 &message,
802 Some(serde_json::json!({
803 "entity_type": entity_type,
804 "missing_count": missing_count,
805 "sections": sections_json,
806 "type_guidance": type_guidance,
807 })),
808 )
809 }
810 EngineError::PatchSectionEmpty { section } => tool_error(
811 "PATCH_SECTION_EMPTY",
812 &format!("patch target section is empty: {section}"),
813 ),
814 EngineError::PatchOldNotFound {
815 section,
816 current_content,
817 truncated,
818 } => {
819 let message = format!("patch `old` substring not found in {section}");
820 tool_error_with_details(
821 "PATCH_OLD_NOT_FOUND",
822 &message,
823 Some(serde_json::json!({
824 "section": section,
825 "current_content": current_content,
826 "truncated": truncated,
827 })),
828 )
829 }
830 EngineError::Backend(e) => tool_error("MEM_ERROR", &e.to_string()),
836 EngineError::ParseAfterWrite(e) => {
837 tool_error("PARSE_ERROR", &format!("parse-after-write failed: {e}"))
838 }
839 EngineError::Parse(e) => tool_error("PARSE_ERROR", &e.to_string()),
840 EngineError::Validation(v) => validation_envelope(v),
841 ref err @ EngineError::MissingRequiredDescription {
855 ref rel_type,
856 ref from_id,
857 ref to_id,
858 } => tool_error_with_details(
859 "MISSING_REQUIRED_DESCRIPTION",
860 &err.to_string(),
861 Some(serde_json::json!({
862 "rel_type": rel_type,
863 "from_id": from_id,
864 "to_id": to_id,
865 })),
866 ),
867 ref err @ EngineError::DescriptionNotPermitted {
868 ref rel_type,
869 ref from_id,
870 ref to_id,
871 } => tool_error_with_details(
872 "DESCRIPTION_NOT_PERMITTED",
873 &err.to_string(),
874 Some(serde_json::json!({
875 "rel_type": rel_type,
876 "from_id": from_id,
877 "to_id": to_id,
878 })),
879 ),
880 ref err @ EngineError::RelationManualAuthoringForbidden {
881 ref rel_type,
882 ref from_id,
883 ref to_id,
884 ref guidance,
885 } => tool_error_with_details(
886 "RELATION_MANUAL_AUTHORING_FORBIDDEN",
887 &err.to_string(),
888 Some(serde_json::json!({
889 "rel_type": rel_type,
890 "from_id": from_id,
891 "to_id": to_id,
892 "guidance": guidance,
893 })),
894 ),
895 e @ EngineError::SchemaNotFound { .. }
896 | e @ EngineError::EmbeddedSchemaInvalid { .. }
897 | e @ EngineError::SchemaPackageInvalid { .. }
898 | e @ EngineError::SchemaResolverInit(_)
899 | e @ EngineError::DuplicateMem(_)
900 | e @ EngineError::MemQuarantined { .. }
901 | e @ EngineError::UnknownMem(_)
902 | e @ EngineError::UnknownRef(_)
903 | e @ EngineError::UnknownRemote(_)
904 | e @ EngineError::LocalDivergence { .. }
905 | e @ EngineError::NonFastForward { .. }
906 | e @ EngineError::LocalInvalidState { .. }
907 | e @ EngineError::SchemaViolationInFetch { .. }
908 | e @ EngineError::PushedCommitsProtected { .. }
909 | e @ EngineError::BranchResetHeadMoved { .. }
910 | e @ EngineError::ReadOnlyMount(_)
911 | e @ EngineError::CheckNotRecorded { .. }
912 | e @ EngineError::Mem(_)
913 | e @ EngineError::MemNameCollision { .. }
914 | e @ EngineError::InvalidInput(_) => tool_error(e.code(), &e.to_string()),
915 EngineError::RenameSimilarityOutOfRange {
916 requested,
917 allowed_min,
918 allowed_max,
919 } => tool_error_with_details(
920 "INVALID_INPUT",
921 &format!(
922 "rename_similarity {requested} outside allowed range [{allowed_min}, {allowed_max}]"
923 ),
924 Some(serde_json::json!({
925 "field": "rename_similarity",
926 "requested": requested,
927 "allowed_range": [allowed_min, allowed_max],
928 })),
929 ),
930 EngineError::MemConfigIncomplete {
931 mem,
932 missing_fields,
933 } => {
934 let message = format!(
935 "mem `{mem}` config is missing required field(s) {missing_fields:?} — \
936 set via `memstead mem set-version {mem} <version>` (e.g. 0.1.0)"
937 );
938 tool_error_with_details(
939 "MEM_CONFIG_INCOMPLETE",
940 &message,
941 Some(serde_json::json!({
942 "mem": mem,
943 "missing_fields": missing_fields,
944 "set_via": format!("memstead mem set-version {mem} <version>"),
945 })),
946 )
947 }
948 EngineError::SearchUnavailable => tool_error_with_details(
949 "SEARCH_UNAVAILABLE_IN_WASM",
950 &display,
951 Some(serde_json::json!({})),
952 ),
953 ref err @ EngineError::MarkdownExportUnsupportedBackend {
959 ref mem,
960 ref active_backend,
961 ref supported_backends,
962 } => tool_error_with_details(
963 "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND",
964 &err.to_string(),
965 Some(serde_json::json!({
966 "mem": mem,
967 "active_backend": active_backend,
968 "supported_backends": supported_backends,
969 })),
970 ),
971 ref err @ EngineError::EmptyUpdate { ref id } => tool_error_with_details(
972 "EMPTY_UPDATE",
973 &err.to_string(),
974 Some(serde_json::json!({
975 "id": id,
976 "recognised_keys": [
977 "sections", "append_sections", "patch_sections",
978 "metadata", "metadata_unset", "declare_relations",
979 ],
980 })),
981 ),
982 ref err @ EngineError::InvalidChangesCursor { ref mem, ref since } => {
987 tool_error_with_details(
988 "INVALID_CURSOR",
989 &err.to_string(),
990 Some(serde_json::json!({ "mem": mem, "since": since })),
991 )
992 }
993 ref err @ EngineError::ReviewMarkNotSet { ref mem } => tool_error_with_details(
995 "REVIEW_MARK_NOT_SET",
996 &err.to_string(),
997 Some(serde_json::json!({ "mem": mem })),
998 ),
999 ref err @ EngineError::InvalidAnchor(ref anchor_err) => tool_error_with_details(
1002 memstead_base::anchor::INVALID_ANCHOR_CODE,
1003 &err.to_string(),
1004 Some(serde_json::Value::Object(
1005 anchor_err.detail().into_iter().collect(),
1006 )),
1007 ),
1008 }
1009}
1010
1011fn validation_envelope(err: memstead_base::runtime_validator::ValidationError) -> CallToolResult {
1016 crate::error_envelopes::validation_envelope(err)
1017}
1018
1019#[tool_router(vis = "pub")]
1020impl FilesystemMcpServer {
1021 #[tool(
1022 name = "memstead_entity",
1023 description = "Read one entity as markdown (filesystem-mem flavour). Same JSON shape as the mem-repo `memstead_entity`. Frontmatter carries `_hash` (content hash) for optimistic locking on follow-up mutations. Pass `sections` to narrow the rendered body; `include_relations` appends the entity's outgoing and incoming edges; `include_context` appends its community cluster.",
1024 annotations(
1025 read_only_hint = true,
1026 destructive_hint = false,
1027 idempotent_hint = true,
1028 open_world_hint = false
1029 )
1030 )]
1031 fn memstead_entity(&self, Parameters(p): Parameters<EntityParams>) -> CallToolResult {
1032 let engine = crate::lock_engine!(self.engine);
1033 let id = EntityId::canonical(&p.id);
1034 let entity = match engine.get_entity(&id) {
1035 Some(e) => e.clone(),
1036 None => {
1037 if engine.quarantine_reason(id.mem()).is_some() {
1040 return engine_op_error(engine.unknown_mem_error(id.mem()));
1041 }
1042 return tool_error("ENTITY_NOT_FOUND", &format!("entity not found: {id}"));
1043 }
1044 };
1045 let sections_filter = p.sections.as_deref();
1046 let mut md = render_entity_markdown(&entity, sections_filter);
1047 if let Some(idx) = md.find("---\n") {
1050 let inject_at = idx + 4;
1051 let line = format!("_hash: {}\n", entity.content_hash);
1052 md.insert_str(inject_at, &line);
1053 }
1054
1055 if p.include_relations.unwrap_or(false) {
1059 let outgoing = engine.store().outgoing(&id).to_vec();
1060 let incoming = engine.store().incoming(&id).to_vec();
1061 md.push_str(&memstead_base::render::render_relations_markdown(
1062 id.as_ref(),
1063 &outgoing,
1064 &incoming,
1065 ));
1066 }
1067
1068 if p.include_context.unwrap_or(false)
1074 && let Some(ctx) = engine.context(&id)
1075 {
1076 let cluster_id = ctx.community.clone().unwrap_or_else(|| "unknown".into());
1077 md.push_str(&memstead_base::render::render_community_context_section(
1078 &ctx,
1079 &cluster_id,
1080 ));
1081 }
1082
1083 let rendered_body_tokens = memstead_base::chunking::estimate_tokens(&md);
1088 let full_tokens = if sections_filter.is_some() {
1089 let full_body = render_entity_markdown(&entity, None);
1090 Some(memstead_base::chunking::estimate_tokens(&full_body))
1091 } else {
1092 None
1093 };
1094 let mut structured = memstead_base::render::build_entity_envelope(
1095 &entity,
1096 rendered_body_tokens,
1097 full_tokens,
1098 sections_filter,
1099 None,
1100 engine.store().outgoing(&entity.id),
1101 );
1102 if p.include_provenance.unwrap_or(false)
1106 && let Some(obj) = structured.as_object_mut()
1107 {
1108 let block = match engine.entity_provenance(id.mem(), id.as_ref()) {
1109 Ok(prov) => serde_json::to_value(&prov).unwrap_or(serde_json::Value::Null),
1110 Err(e) => serde_json::json!({ "unavailable": e.to_string() }),
1111 };
1112 obj.insert("mutation_provenance".into(), block);
1113 }
1114 md_with_structured(md, structured)
1115 }
1116
1117 #[tool(
1118 name = "memstead_create",
1119 description = "Create a new entity in the filesystem-mem workspace. Required: `title`, `entity_type`. Titles accept any single-line text (control characters such as tab/newline are rejected); the title is stored verbatim as display text, while characters outside Unicode alphanumerics, whitespace, and hyphen are dropped from the derived slug — warning TITLE_CHARS_DROPPED_FROM_SLUG names them (`INVALID_TITLE` refusals remain for control characters, empty-deriving titles, and over-long ids). Optional `sections`, `metadata`, `note`, `mem`. `mem` selects the target mount; omit it to land in the default writable mem (the first writable mount in declaration order). A create aimed at a read-only mount is refused with READ_ONLY_MOUNT. The `note` lands in `.memstead/changes.jsonl` (the filesystem-mem analogue of the mem-repo commit body). `relations` and `dry_run` are not implemented on this surface: passing a non-empty `relations` or `dry_run: true` is REFUSED up front with `UNSUPPORTED_PARAM` (`details.params` names them), never silently ignored — so a `dry_run` preview can never accidentally land a real write. Omit them, or use the unified engine (mem-repo MCP / CLI) which honours both.",
1120 annotations(
1121 read_only_hint = false,
1122 destructive_hint = false,
1123 idempotent_hint = false,
1124 open_world_hint = false
1125 )
1126 )]
1127 fn memstead_create(&self, Parameters(p): Parameters<CreateParams>) -> CallToolResult {
1128 if let Some(err) = reject_unsupported_params(&[
1133 ("dry_run", p.dry_run == Some(true)),
1134 (
1135 "relations",
1136 p.relations.as_ref().is_some_and(|r| !r.is_empty()),
1137 ),
1138 ]) {
1139 return err;
1140 }
1141 let mut engine = crate::lock_engine!(self.engine);
1142 match resolve_role_lean(p.role.as_deref()) {
1143 Ok(r) => engine.set_role(r),
1144 Err(resp) => return *resp,
1145 }
1146 let (actor, client) = self.actor_and_client();
1147 let mem = match p.mem.as_deref() {
1159 Some(v) if !v.is_empty() => v.to_string(),
1160 _ => engine
1161 .default_writable_mem()
1162 .or_else(|| engine.mem_names().into_iter().next())
1163 .map(String::from)
1164 .unwrap_or_default(),
1165 };
1166 let args = CreateEntityArgs {
1167 anchors: p
1168 .anchors
1169 .unwrap_or_default()
1170 .into_iter()
1171 .map(|a| a.into_engine())
1172 .collect(),
1173 mem,
1174 title: p.title,
1175 entity_type: p.entity_type,
1176 sections: p.sections.unwrap_or_default(),
1177 metadata: p.metadata.unwrap_or_default(),
1178 relations: Vec::new(),
1182 dry_run: false,
1186 };
1187 match engine.create_entity(args, actor, client.as_ref(), p.note.as_deref()) {
1188 Ok(outcome) => {
1189 let durable = mem_is_durable(&engine, &outcome.mem);
1194 let body = serde_json::json!({
1195 "id": outcome.id.to_string(),
1196 "title": outcome.title,
1197 "mem": outcome.mem,
1198 "file_path": outcome.file_path,
1199 "_hash": outcome.content_hash,
1200 "commit_sha": outcome.commit_sha,
1201 "durable": durable,
1202 "warnings": outcome.warnings,
1203 "type_guidance": outcome.type_guidance,
1204 });
1205 json_response(&body)
1206 }
1207 Err(e) => engine_op_error(e),
1208 }
1209 }
1210
1211 #[tool(
1212 name = "memstead_update",
1213 description = "Update an existing entity in the filesystem-mem workspace. `expected_hash` (from a previous memstead_entity read) is required — mismatch returns code HASH_MISMATCH with details.current carrying the live hash. This surface honours `sections` (replace) + `metadata` (set) + `metadata_unset` + `declare_relations` + `relations_unset`. The mem-repo `append_sections` / `patch_sections` / `dry_run` shapes are NOT implemented here: passing a non-empty `append_sections` / `patch_sections`, or `dry_run: true`, is REFUSED up front with `UNSUPPORTED_PARAM` (`details.params` names them), never silently ignored — an agent that patches is told its patch was dropped instead of believing it applied. Omit them, or use the unified engine (mem-repo MCP / CLI) which honours all three.",
1214 annotations(
1215 read_only_hint = false,
1216 destructive_hint = false,
1217 idempotent_hint = false,
1218 open_world_hint = false
1219 )
1220 )]
1221 fn memstead_update(&self, Parameters(p): Parameters<UpdateParams>) -> CallToolResult {
1222 if let Some(err) = reject_unsupported_params(&[
1229 ("dry_run", p.dry_run == Some(true)),
1230 (
1231 "append_sections",
1232 p.append_sections.as_ref().is_some_and(|m| !m.is_empty()),
1233 ),
1234 (
1235 "patch_sections",
1236 p.patch_sections.as_ref().is_some_and(|m| !m.is_empty()),
1237 ),
1238 ]) {
1239 return err;
1240 }
1241 let mut engine = crate::lock_engine!(self.engine);
1242 match resolve_role_lean(p.role.as_deref()) {
1243 Ok(r) => engine.set_role(r),
1244 Err(resp) => return *resp,
1245 }
1246 let (actor, client) = self.actor_and_client();
1247 let args = UpdateEntityArgs {
1248 anchors: p
1249 .anchors
1250 .unwrap_or_default()
1251 .into_iter()
1252 .map(|a| a.into_engine())
1253 .collect(),
1254 anchors_unset: p
1255 .anchors_unset
1256 .unwrap_or_default()
1257 .into_iter()
1258 .map(|u| u.into_engine())
1259 .collect(),
1260 relations_unset: p
1261 .relations_unset
1262 .unwrap_or_default()
1263 .into_iter()
1264 .map(|r| memstead_base::ops::RelationUnsetArg {
1265 rel_type: r.rel_type,
1266 target: memstead_base::EntityId(r.target),
1267 })
1268 .collect(),
1269 id: EntityId(p.id),
1270 expected_hash: Some(p.expected_hash),
1271 sections: p.sections.unwrap_or_default(),
1272 append_sections: IndexMap::new(),
1276 patch_sections: IndexMap::new(),
1277 metadata: p.metadata.unwrap_or_default(),
1278 metadata_unset: p.metadata_unset.unwrap_or_default(),
1279 declare_relations: p
1280 .declare_relations
1281 .unwrap_or_default()
1282 .into_iter()
1283 .map(|r| memstead_base::ops::RelateArg {
1284 rel_type: r.r#type,
1285 to: EntityId(r.to),
1286 description: r.description,
1287 })
1288 .collect(),
1289 dry_run: false,
1290 };
1291 match engine.update_entity(args, actor, client.as_ref(), p.note.as_deref()) {
1292 Ok(outcome) => {
1293 let durable = mem_is_durable(&engine, outcome.id.mem());
1294 let body = serde_json::json!({
1295 "id": outcome.id.to_string(),
1296 "file_path": outcome.file_path,
1297 "_hash": outcome.content_hash,
1298 "durable": durable,
1299 "modified_sections": outcome.modified_sections.replaced,
1300 "modified_metadata_set": outcome.modified_metadata.set,
1301 "modified_metadata_unset": outcome.modified_metadata.unset,
1302 "warnings": outcome.warnings,
1306 "orphan_stubs_removed": outcome
1313 .orphan_stubs_removed
1314 .iter()
1315 .map(|i| i.to_string())
1316 .collect::<Vec<_>>(),
1317 });
1318 json_response(&body)
1319 }
1320 Err(e) => engine_op_error(e),
1321 }
1322 }
1323
1324 #[tool(
1325 name = "memstead_delete",
1326 description = "Remove an entity from the filesystem-mem workspace. `expected_hash` is required (read first via memstead_entity); mismatch returns HASH_MISMATCH. Refuses entities with incoming references — v1 has no per-call force toggle on the MCP surface; use `memstead delete --force` on the CLI. The `note` lands in `.memstead/changes.jsonl` (the per-mutation changelog).",
1327 annotations(
1328 read_only_hint = false,
1329 destructive_hint = true,
1330 idempotent_hint = false,
1331 open_world_hint = false
1332 )
1333 )]
1334 fn memstead_delete(&self, Parameters(p): Parameters<DeleteParams>) -> CallToolResult {
1335 let mut engine = crate::lock_engine!(self.engine);
1336 match resolve_role_lean(p.role.as_deref()) {
1337 Ok(r) => engine.set_role(r),
1338 Err(resp) => return *resp,
1339 }
1340 let (actor, client) = self.actor_and_client();
1341 let args = DeleteEntityArgs {
1342 id: EntityId(p.id),
1343 expected_hash: if p.expected_hash.is_empty() {
1348 None
1349 } else {
1350 Some(p.expected_hash)
1351 },
1352 };
1353 match engine.delete_entity(args, actor, client.as_ref(), p.note.as_deref()) {
1354 Ok(outcome) => {
1355 let durable = mem_is_durable(&engine, outcome.id.mem());
1356 let body = serde_json::json!({
1357 "id": outcome.id.to_string(),
1358 "file_path": outcome.file_path,
1359 "removed_incoming": outcome.removed_incoming,
1360 "durable": durable,
1361 "warnings": outcome.warnings,
1364 });
1365 json_response(&body)
1366 }
1367 Err(e) => engine_op_error(e),
1368 }
1369 }
1370
1371 #[tool(
1372 name = "memstead_relate",
1373 description = "Connect or disconnect two entities with a typed relationship in the same filesystem-mem. Cross-mem targets are rejected with CROSS_MEM_RELATION (filesystem-mem is single-mem by design). `remove: true` drops the matching pair if present; otherwise the call appends. No-op paths (already present add, absent remove) succeed silently and do not append a changelog line. `dry_run` is not implemented on this surface: passing `dry_run: true` is REFUSED up front with `UNSUPPORTED_PARAM` (`details.params` names it), never silently ignored — so a rehearsal can never accidentally land a real write. Omit it, or use the unified engine (mem-repo MCP / CLI) which honours it.",
1374 annotations(read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
1379 )]
1380 fn memstead_relate(&self, Parameters(p): Parameters<RelateParams>) -> CallToolResult {
1381 if let Some(refusal) = reject_unsupported_params(&[("dry_run", p.dry_run == Some(true))]) {
1385 return refusal;
1386 }
1387 if p.relations.is_empty() {
1388 return tool_error(
1389 "INVALID_INPUT",
1390 "relations must carry at least one operation",
1391 );
1392 }
1393 let mut engine = crate::lock_engine!(self.engine);
1394 match resolve_role_lean(p.role.as_deref()) {
1395 Ok(r) => engine.set_role(r),
1396 Err(resp) => return *resp,
1397 }
1398 let (actor, client) = self.actor_and_client();
1399 let ops: Vec<(RelateEntityArgs, Option<String>)> = p
1404 .relations
1405 .iter()
1406 .map(|op| {
1407 (
1408 RelateEntityArgs {
1409 source: EntityId::canonical(&op.from),
1410 expected_hash: None,
1411 rel_type: op.r#type.clone(),
1412 target: EntityId::canonical(&op.to),
1413 remove: op.remove.unwrap_or(false),
1414 description: op.description.clone(),
1415 dry_run: false,
1416 },
1417 p.note.clone(),
1418 )
1419 })
1420 .collect();
1421 let anchor_mem = ops[0].0.source.mem().to_string();
1422
1423 if p.relations.len() == 1 {
1427 let (args, note) = {
1428 let mut it = ops.into_iter();
1429 it.next().expect("len checked above")
1430 };
1431 return match engine.relate_entity(args, actor, client.as_ref(), note.as_deref()) {
1432 Ok(outcome) => {
1433 let action = match outcome.action {
1434 RelateAction::Added => "added",
1435 RelateAction::Removed => "removed",
1436 RelateAction::NoOpAlreadyPresent | RelateAction::NoOpAbsent => "noop",
1437 };
1438 let durable = mem_is_durable(&engine, outcome.from.mem());
1439 let body = serde_json::json!({
1440 "results": [{
1441 "from": outcome.from.to_string(),
1442 "to": outcome.to.to_string(),
1443 "rel_type": outcome.rel_type,
1444 "action": action,
1445 "source": outcome.source,
1446 "_hash": outcome.content_hash,
1447 }],
1448 "commit_sha": outcome.commit_sha,
1449 "durable": durable,
1450 "warnings": outcome.warnings,
1451 "orphan_stubs_removed": outcome
1452 .orphan_stubs_removed
1453 .iter()
1454 .map(|i| i.to_string())
1455 .collect::<Vec<_>>(),
1456 });
1457 json_response(&body)
1458 }
1459 Err(e) => engine_op_error(e),
1460 };
1461 }
1462
1463 let absent_targets: std::collections::HashSet<String> = p
1467 .relations
1468 .iter()
1469 .filter(|op| !op.remove.unwrap_or(false))
1470 .map(|op| EntityId::canonical(&op.to))
1471 .filter(|to| engine.store().get(to).is_none())
1472 .map(|to| to.to_string())
1473 .collect();
1474 let result = match engine.batch_relate(ops, actor, client.as_ref(), false) {
1475 Ok(r) => r,
1476 Err(e) => return engine_op_error(e),
1477 };
1478
1479 if !result.applied {
1480 let entries: Vec<serde_json::Value> = result
1484 .results
1485 .iter()
1486 .zip(p.relations.iter())
1487 .enumerate()
1488 .map(|(i, (entry, op))| {
1489 let mut e = serde_json::json!({
1490 "index": i,
1491 "from": op.from,
1492 "to": op.to,
1493 "rel_type": op.r#type,
1494 "action": entry.action,
1495 });
1496 if let Some(err) = &entry.error {
1497 e["code"] = serde_json::json!(err.code);
1498 e["message"] = serde_json::json!(err.message);
1499 e["details"] = err.details.clone();
1500 }
1501 e
1502 })
1503 .collect();
1504 let msg = format!(
1505 "batch refused — {} of {} operation(s) failed, nothing committed",
1506 result.failed,
1507 p.relations.len(),
1508 );
1509 return tool_error_with_details(
1510 "BATCH_REFUSED",
1511 &msg,
1512 Some(serde_json::json!({
1513 "entries": entries,
1514 "failed": result.failed,
1515 "errors_suppressed": result.errors_suppressed,
1516 })),
1517 );
1518 }
1519
1520 let durable = mem_is_durable(&engine, anchor_mem.as_str());
1521 let mut warnings: Vec<memstead_base::ops::WarningHint> = Vec::new();
1522 let entries: Vec<serde_json::Value> = result
1523 .results
1524 .iter()
1525 .zip(p.relations.iter())
1526 .map(|(entry, op)| {
1527 let from = EntityId::canonical(&op.from);
1528 let to = EntityId::canonical(&op.to);
1529 let canonical_type = op.r#type.to_uppercase();
1530 if entry.action == "noop" {
1531 if op.remove.unwrap_or(false) {
1532 warnings.push(memstead_base::ops::WarningHint::NoSuchRelationship {
1533 rel_type: canonical_type.clone(),
1534 from: from.clone(),
1535 to: to.clone(),
1536 });
1537 } else {
1538 warnings.push(memstead_base::ops::WarningHint::DuplicateRelationship {
1539 rel_type: canonical_type.clone(),
1540 from: from.clone(),
1541 to: to.clone(),
1542 });
1543 }
1544 }
1545 if !op.remove.unwrap_or(false)
1546 && absent_targets.contains(&to.to_string())
1547 && engine.store().get(&to).map(|e| e.stub).unwrap_or(false)
1548 {
1549 warnings.push(memstead_base::ops::WarningHint::AutoStubCreated {
1552 stub_id: to.clone(),
1553 pending: false,
1554 });
1555 }
1556 let source_label = engine
1557 .store()
1558 .outgoing(&from)
1559 .iter()
1560 .find(|e| e.target == to && e.rel_type.eq_ignore_ascii_case(&op.r#type))
1561 .map(|e| match e.source {
1562 memstead_base::EdgeSource::BodyLink => "body_link",
1563 memstead_base::EdgeSource::Hierarchy => "hierarchy",
1564 memstead_base::EdgeSource::Explicit => "explicit",
1565 })
1566 .unwrap_or("explicit");
1567 let hash = engine
1568 .store()
1569 .get(&from)
1570 .map(|e| e.content_hash.clone())
1571 .unwrap_or_default();
1572 serde_json::json!({
1573 "from": from.to_string(),
1574 "to": to.to_string(),
1575 "rel_type": canonical_type,
1576 "action": entry.action,
1577 "source": source_label,
1578 "_hash": hash,
1579 })
1580 })
1581 .collect();
1582
1583 let body = serde_json::json!({
1584 "results": entries,
1585 "commit_sha": result.commit_sha,
1586 "durable": durable,
1587 "warnings": warnings,
1588 "orphan_stubs_removed": result
1589 .orphan_stubs_removed
1590 .iter()
1591 .map(|i| i.to_string())
1592 .collect::<Vec<_>>(),
1593 });
1594 json_response(&body)
1595 }
1596
1597 #[tool(
1598 name = "memstead_search",
1599 description = "Search entities by lexical content + structural filters. Same JSON shape as the mem-repo `memstead_search`. The first call after engine init or any mutation pays a one-time search-index build (scales with entity count); subsequent calls reuse the cache. Pass an empty `query: {}` (or omit it) for a metadata-only structural filter — the list shape folds in here. Filters: `mem`, `entity_type`, `edge_type` (first-class engine axes), `stub`, plus `filters: { <field>: <value> }` for any schema-declared `filterable: equality` field (e.g. `{\"level\": \"M0\", \"tags\": \"auth\"}`). Strict type-narrowing: an entity whose type doesn't declare a *filterable* field is excluded (warning `FILTER_TYPE_SCOPED`); a field declared but not filterable on any reachable type is ignored — the result equals the same search without it (warning `FIELD_NOT_FILTERABLE`), never emptied, in both the scoped and unscoped case; a key no schema declares is ignored (`UNKNOWN_FILTER_KEY`). Pagination via `limit` / `offset`. Section bodies are not shipped per hit — read them with `memstead_entity`. A page is bounded to `token_budget` (default 12000): an overflowing page returns the highest-ranked hits that fit with a `SEARCH_RESULTS_TRUNCATED` warning (`kept`/`budget`) while `_total` stays the full count — page on with `offset` or raise `token_budget`.",
1600 annotations(
1601 read_only_hint = true,
1602 destructive_hint = false,
1603 idempotent_hint = true,
1604 open_world_hint = false
1605 )
1606 )]
1607 fn memstead_search(&self, Parameters(p): Parameters<SearchParams>) -> CallToolResult {
1608 let engine = crate::lock_engine!(self.engine);
1609 let filters = p.filters.unwrap_or_default();
1610 let scope = SearchScope {
1611 query: p.query,
1612 mem: p.mem,
1613 entity_type: p.entity_type,
1614 limit: p.limit,
1615 offset: p.offset,
1616 filters,
1617 range_filters: p.range_filters.unwrap_or_default(),
1622 edge_type: p.edge_type,
1623 related_to: p.related_to.map(EntityId),
1624 depth: p.depth,
1625 expand_via: p.expand_via,
1626 expand_depth: p.expand_depth,
1627 direction: p.direction.unwrap_or_default(),
1628 stub: p.stub,
1629 token_budget: p.token_budget,
1630 };
1631 let offset = scope.offset.unwrap_or(0);
1632 let result = match engine.search(&scope) {
1633 Ok(r) => r,
1634 Err(e) => return engine_op_error(e),
1635 };
1636 let md = render_search_markdown(&result, offset);
1637 let envelope = memstead_base::render::build_search_envelope(&result, offset);
1642 let structured = serde_json::to_value(&envelope).unwrap_or(serde_json::Value::Null);
1643 md_with_structured(md, structured)
1644 }
1645
1646 #[tool(
1647 name = "memstead_health",
1648 description = "Return the filesystem-mem workspace's health summary: orphans, stubs, missing required fields, stale entities. Same JSON shape as the mem-repo `memstead_health` (single-mem, so `writable_mems` carries one entry). `include` accepts the shared health key set — today the lean surface dispatches `dangling_links` (matching the mem-repo response shape: `{from, target_id, target_path, section}`) and validates every key against the allowed set, emitting `UNKNOWN_INCLUDE_KEY` on the response's `warnings[]` for typos. `conformance` / `integrity` are dispatched too: `conformance` lints every entity against the effective schema (the pin, or `target_schema` when given) into a `findings` array of `{id, axis, code, detail}` with write-time typed codes; `integrity` adds the consistency axis (DANGLING_LINK, ORPHAN_STUB) to the same list; `anchors` adds per-mem counts of the four standalone anchor-verification states (resolved/drifted/recheck/unresolvable). `constraints` lists standing declared-constraint violations with `severity`. Other detail keys (`orphans`, `stubs`, …) are accepted but the v1 surface returns the full report regardless — narrowing is a follow-up.",
1649 annotations(
1650 read_only_hint = true,
1651 destructive_hint = false,
1652 idempotent_hint = true,
1653 open_world_hint = false
1654 )
1655 )]
1656 fn memstead_health(&self, Parameters(p): Parameters<HealthParams>) -> CallToolResult {
1657 let engine = crate::lock_engine!(self.engine);
1658 let mut health = engine.health();
1659 let include = p.include.unwrap_or_default();
1660
1661 for key in &include {
1665 if !memstead_base::ops::health::HEALTH_INCLUDE_KEYS.contains(&key.as_str()) {
1666 health
1667 .warnings
1668 .push(memstead_base::WarningHint::UnknownIncludeKey {
1669 key: key.clone(),
1670 allowed: memstead_base::ops::health::HEALTH_INCLUDE_KEYS
1671 .iter()
1672 .map(|s| s.to_string())
1673 .collect(),
1674 });
1675 }
1676 }
1677
1678 if include.iter().any(|s| s == "dangling_links") {
1684 let dangling = memstead_base::ops::health::collect_dangling_links(engine.store(), None);
1685 health.dangling_links = Some(dangling);
1686 }
1687
1688 if include
1697 .iter()
1698 .any(|s| s == "conformance" || s == "integrity")
1699 {
1700 let target: Option<memstead_schema::SchemaRef> = match p.target_schema.as_deref() {
1701 None => None,
1702 Some(raw) => match raw.parse::<memstead_schema::SchemaRef>() {
1703 Ok(r) => Some(r),
1704 Err(reason) => {
1705 return tool_error(
1706 "INVALID_INPUT",
1707 &format!("invalid target_schema {raw:?}: {reason}"),
1708 );
1709 }
1710 },
1711 };
1712 let mut mem_names: Vec<String> = engine.schemas().keys().cloned().collect();
1713 mem_names.sort();
1714 let mut findings = Vec::new();
1715 for v in &mem_names {
1716 match engine.conformance_findings(v, target.as_ref()) {
1717 Ok(f) => findings.extend(f),
1718 Err(e) => {
1719 return tool_error(e.code(), &e.to_string());
1720 }
1721 }
1722 if include.iter().any(|s| s == "integrity") {
1723 match engine.consistency_findings(v) {
1724 Ok(f) => findings.extend(f),
1725 Err(e) => {
1726 return tool_error(e.code(), &e.to_string());
1727 }
1728 }
1729 }
1730 }
1731 health.findings = Some(findings);
1732 }
1733
1734 let wants_anchors = include.iter().any(|s| s == "anchors");
1741 let wants_constraints = include.iter().any(|s| s == "constraints");
1742 let wants_friction = include.iter().any(|s| s == "friction");
1743 let wants_open_questions = include.iter().any(|s| s == "open_questions");
1744 let wants_stale_derivations = include.iter().any(|s| s == "stale_derivations");
1745 let wants_checks = include.iter().any(|s| s == "checks");
1746 if wants_anchors
1747 || wants_constraints
1748 || wants_friction
1749 || wants_open_questions
1750 || wants_stale_derivations
1751 || wants_checks
1752 {
1753 let mut value = match serde_json::to_value(&health) {
1754 Ok(v) => v,
1755 Err(e) => return tool_error("INTERNAL", &format!("serialize health: {e}")),
1756 };
1757 if wants_anchors {
1758 value["anchors"] = memstead_base::ops::health::health_anchors_axis(&engine);
1759 }
1760 if wants_constraints {
1761 value["constraints"] = serde_json::to_value(engine.constraint_findings(None))
1762 .unwrap_or(serde_json::Value::Null);
1763 let defects = engine.schema_format_defects();
1764 if !defects.is_empty() {
1765 value["schema_format_defects"] =
1766 serde_json::to_value(defects).unwrap_or(serde_json::Value::Null);
1767 }
1768 }
1769 if wants_friction {
1770 value["friction"] =
1771 memstead_base::friction::FrictionLedger::for_workspace(&self.workspace_root)
1772 .summarize();
1773 }
1774 if wants_open_questions {
1775 value["open_questions"] =
1776 memstead_base::ops::health::health_open_questions_axis(&engine, None);
1777 }
1778 if wants_stale_derivations {
1779 value["stale_derivations"] =
1780 memstead_base::ops::health::health_stale_derivations_axis(&engine, None);
1781 }
1782 if wants_checks {
1783 value["checks"] = memstead_base::ops::health::health_checks_axis(&engine, None);
1784 }
1785 return json_response(&value);
1786 }
1787
1788 json_response(&health)
1789 }
1790
1791 #[tool(
1792 name = "memstead_schema",
1793 description = "Read the workspace's pinned schema as a JSON document — `ref` (canonical `name@version`), `relationship_mode`, the relationship vocabulary, `community`, `used_by[]`, top-level `origin` (`first-party` / `third-party`; a third-party schema is served structural-only with its prose-instruction fields omitted), top-level `alias_target_rel_type` (when authored — the rel-type body wiki-links `[[target]]` auto-emit), and per-type section/field detail. Accepts either `name` (bare name or canonical pin) or `mem` (the workspace's single mem). Passing both is `INVALID_INPUT`. v1 surface returns the engine's pinned schema regardless of which form is used (filesystem-mem is single-mem, single-schema). Default `verbosity` is `\"lite\"` — a cheap cold-start skeleton: entity-type names + section keys + field shapes, relationship names + endpoints, the alias pointer, prose dropped (heavy arrays ship as `types_summary`/`relationships_summary`). Pass `verbosity: \"full\"` for the complete prose payload. An unrecognized `verbosity` returns `INVALID_INPUT`. Returns `ENTITY_NOT_FOUND` when `name` explicitly mismatches the pinned schema; `UNKNOWN_MEM` when `mem` is not the workspace's mem.",
1794 annotations(
1795 read_only_hint = true,
1796 destructive_hint = false,
1797 idempotent_hint = true,
1798 open_world_hint = false
1799 )
1800 )]
1801 fn memstead_schema(&self, Parameters(p): Parameters<SchemaParams>) -> CallToolResult {
1802 let engine = crate::lock_engine!(self.engine);
1803 let Some((mem_name, schema)) = engine.schemas().iter().next() else {
1806 return tool_error(
1814 "INTERNAL",
1815 "engine has no schemas — workspace mount list is empty",
1816 );
1817 };
1818 let pinned_name = &schema.manifest.name;
1819 let pinned_version = schema.version.to_string();
1820 let canon = format!("{pinned_name}@{pinned_version}");
1821
1822 let want_owned: String = match (p.name.as_deref(), p.mem.as_deref()) {
1828 (Some(_), Some(_)) => {
1829 return tool_error(
1830 "INVALID_INPUT",
1831 "memstead_schema accepts exactly one of `name` or `mem`, not both.",
1832 );
1833 }
1834 (Some(name), None) => name.trim().to_string(),
1835 (None, Some(mem)) => {
1836 if mem != mem_name.as_str() {
1837 return tool_error(
1838 "UNKNOWN_MEM",
1839 &format!("unknown mem: {mem:?} — workspace mounts {mem_name:?}"),
1840 );
1841 }
1842 String::new() }
1844 (None, None) => String::new(),
1845 };
1846 let want = want_owned.as_str();
1847 let matches = want.is_empty() || want == pinned_name.as_str() || want == canon.as_str();
1848 if !matches {
1849 return tool_error(
1850 "ENTITY_NOT_FOUND",
1851 &format!("schema not found: {want:?} — workspace pins {canon}"),
1852 );
1853 }
1854
1855 let verbosity = match p.verbosity.as_deref() {
1860 None => memstead_base::render::SchemaVerbosity::Lite,
1861 Some(v) => match memstead_base::render::SchemaVerbosity::from_wire(v) {
1862 Some(sv) => sv,
1863 None => {
1864 return tool_error(
1865 "INVALID_INPUT",
1866 &format!("unknown verbosity: {v:?} — expected \"full\" or \"lite\""),
1867 );
1868 }
1869 },
1870 };
1871
1872 let origin = engine.schema_origin(schema);
1882 let payload = memstead_base::render::build_schema_payload(
1883 schema,
1884 vec![mem_name.to_string()],
1885 verbosity,
1886 origin,
1887 );
1888 json_response(&payload)
1889 }
1890
1891 #[tool(
1892 name = "memstead_diff",
1893 description = "Return a two-ref structural diff at entity granularity. **Filesystem-mem flavour:** folder mounts carry no git refs, so this tool refuses with `INVALID_INPUT` against folder-backed mems. Use the mem-repo flavour for the real diff; the surface stays for cross-flavour clients that hit either server.",
1894 annotations(
1895 read_only_hint = true,
1896 destructive_hint = false,
1897 idempotent_hint = true,
1898 open_world_hint = false
1899 )
1900 )]
1901 fn memstead_diff(&self, Parameters(p): Parameters<DiffParams>) -> CallToolResult {
1902 let engine = crate::lock_engine!(self.engine);
1903 let config = memstead_base::ops::DiffConfig {
1904 rename_similarity: p
1905 .rename_similarity
1906 .unwrap_or(memstead_base::ops::RENAME_SIMILARITY_DEFAULT),
1907 include_content: p.include_content,
1908 include_ripple: p.include_ripple,
1909 };
1910 match engine.diff(&p.mem, &p.ref_a, &p.ref_b, Some(config)) {
1911 Ok(diff) => json_response(&diff),
1912 Err(e) => engine_op_error(e),
1913 }
1914 }
1915
1916 #[tool(
1917 name = "memstead_changes_since",
1918 description = "Read the per-mutation changelog at `.memstead/changes.jsonl` since a given RFC 3339 timestamp. **Diverges from the mem-repo flavour** — filesystem-mem has no commit history, so `since` is a timestamp string (e.g. `\"2026-05-08T15:30:00.000Z\"`) and the response yields the JSONL entries with `ts > since` as a structured array. Pass an empty string or the UNIX epoch (`\"1970-01-01T00:00:00.000Z\"`) for a full dump. The `mem` field is accepted for shape compatibility with the mem-repo flavour but ignored — single-mem. `rename_similarity` and `include_notes` are also accepted but ignored.",
1919 annotations(
1920 read_only_hint = true,
1921 destructive_hint = false,
1922 idempotent_hint = true,
1923 open_world_hint = false
1924 )
1925 )]
1926 fn memstead_changes_since(
1927 &self,
1928 Parameters(p): Parameters<ChangesSinceParams>,
1929 ) -> CallToolResult {
1930 let log_path = self
1933 .workspace_root
1934 .join(memstead_base::MEM_META_DIR)
1935 .join("changes.jsonl");
1936 let raw = match std::fs::read_to_string(&log_path) {
1937 Ok(s) => s,
1938 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
1939 Err(e) => {
1940 return tool_error("CHANGELOG_ERROR", &e.to_string());
1941 }
1942 };
1943
1944 let since = p.since.trim();
1945 let mut entries: Vec<serde_json::Value> = Vec::new();
1946 for line in raw.lines() {
1947 let trimmed = line.trim();
1948 if trimmed.is_empty() {
1949 continue;
1950 }
1951 let value: serde_json::Value = match serde_json::from_str(trimmed) {
1952 Ok(v) => v,
1953 Err(_) => continue, };
1955 let ts_match = value
1956 .get("ts")
1957 .and_then(|v| v.as_str())
1958 .map(|s| s.to_string())
1959 .unwrap_or_default();
1960 if !since.is_empty() && ts_match.as_str() <= since {
1961 continue;
1962 }
1963 entries.push(value);
1964 }
1965 let payload = serde_json::json!({
1966 "since": since,
1967 "count": entries.len(),
1968 "entries": entries,
1969 });
1970 json_response(&payload)
1971 }
1972
1973 #[tool(
1974 name = "memstead_rename",
1975 description = "Rename an entity by changing its title. The slug, id, and on-disk file path follow. Titles accept any single-line text (control characters such as tab/newline are rejected); the title is stored verbatim as display text, while characters outside Unicode alphanumerics, whitespace, and hyphen are dropped from the derived slug — warning TITLE_CHARS_DROPPED_FROM_SLUG names them (`INVALID_TITLE` refusals remain for control characters, empty-deriving titles, and over-long ids). `expected_hash` is required. Atomic referrer rewrite: every Write-Mem entity whose relationships or section bodies point at the old id has its `[[old-slug]]` tokens rewritten in one per-mem commit; ReadOnly referrers leave a residual stub at the old id holding the surviving incoming edges.",
1976 annotations(
1977 read_only_hint = false,
1978 destructive_hint = false,
1979 idempotent_hint = false,
1980 open_world_hint = false
1981 )
1982 )]
1983 fn memstead_rename(&self, Parameters(p): Parameters<RenameParams>) -> CallToolResult {
1984 let mut engine = crate::lock_engine!(self.engine);
1985 match resolve_role_lean(p.role.as_deref()) {
1986 Ok(r) => engine.set_role(r),
1987 Err(resp) => return *resp,
1988 }
1989 let (actor, client) = self.actor_and_client();
1990 let args = RenameEntityArgs {
1991 id: EntityId(p.id),
1992 expected_hash: Some(p.expected_hash),
1993 new_title: p.new_title,
1994 };
1995 match engine.rename_entity(args, actor, client.as_ref(), p.note.as_deref()) {
1996 Ok(outcome) => {
1997 let durable = mem_is_durable(&engine, outcome.new_id.mem());
1998 let body = serde_json::json!({
1999 "old_id": outcome.old_id.to_string(),
2000 "new_id": outcome.new_id.to_string(),
2001 "old_file_path": outcome.old_path,
2002 "new_file_path": outcome.new_path,
2003 "_hash": outcome.content_hash,
2004 "durable": durable,
2005 "warnings": outcome.warnings,
2008 });
2009 json_response(&body)
2010 }
2011 Err(e) => engine_op_error(e),
2012 }
2013 }
2014
2015 #[tool(
2016 name = "memstead_check",
2017 description = "Record a check: \"entity E checked, verdict ok | failed, via method M\" — the engine-recorded act of verification (never a mutation: entity markdown, `_hash`, and the mem's change history are untouched). The record carries the caller-declared `role` plus actor/client identity and the entity's `_hash` at check time, appended to the workspace's append-only check ledger. Derived check state (`never_checked` | `checked_ok` | `check_failed` | `check_stale` — computed by hash comparison, never stamped) is served in `memstead_entity`'s opt-in `mutation_provenance` block and echoed here as `check_state`. Verdict vocabulary is closed (`ok` | `failed`); an unknown verdict refuses `INVALID_VERDICT`. Refuses typed on unknown entity (`ENTITY_NOT_FOUND`), read-only mems (`READ_ONLY_MOUNT`), and persistence failure (`CHECK_NOT_RECORDED`).",
2018 annotations(
2019 read_only_hint = false,
2020 destructive_hint = false,
2021 idempotent_hint = false,
2022 open_world_hint = false
2023 )
2024 )]
2025 fn memstead_check(&self, Parameters(p): Parameters<CheckParams>) -> CallToolResult {
2026 let Some(verdict) = memstead_base::check::Verdict::from_wire(&p.verdict) else {
2027 return tool_error_with_details(
2028 "INVALID_VERDICT",
2029 &format!(
2030 "unknown verdict {:?} — the vocabulary is: {}",
2031 p.verdict,
2032 memstead_base::check::VERDICTS.join(", ")
2033 ),
2034 Some(serde_json::json!({ "allowed": memstead_base::check::VERDICTS })),
2035 );
2036 };
2037 let mut engine = crate::lock_engine!(self.engine);
2038 match resolve_role_lean(p.role.as_deref()) {
2039 Ok(r) => engine.set_role(r),
2040 Err(resp) => return *resp,
2041 }
2042 let (actor, client) = self.actor_and_client();
2043 let id = EntityId(p.entity);
2044 match engine.record_check(
2045 id.mem(),
2046 id.as_ref(),
2047 verdict,
2048 p.method.as_deref(),
2049 actor,
2050 client.as_ref(),
2051 ) {
2052 Ok(record) => {
2053 let (state, _) = match engine.entity_check_state(id.mem(), id.as_ref()) {
2054 Ok(pair) => pair,
2055 Err(e) => return engine_op_error(e),
2056 };
2057 json_response(&serde_json::json!({
2058 "entity": record.entity,
2059 "verdict": record.verdict,
2060 "check_state": state.as_str(),
2061 "role": record.role,
2062 "ts": record.ts,
2063 "method": record.method,
2064 }))
2065 }
2066 Err(e) => engine_op_error(e),
2067 }
2068 }
2069
2070 #[tool(
2071 name = "memstead_overview",
2072 description = "Start here — the cold-start entry point for a Memstead engine. Returns the schema catalogue, the mem inventory, and the community clusters as Markdown. Every visible mem is listed under `## Mems`: a writable mem carries a `durable` flag and `storage` kind (an in-memory sketch reads `durable: false` / `storage: in-memory` — writes are volatile, evicted on session-TTL / restart), and a read-only mount carries `Access: read-only`, its deployment-declared trust `Origin` (`first-party` / `third-party`), and its own entity count. Per-mem counts, `_entity_count`, and the communities section always agree — one rendering authority. Schemas list as `{ref, description}` only — call `memstead_schema(name=<ref>)` for full per-type bodies. Token-budget-driven: hard-required content (mems, schema, community titles) always ships; heavy content greedy-fills the remaining budget by default-priority. Anything that didn't fit is advertised under `## Hints` with `estimated_tokens`; re-query by passing `key` into `include[]`. Allowed `include` keys: `community_members`, `community_bridges`, `mem_distribution`, `dangling_links`. `mem` scopes the roster, schema anchor, and communities to any one visible mem (a read-only mount included); a name matching no visible mem returns `UNKNOWN_MEM` whose list names every visible mem. Set `rebuild: true` to invalidate the community memo before computing — it recomputes the whole-graph Louvain partition (detection is global; there is no per-subgraph scoping). A small or disconnected subgraph may surface as no cluster: sparsely-connected / edge-less nodes collapse into a single catch-all rather than forming their own cluster. This surface carries no mem-lifecycle tools, so the `## Lifecycle Namespaces` section is omitted. Frontmatter `_overview_mode` is \"complete\", \"reduced\", or \"overbudget\"; `_mem_schema` appears only under a `mem` filter; `_workspace_root` is the serving engine's absolute workspace path (omitted for rootless in-memory engines).",
2073 annotations(
2074 read_only_hint = true,
2075 destructive_hint = false,
2076 idempotent_hint = true,
2077 open_world_hint = false
2078 )
2079 )]
2080 fn memstead_overview(&self, Parameters(p): Parameters<OverviewParams>) -> CallToolResult {
2081 let mut engine = crate::lock_engine!(self.engine);
2090 let include = p.include.clone().unwrap_or_default();
2091 let args = memstead_base::overview::OverviewArgs {
2092 include: &include,
2093 mem: p.mem.as_deref(),
2094 rebuild: p.rebuild.unwrap_or(false) && p.chunk.unwrap_or(1) <= 1,
2095 token_budget: p
2096 .token_budget
2097 .unwrap_or(memstead_base::overview::DEFAULT_OVERVIEW_BUDGET),
2098 operator_mode: false,
2099 suppress_lifecycle: true,
2100 };
2101 match memstead_base::overview::compose_overview(
2102 &mut engine,
2103 args,
2104 memstead_base::overview::Surface::Mcp,
2105 ) {
2106 Ok(out) => md_response(out.markdown),
2107 Err(memstead_base::overview::ComposeOverviewError::InvalidIncludeKeySchemaTypes) => {
2108 tool_error(
2109 "INVALID_INPUT",
2110 "include key 'schema_types' was removed; \
2111 call memstead_schema(name=...) for full schema bodies.",
2112 )
2113 }
2114 Err(memstead_base::overview::ComposeOverviewError::MemQuarantined(name)) => {
2115 engine_op_error(engine.unknown_mem_error(&name))
2116 }
2117 Err(memstead_base::overview::ComposeOverviewError::UnknownMem {
2118 name,
2119 writable_mems,
2120 }) => tool_error_with_details(
2121 "UNKNOWN_MEM",
2122 &format!(
2123 "unknown mem: \"{name}\". Visible mems: [{}]",
2124 writable_mems.join(", ")
2125 ),
2126 Some(serde_json::json!({ "name": name, "visible_mems": writable_mems })),
2127 ),
2128 }
2129 }
2130}
2131
2132pub const FS_SERVER_INSTRUCTIONS: &str = concat!(
2138 "Memstead: schema-agnostic graph engine over typed, interconnected markdown entities. Each mem is a typed model of a chosen subject — its modal flavour (knowledge, planning, inquiry, spec, or any mix) follows from the schema the mem pins. Granularity: a mem is the packaged unit — a whole typed model, designed for 1,000-5,000 entities (operating costs measured in docs/sizing-curve.md; larger holdings work at proportionally higher load cost); an entity is never called a mem (a mem is not one 'memory'/fact). Cold-start: call memstead_overview first for the schema catalogue and mem inventory; read a mem's schema via memstead_schema before mutating.",
2139 " Engine version: ",
2140 env!("CARGO_PKG_VERSION"),
2141 " — serverInfo.version carries the same value; a version different from your last session means this surface may have changed: re-read the roster below. Tool roster (complete, 13 tools): READ — memstead_overview (workspace dashboard: schemas, mems, communities, quarantine roster), memstead_entity (one entity + _hash), memstead_search (text + metadata filter), memstead_schema (the pinned schema, lite/full), memstead_health (drift, conformance, quarantine roster, boot diagnosis), memstead_diff (two-ref structural diff), memstead_changes_since (change deltas for incremental sync). WRITE — memstead_create, memstead_update, memstead_relate, memstead_rename, memstead_delete (entity mutations, optimistic _hash locking). PROCESS — memstead_check (record a check of one entity: verdict ok|failed with method note; never a mutation — derived check state serves in memstead_entity's opt-in provenance block). CLI companion: the `memstead` CLI serves this same engine with verb families that deliberately live only there — bulk mutation (batch-create, batch-update, batch-relate: reach for these instead of looping single MCP mutation calls when writing many entities), archive export (export), distribution/registry (publish, unpublish, login, logout, domain), workspace bootstrap and repair (init, quickstart, projection migrate, schema install), and read/report verbs (status, list, context, due — the due-brief: open entities whose schema-declared due date falls inside a window, overdue first). If a task feels like N repetitive single-entity calls, check the CLI first."
2142);
2143
2144#[tool_handler(router = FilesystemMcpServer::tool_router())]
2145impl ServerHandler for FilesystemMcpServer {
2146 fn get_info(&self) -> rmcp::model::ServerInfo {
2153 rmcp::model::ServerInfo::new(
2154 rmcp::model::ServerCapabilities::builder()
2155 .enable_tools()
2156 .build(),
2157 )
2158 .with_server_info(rmcp::model::Implementation::new(
2159 "memstead-mcp",
2160 memstead_base::build_info::full_version(),
2161 ))
2162 .with_instructions(FS_SERVER_INSTRUCTIONS.to_string())
2163 }
2164
2165 async fn initialize(
2166 &self,
2167 request: InitializeRequestParams,
2168 context: RequestContext<RoleServer>,
2169 ) -> Result<InitializeResult, McpError> {
2170 let info = request.client_info.clone();
2171 let cid = ClientId {
2172 name: info.name.clone(),
2173 version: info.version.clone(),
2174 };
2175 let _ = self.client.set(cid);
2176 if context.peer.peer_info().is_none() {
2177 context.peer.set_peer_info(request);
2178 }
2179 Ok(self.get_info())
2180 }
2181
2182 async fn list_tools(
2183 &self,
2184 _request: Option<PaginatedRequestParams>,
2185 _context: RequestContext<RoleServer>,
2186 ) -> Result<ListToolsResult, McpError> {
2187 let tools: Vec<Tool> = Self::tool_router().list_all();
2188 Ok(ListToolsResult {
2189 tools,
2190 meta: None,
2191 next_cursor: None,
2192 })
2193 }
2194
2195 async fn call_tool(
2200 &self,
2201 request: CallToolRequestParams,
2202 context: RequestContext<RoleServer>,
2203 ) -> Result<CallToolResult, McpError> {
2204 let verb = request.name.to_string();
2205 let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
2206 let result = Self::tool_router().call(tcc).await;
2207 if let Ok(r) = &result
2208 && r.is_error.unwrap_or(false)
2209 && let Some(code) = r
2210 .structured_content
2211 .as_ref()
2212 .and_then(|v| v.get("code"))
2213 .and_then(|c| c.as_str())
2214 {
2215 let details = r.structured_content.as_ref().and_then(|v| v.get("details"));
2216 memstead_base::friction::FrictionLedger::for_workspace(&self.workspace_root).record(
2217 "mcp",
2218 &verb,
2219 code,
2220 memstead_base::friction::closed_reason(code, details),
2221 );
2222 }
2223 result
2224 }
2225}
2226
2227#[cfg(test)]
2228mod tests {
2229 use super::*;
2230 use crate::tools::mutation::RelateOpInput;
2231 use indexmap::IndexMap;
2232 use memstead_base::filesystem::config::{WorkspaceConfig, write_workspace_config};
2233 use memstead_schema::SchemaRef;
2234 use rmcp::handler::server::wrapper::Parameters;
2235 use tempfile::TempDir;
2236
2237 #[test]
2244 fn lean_backend_and_parse_after_write_codes_follow_code_contract() {
2245 let cases: Vec<EngineError> = vec![
2246 EngineError::Backend(memstead_base::backend::BackendError::Other("disk".into())),
2247 EngineError::ParseAfterWrite("boom".into()),
2248 ];
2249 for err in cases {
2250 let expected = err.code();
2251 let result = engine_op_error(err);
2252 let code = result
2253 .structured_content
2254 .as_ref()
2255 .and_then(|v| v.get("code"))
2256 .and_then(|c| c.as_str())
2257 .expect("error envelope carries structured.code")
2258 .to_string();
2259 assert_eq!(
2260 code, expected,
2261 "lean error-map code drifted from EngineError::code()"
2262 );
2263 }
2264 }
2265
2266 #[test]
2272 fn relate_annotation_is_idempotent_on_lean() {
2273 let tools = FilesystemMcpServer::tool_router().list_all();
2274 let relate = tools
2275 .iter()
2276 .find(|t| t.name == "memstead_relate")
2277 .expect("memstead_relate is on the lean surface");
2278 let ann = relate
2279 .annotations
2280 .as_ref()
2281 .expect("memstead_relate sets annotation hints");
2282 assert_eq!(
2283 ann.idempotent_hint,
2284 Some(true),
2285 "lean memstead_relate idempotent_hint must match the mem-repo server's `true`"
2286 );
2287 }
2288
2289 fn write_workspace(tmp: &TempDir, name: &str) {
2290 let pin: SchemaRef = "default@1.0.0".parse().unwrap();
2291 let cfg = WorkspaceConfig::new(name, pin.clone());
2292 write_workspace_config(tmp.path(), &cfg).unwrap();
2293 let memstead = tmp.path().join(".memstead");
2297 std::fs::write(
2298 memstead.join("workspace.toml"),
2299 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2300 )
2301 .unwrap();
2302 let workspace = memstead_base::Workspace {
2303 mounts: vec![memstead_base::Mount {
2304 mem: name.to_string(),
2305 schema: Some(pin),
2306 storage: memstead_base::MountStorage::Folder {
2307 path: tmp.path().to_path_buf(),
2308 },
2309 capability: memstead_base::MountCapability::Write,
2310 lifecycle: memstead_base::MountLifecycle::Eager,
2311 cross_linkable: true,
2312 migration_target: None,
2313 }],
2314 settings: memstead_base::WorkspaceSettings::default(),
2315 };
2316 use memstead_base::WorkspaceStoreAdapter;
2317 memstead_base::FileWorkspaceStore::new()
2318 .save_state(tmp.path(), &workspace)
2319 .unwrap();
2320 }
2321
2322 #[test]
2323 fn poisoned_engine_lock_returns_typed_envelope_not_panic() {
2324 let tmp = TempDir::new().unwrap();
2325 write_workspace(&tmp, "demo");
2326 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
2327
2328 let engine = server.engine.clone();
2331 std::thread::spawn(move || {
2332 let _guard = engine.lock().unwrap();
2333 panic!("deliberate poison");
2334 })
2335 .join()
2336 .unwrap_err();
2337
2338 let result = server.memstead_overview(Parameters(OverviewParams {
2339 rebuild: None,
2340 chunk: None,
2341 mem: None,
2342 include: None,
2343 token_budget: None,
2344 }));
2345 assert_eq!(result.is_error, Some(true));
2346 let code = result
2347 .structured_content
2348 .as_ref()
2349 .and_then(|v| v.get("code"))
2350 .and_then(|c| c.as_str())
2351 .unwrap();
2352 assert_eq!(code, "ENGINE_LOCK_POISONED");
2353 }
2354
2355 #[test]
2356 fn create_then_entity_round_trip() {
2357 let tmp = TempDir::new().unwrap();
2358 write_workspace(&tmp, "demo");
2359 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
2360
2361 let mut sections = IndexMap::new();
2364 sections.insert("identity".to_string(), "first identity".to_string());
2365 sections.insert("purpose".to_string(), "first purpose".to_string());
2366 let create_params = CreateParams {
2367 anchors: None,
2368 title: "First".to_string(),
2369 entity_type: "spec".to_string(),
2370 mem: None,
2371 sections: Some(sections),
2372 metadata: None,
2373 relations: None,
2374 dry_run: None,
2375 note: Some("first via mcp".to_string()),
2376 role: None,
2377 };
2378 let create_result = server.memstead_create(Parameters(create_params));
2379 assert!(
2380 !create_result.is_error.unwrap_or(false),
2381 "create must succeed: {:?}",
2382 create_result.structured_content,
2383 );
2384 let create_body = create_result
2385 .structured_content
2386 .as_ref()
2387 .expect("structured content");
2388 let id = create_body["id"].as_str().unwrap().to_string();
2389 assert_eq!(id, "demo--first");
2390
2391 let log =
2393 std::fs::read_to_string(tmp.path().join(".memstead").join("changes.jsonl")).unwrap();
2394 assert!(log.contains("\"note\":\"first via mcp\""));
2395
2396 let entity_params = EntityParams {
2398 id: id.clone(),
2399 sections: None,
2400 include_relations: None,
2401 include_context: None,
2402 token_budget: None,
2403 chunk: None,
2404 include_provenance: None,
2405 };
2406 let entity_result = server.memstead_entity(Parameters(entity_params));
2407 assert!(!entity_result.is_error.unwrap_or(false));
2408 let text = match entity_result.content.first() {
2409 Some(c) => match c.as_text() {
2410 Some(t) => t.text.clone(),
2411 None => panic!("expected text"),
2412 },
2413 None => panic!("expected at least one content"),
2414 };
2415 assert!(text.contains("# First"));
2416 assert!(text.contains("_hash:"));
2417 }
2418
2419 fn two_mount_engine() -> memstead_base::Engine {
2424 use memstead_base::backend::MemBackend;
2425 use memstead_base::storage::InMemoryBackend;
2426 use memstead_base::{Mount, MountCapability, MountLifecycle, MountStorage};
2427 let pin: SchemaRef = "default@1.0.0".parse().unwrap();
2428 let build = |name: &str, cap: MountCapability| -> (Mount, Box<dyn MemBackend>) {
2429 let backend = InMemoryBackend::new();
2430 let cfg = format!(r#"{{"version":"0.1.0","schema":"{pin}"}}"#).into_bytes();
2431 backend.write_mem_config(&cfg).unwrap();
2432 let mount = Mount {
2433 mem: name.to_string(),
2434 schema: Some(pin.clone()),
2435 storage: MountStorage::InMemory,
2436 capability: cap,
2437 lifecycle: MountLifecycle::Eager,
2438 cross_linkable: true,
2439 migration_target: None,
2440 };
2441 (mount, Box::new(backend) as Box<dyn MemBackend>)
2442 };
2443 memstead_base::Engine::from_mounts(vec![
2444 build("sketch", MountCapability::Write),
2445 build("content", MountCapability::ReadOnly),
2446 ])
2447 .unwrap()
2448 }
2449
2450 fn spec_sections() -> Option<IndexMap<String, String>> {
2451 let mut s = IndexMap::new();
2452 s.insert("identity".to_string(), "i".to_string());
2453 s.insert("purpose".to_string(), "p".to_string());
2454 Some(s)
2455 }
2456
2457 fn create_params(title: &str, mem: Option<&str>) -> CreateParams {
2458 CreateParams {
2459 anchors: None,
2460 title: title.to_string(),
2461 entity_type: "spec".to_string(),
2462 mem: mem.map(String::from),
2463 sections: spec_sections(),
2464 metadata: None,
2465 relations: None,
2466 dry_run: None,
2467 note: None,
2468 role: None,
2469 }
2470 }
2471
2472 #[test]
2478 fn create_resolves_target_mem_across_multiple_mounts() {
2479 let server =
2480 FilesystemMcpServer::from_engine(two_mount_engine(), std::path::PathBuf::new());
2481
2482 let r = server.memstead_create(Parameters(create_params("Default Target", None)));
2484 assert!(
2485 !r.is_error.unwrap_or(false),
2486 "omitted-mem create must land in the writable mount: {:?}",
2487 r.structured_content
2488 );
2489 assert_eq!(
2490 r.structured_content.as_ref().unwrap()["id"].as_str(),
2491 Some("sketch--default-target"),
2492 "create defaults to the writable mem, not the read-only one"
2493 );
2494
2495 let r = server.memstead_create(Parameters(create_params("Into Content", Some("content"))));
2497 assert!(
2498 r.is_error.unwrap_or(false),
2499 "write to a read-only mount must refuse"
2500 );
2501 assert_eq!(
2502 r.structured_content.unwrap()["code"],
2503 "READ_ONLY_MOUNT",
2504 "the engine capability layer refuses the read-only target"
2505 );
2506
2507 let r =
2509 server.memstead_create(Parameters(create_params("Explicit Sketch", Some("sketch"))));
2510 assert!(
2511 !r.is_error.unwrap_or(false),
2512 "explicit writable target must land: {:?}",
2513 r.structured_content
2514 );
2515 assert_eq!(
2516 r.structured_content.as_ref().unwrap()["id"].as_str(),
2517 Some("sketch--explicit-sketch")
2518 );
2519 }
2520
2521 #[test]
2526 fn unsupported_write_params_refuse_rather_than_silently_drop() {
2527 use crate::tools::mutation::RelationInput;
2528 let tmp = TempDir::new().unwrap();
2529 write_workspace(&tmp, "demo");
2530 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
2531
2532 let dropped = |r: &CallToolResult| -> Vec<String> {
2533 r.structured_content.as_ref().unwrap()["details"]["params"]
2534 .as_array()
2535 .unwrap()
2536 .iter()
2537 .map(|v| v.as_str().unwrap().to_string())
2538 .collect()
2539 };
2540 let is_unsupported = |r: &CallToolResult| {
2541 r.is_error.unwrap_or(false)
2542 && r.structured_content.as_ref().unwrap()["code"] == "UNSUPPORTED_PARAM"
2543 };
2544
2545 let mut p = create_params("Preview Me", None);
2547 p.dry_run = Some(true);
2548 let r = server.memstead_create(Parameters(p));
2549 assert!(is_unsupported(&r), "dry_run create must refuse: {r:?}");
2550 assert!(dropped(&r).contains(&"dry_run".to_string()));
2551 let entity = server.memstead_entity(Parameters(EntityParams {
2553 id: "demo--preview-me".into(),
2554 include_relations: None,
2555 include_context: None,
2556 sections: None,
2557 token_budget: None,
2558 chunk: None,
2559 include_provenance: None,
2560 }));
2561 assert!(
2562 entity.is_error.unwrap_or(false),
2563 "a refused dry_run must NOT have created the entity"
2564 );
2565
2566 let mut p = create_params("With Edges", None);
2568 p.relations = Some(vec![RelationInput {
2569 to: "demo--target".into(),
2570 r#type: "REFERENCES".into(),
2571 description: None,
2572 }]);
2573 let r = server.memstead_create(Parameters(p));
2574 assert!(is_unsupported(&r));
2575 assert!(dropped(&r).contains(&"relations".to_string()));
2576
2577 let base = || UpdateParams {
2579 anchors: None,
2580 id: "demo--anything".into(),
2581 expected_hash: "deadbeef".into(),
2582 sections: None,
2583 append_sections: None,
2584 patch_sections: None,
2585 metadata: None,
2586 metadata_unset: None,
2587 dry_run: None,
2588 declare_relations: None,
2589 relations_unset: None,
2590 anchors_unset: None,
2591 note: None,
2592 role: None,
2593 };
2594 let mut u = base();
2595 u.append_sections = Some(IndexMap::from([("purpose".to_string(), "x".to_string())]));
2596 let r = server.memstead_update(Parameters(u));
2597 assert!(is_unsupported(&r));
2598 assert!(dropped(&r).contains(&"append_sections".to_string()));
2599
2600 let mut u = base();
2601 u.patch_sections = Some(IndexMap::from([(
2602 "purpose".to_string(),
2603 crate::tools::mutation::PatchInput {
2604 old: "a".into(),
2605 new: "b".into(),
2606 all: None,
2607 },
2608 )]));
2609 let r = server.memstead_update(Parameters(u));
2610 assert!(is_unsupported(&r));
2611 assert!(dropped(&r).contains(&"patch_sections".to_string()));
2612
2613 let mut u = base();
2614 u.dry_run = Some(true);
2615 let r = server.memstead_update(Parameters(u));
2616 assert!(is_unsupported(&r));
2617 assert!(dropped(&r).contains(&"dry_run".to_string()));
2618
2619 let relate = |dry: Option<bool>| {
2623 server.memstead_relate(Parameters(crate::tools::mutation::RelateParams {
2624 relations: vec![crate::tools::mutation::RelateOpInput {
2625 from: "demo--anything".into(),
2626 to: "demo--other".into(),
2627 r#type: "REFERENCES".into(),
2628 remove: None,
2629 description: None,
2630 }],
2631 note: None,
2632 role: None,
2633 dry_run: dry,
2634 }))
2635 };
2636 let r = relate(Some(true));
2637 assert!(is_unsupported(&r), "dry_run relate must refuse: {r:?}");
2638 assert!(dropped(&r).contains(&"dry_run".to_string()));
2639 let r = relate(Some(false));
2643 assert!(
2644 !(r.is_error.unwrap_or(false)
2645 && r.structured_content.as_ref().unwrap()["code"] == "UNSUPPORTED_PARAM"),
2646 "dry_run: false must not refuse: {r:?}"
2647 );
2648 }
2649
2650 #[test]
2655 fn open_questions_axis_is_include_gated_on_lean() {
2656 let tmp = TempDir::new().unwrap();
2657 write_workspace(&tmp, "demo");
2658 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
2659
2660 let plain = server.memstead_health(Parameters(HealthParams::default()));
2661 assert!(!plain.is_error.unwrap_or(false));
2662 assert!(
2663 plain
2664 .structured_content
2665 .as_ref()
2666 .unwrap()
2667 .get("open_questions")
2668 .is_none(),
2669 "axis must be include-gated on lean"
2670 );
2671
2672 let served = server.memstead_health(Parameters(HealthParams {
2673 include: Some(vec!["open_questions".to_string()]),
2674 ..Default::default()
2675 }));
2676 assert!(!served.is_error.unwrap_or(false));
2677 let axis = &served.structured_content.as_ref().unwrap()["open_questions"];
2678 assert_eq!(axis["_item_cap"], 20, "{axis}");
2679 assert_eq!(axis["demo"]["total_open"], 0, "{axis}");
2680 }
2681
2682 #[test]
2687 fn absent_unsupported_params_do_not_refuse() {
2688 let tmp = TempDir::new().unwrap();
2689 write_workspace(&tmp, "demo");
2690 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
2691
2692 let r = server.memstead_create(Parameters(create_params("Plain Create", None)));
2694 assert!(
2695 !r.is_error.unwrap_or(false),
2696 "a default create must not be refused: {r:?}"
2697 );
2698 let mut p = create_params("Explicit No Preview", None);
2700 p.dry_run = Some(false);
2701 let r = server.memstead_create(Parameters(p));
2702 assert!(
2703 !r.is_error.unwrap_or(false),
2704 "dry_run: false must not be refused: {r:?}"
2705 );
2706 }
2707
2708 #[test]
2709 fn create_rejects_unknown_type_with_typed_code() {
2710 let tmp = TempDir::new().unwrap();
2711 write_workspace(&tmp, "demo");
2712 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
2713
2714 let result = server.memstead_create(Parameters(CreateParams {
2715 anchors: None,
2716 title: "X".into(),
2717 entity_type: "totally-not-a-type".into(),
2718 mem: None,
2719 sections: None,
2720 metadata: None,
2721 relations: None,
2722 dry_run: None,
2723 note: None,
2724 role: None,
2725 }));
2726 assert!(result.is_error.unwrap_or(false));
2727 let body = result.structured_content.unwrap();
2728 assert_eq!(body["code"], "UNKNOWN_ENTITY_TYPE");
2729 }
2730
2731 #[test]
2736 fn create_rejects_unknown_section_keys() {
2737 let tmp = TempDir::new().unwrap();
2738 write_workspace(&tmp, "demo");
2739 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
2740
2741 let mut sections = IndexMap::new();
2742 sections.insert("identity".to_string(), "Some text".to_string());
2743 sections.insert("purpose".to_string(), "Other text".to_string());
2744
2745 let result = server.memstead_create(Parameters(CreateParams {
2746 anchors: None,
2747 title: "Stray Memo".into(),
2748 entity_type: "memo".into(),
2749 mem: None,
2750 sections: Some(sections),
2751 metadata: None,
2752 relations: None,
2753 dry_run: None,
2754 note: None,
2755 role: None,
2756 }));
2757 assert!(result.is_error.unwrap_or(false));
2758 let body = result.structured_content.unwrap();
2759 assert_eq!(body["code"], "UNKNOWN_SECTION");
2760 let bad_key = body["details"]["key"].as_str().unwrap();
2762 assert!(
2763 bad_key == "identity" || bad_key == "purpose",
2764 "expected identity/purpose, got {bad_key}"
2765 );
2766 assert!(
2768 !tmp.path().join("stray-memo.md").exists(),
2769 "stray memo should not have been persisted"
2770 );
2771 }
2772
2773 #[test]
2779 fn create_refuses_missing_required_section_with_typed_envelope() {
2780 let tmp = TempDir::new().unwrap();
2787 write_workspace(&tmp, "demo");
2788 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
2789
2790 let result = server.memstead_create(Parameters(CreateParams {
2791 anchors: None,
2792 title: "Empty Memo".into(),
2793 entity_type: "memo".into(),
2794 mem: None,
2795 sections: Some(IndexMap::new()),
2796 metadata: None,
2797 relations: None,
2798 dry_run: None,
2799 note: None,
2800 role: None,
2801 }));
2802 assert!(result.is_error.unwrap_or(false), "create must refuse");
2803 let body = result.structured_content.unwrap();
2804 assert_eq!(body["code"], "MISSING_REQUIRED_SECTION");
2805 assert_eq!(body["details"]["entity_type"], "memo");
2806 assert!(
2807 body["details"]["sections"]
2808 .as_array()
2809 .map_or(0, |s| s.len())
2810 >= 1,
2811 "details.sections must list at least one missing key, got: {body}"
2812 );
2813 assert!(
2814 body["details"]["type_guidance"].is_object(),
2815 "details.type_guidance must be a map, got: {body}"
2816 );
2817 }
2818
2819 #[test]
2823 fn create_rejects_out_of_enum_metadata_value() {
2824 let tmp = TempDir::new().unwrap();
2825 write_workspace(&tmp, "demo");
2826 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
2827
2828 let mut metadata = IndexMap::new();
2829 metadata.insert("level".to_string(), "Z3".to_string());
2830
2831 let result = server.memstead_create(Parameters(CreateParams {
2832 anchors: None,
2833 title: "Bad Level".into(),
2834 entity_type: "spec".into(),
2835 mem: None,
2836 sections: None,
2837 metadata: Some(metadata),
2838 relations: None,
2839 dry_run: None,
2840 note: None,
2841 role: None,
2842 }));
2843 assert!(result.is_error.unwrap_or(false));
2844 let body = result.structured_content.unwrap();
2845 assert_eq!(body["code"], "INVALID_ENUM_VALUE");
2846 assert_eq!(body["details"]["field"], "level");
2847 assert_eq!(body["details"]["value"], "Z3");
2848 }
2849
2850 #[test]
2855 fn create_rejects_unknown_metadata_field() {
2856 let tmp = TempDir::new().unwrap();
2857 write_workspace(&tmp, "demo");
2858 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
2859
2860 let mut metadata = IndexMap::new();
2861 metadata.insert("nonsense".to_string(), "value".to_string());
2862
2863 let result = server.memstead_create(Parameters(CreateParams {
2864 anchors: None,
2865 title: "Stray Field".into(),
2866 entity_type: "spec".into(),
2867 mem: None,
2868 sections: None,
2869 metadata: Some(metadata),
2870 relations: None,
2871 dry_run: None,
2872 note: None,
2873 role: None,
2874 }));
2875 assert!(result.is_error.unwrap_or(false));
2876 let body = result.structured_content.unwrap();
2877 assert_eq!(body["code"], "UNKNOWN_METADATA_FIELD");
2878 assert_eq!(body["details"]["key"], "nonsense");
2879 }
2880
2881 #[test]
2882 fn entity_not_found_returns_typed_code() {
2883 let tmp = TempDir::new().unwrap();
2884 write_workspace(&tmp, "demo");
2885 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
2886
2887 let result = server.memstead_entity(Parameters(EntityParams {
2888 id: "demo--ghost".into(),
2889 sections: None,
2890 include_relations: None,
2891 include_context: None,
2892 token_budget: None,
2893 chunk: None,
2894 include_provenance: None,
2895 }));
2896 assert!(result.is_error.unwrap_or(false));
2897 let body = result.structured_content.unwrap();
2898 assert_eq!(body["code"], "ENTITY_NOT_FOUND");
2899 }
2900
2901 fn seed_via_mcp(server: &FilesystemMcpServer, title: &str) -> (String, String) {
2902 let mut seeded_sections = IndexMap::new();
2908 seeded_sections.insert("identity".to_string(), "seed identity".to_string());
2909 seeded_sections.insert("purpose".to_string(), "seed purpose".to_string());
2910 let result = server.memstead_create(Parameters(CreateParams {
2911 anchors: None,
2912 title: title.into(),
2913 entity_type: "spec".into(),
2914 mem: None,
2915 sections: Some(seeded_sections),
2916 metadata: None,
2917 relations: None,
2918 dry_run: None,
2919 note: None,
2920 role: None,
2921 }));
2922 assert!(
2923 !result.is_error.unwrap_or(false),
2924 "seed_via_mcp must succeed; got error: {:?}",
2925 result.structured_content,
2926 );
2927 let body = result.structured_content.unwrap();
2928 (
2929 body["id"].as_str().unwrap().to_string(),
2930 body["_hash"].as_str().unwrap().to_string(),
2931 )
2932 }
2933
2934 #[test]
2935 fn update_replaces_section_and_returns_new_hash() {
2936 let tmp = TempDir::new().unwrap();
2937 write_workspace(&tmp, "demo");
2938 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
2939 let (id, hash) = seed_via_mcp(&server, "Updatable");
2940
2941 let mut sections = indexmap::IndexMap::new();
2942 sections.insert("identity".to_string(), "Updated body.".to_string());
2943 let result = server.memstead_update(Parameters(UpdateParams {
2944 anchors: None,
2945 relations_unset: None,
2946 anchors_unset: None,
2947 id: id.clone(),
2948 expected_hash: hash.clone(),
2949 sections: Some(sections),
2950 append_sections: None,
2951 patch_sections: None,
2952 metadata: None,
2953 metadata_unset: None,
2954 dry_run: None,
2955 note: Some("touched body".into()),
2956 role: None,
2957 declare_relations: None,
2958 }));
2959 assert!(!result.is_error.unwrap_or(false));
2960 let body = result.structured_content.unwrap();
2961 let new_hash = body["_hash"].as_str().unwrap();
2962 assert_ne!(new_hash, hash);
2963 assert_eq!(body["modified_sections"][0], "identity");
2964 }
2965
2966 #[test]
2967 fn update_rejects_stale_hash_with_typed_code() {
2968 let tmp = TempDir::new().unwrap();
2969 write_workspace(&tmp, "demo");
2970 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
2971 let (id, _hash) = seed_via_mcp(&server, "Pinned");
2972
2973 let result = server.memstead_update(Parameters(UpdateParams {
2974 anchors: None,
2975 relations_unset: None,
2976 anchors_unset: None,
2977 id,
2978 expected_hash: "0000000000".into(),
2979 sections: None,
2980 append_sections: None,
2981 patch_sections: None,
2982 metadata: None,
2983 metadata_unset: None,
2984 dry_run: None,
2985 note: None,
2986 role: None,
2987 declare_relations: None,
2988 }));
2989 assert!(result.is_error.unwrap_or(false));
2990 let body = result.structured_content.unwrap();
2991 assert_eq!(body["code"], "HASH_MISMATCH");
2992 assert!(body["details"]["current"].is_string());
2993 }
2994
2995 #[test]
2999 fn update_rejects_read_only_metadata_set() {
3000 let tmp = TempDir::new().unwrap();
3001 write_workspace(&tmp, "demo");
3002 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3003 let (id, hash) = seed_via_mcp(&server, "Locked");
3004
3005 for field in ["mem", "id", "type"] {
3006 let mut metadata = IndexMap::new();
3007 metadata.insert(field.to_string(), "garbage".to_string());
3008 let result = server.memstead_update(Parameters(UpdateParams {
3009 anchors: None,
3010 relations_unset: None,
3011 anchors_unset: None,
3012 id: id.clone(),
3013 expected_hash: hash.clone(),
3014 sections: None,
3015 append_sections: None,
3016 patch_sections: None,
3017 metadata: Some(metadata),
3018 metadata_unset: None,
3019 dry_run: None,
3020 note: None,
3021 role: None,
3022 declare_relations: None,
3023 }));
3024 assert!(
3025 result.is_error.unwrap_or(false),
3026 "set of {field} should error"
3027 );
3028 let body = result.structured_content.unwrap();
3029 assert_eq!(body["code"], "READ_ONLY_FIELD");
3030 assert_eq!(body["details"]["field"], field);
3031 }
3032 }
3033
3034 #[test]
3041 fn update_allows_reserved_metadata_unset_but_refuses_timestamp_unset() {
3042 let tmp = TempDir::new().unwrap();
3043 write_workspace(&tmp, "demo");
3044 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3045 let (id, hash) = seed_via_mcp(&server, "Unsettable");
3046
3047 let unset_params = |keys: Vec<&str>| {
3048 Parameters(UpdateParams {
3049 anchors: None,
3050 relations_unset: None,
3051 anchors_unset: None,
3052 id: id.clone(),
3053 expected_hash: hash.clone(),
3054 sections: None,
3055 append_sections: None,
3056 patch_sections: None,
3057 metadata: None,
3058 metadata_unset: Some(keys.into_iter().map(String::from).collect()),
3059 dry_run: None,
3060 note: None,
3061 role: None,
3062 declare_relations: None,
3063 })
3064 };
3065
3066 for field in ["type", "mem", "id"] {
3069 let result = server.memstead_update(unset_params(vec![field]));
3070 assert!(
3071 !result.is_error.unwrap_or(false),
3072 "unset of reserved '{field}' must be allowed (repair route)"
3073 );
3074 let body = result.structured_content.unwrap();
3075 assert!(
3076 body["warnings"]
3077 .as_array()
3078 .is_some_and(|w| w.iter().any(|e| e["code"] == "UPDATE_NOOP")),
3079 "healthy-entity reserved unset is a no-op: {body}"
3080 );
3081 }
3082
3083 let result = server.memstead_update(unset_params(vec!["last_modified"]));
3085 assert!(result.is_error.unwrap_or(false));
3086 let body = result.structured_content.unwrap();
3087 assert_eq!(body["code"], "READ_ONLY_FIELD");
3088 assert_eq!(body["details"]["field"], "last_modified");
3089 }
3090
3091 #[test]
3096 fn update_rejects_relationships_section_as_not_updatable() {
3097 let tmp = TempDir::new().unwrap();
3098 write_workspace(&tmp, "demo");
3099 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3100 let (id, hash) = seed_via_mcp(&server, "Sneaky");
3101
3102 let mut sections = IndexMap::new();
3103 sections.insert("relationships".to_string(), "- KIND: target".to_string());
3104 let result = server.memstead_update(Parameters(UpdateParams {
3105 anchors: None,
3106 relations_unset: None,
3107 anchors_unset: None,
3108 id,
3109 expected_hash: hash,
3110 sections: Some(sections),
3111 append_sections: None,
3112 patch_sections: None,
3113 metadata: None,
3114 metadata_unset: None,
3115 dry_run: None,
3116 note: None,
3117 role: None,
3118 declare_relations: None,
3119 }));
3120 assert!(result.is_error.unwrap_or(false));
3121 let body = result.structured_content.unwrap();
3122 let code = body["code"].as_str().unwrap();
3127 assert!(
3128 code == "SECTION_NOT_UPDATABLE" || code == "UNKNOWN_SECTION",
3129 "expected SECTION_NOT_UPDATABLE or UNKNOWN_SECTION, got {code}"
3130 );
3131 }
3132
3133 #[test]
3134 fn delete_removes_entity_and_logs_change() {
3135 let tmp = TempDir::new().unwrap();
3136 write_workspace(&tmp, "demo");
3137 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3138 let (id, hash) = seed_via_mcp(&server, "Doomed");
3139
3140 let result = server.memstead_delete(Parameters(DeleteParams {
3141 id: id.clone(),
3142 expected_hash: hash,
3143 note: Some("retired".into()),
3144 role: None,
3145 }));
3146 assert!(!result.is_error.unwrap_or(false));
3147 let body = result.structured_content.unwrap();
3148 assert_eq!(body["id"], id);
3149
3150 let log =
3151 std::fs::read_to_string(tmp.path().join(".memstead").join("changes.jsonl")).unwrap();
3152 assert!(log.contains("\"kind\":\"delete\""));
3153 assert!(log.contains("\"note\":\"retired\""));
3154 }
3155
3156 #[test]
3157 fn relate_appends_then_no_op_on_duplicate() {
3158 let tmp = TempDir::new().unwrap();
3159 write_workspace(&tmp, "demo");
3160 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3161 let (from, _) = seed_via_mcp(&server, "Source");
3162 let (to, _) = seed_via_mcp(&server, "Target");
3163
3164 let added = server.memstead_relate(Parameters(RelateParams {
3165 relations: vec![RelateOpInput {
3166 from: from.clone(),
3167 to: to.clone(),
3168 r#type: "USES".into(),
3169 remove: None,
3170 description: None,
3171 }],
3172 note: Some("first".into()),
3173 role: None,
3174 dry_run: None,
3175 }));
3176 assert!(!added.is_error.unwrap_or(false));
3177 assert_eq!(
3178 added.structured_content.unwrap()["results"][0]["action"],
3179 "added"
3180 );
3181
3182 let dup = server.memstead_relate(Parameters(RelateParams {
3183 relations: vec![RelateOpInput {
3184 from: from.clone(),
3185 to: to.clone(),
3186 r#type: "USES".into(),
3187 remove: None,
3188 description: None,
3189 }],
3190 note: None,
3191 role: None,
3192 dry_run: None,
3193 }));
3194 assert!(!dup.is_error.unwrap_or(false));
3195 let dup_body = dup.structured_content.unwrap();
3196 assert_eq!(dup_body["results"][0]["action"], "noop");
3197 assert!(
3198 dup_body["warnings"]
3199 .as_array()
3200 .is_some_and(|w| w.iter().any(|x| x["code"] == "DUPLICATE_RELATIONSHIP")),
3201 "duplicate add must warn typed: {dup_body}"
3202 );
3203 }
3204
3205 #[test]
3211 fn relate_rejects_undeclared_rel_type() {
3212 let tmp = TempDir::new().unwrap();
3213 write_workspace(&tmp, "demo");
3214 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3215 let (from, _) = seed_via_mcp(&server, "Source");
3216 let (to, _) = seed_via_mcp(&server, "Target");
3217
3218 let result = server.memstead_relate(Parameters(RelateParams {
3219 relations: vec![RelateOpInput {
3220 from: from.clone(),
3221 to: to.clone(),
3222 r#type: "TOTALLY_MADE_UP".into(),
3223 remove: None,
3224 description: None,
3225 }],
3226 note: None,
3227 role: None,
3228 dry_run: None,
3229 }));
3230 assert!(result.is_error.unwrap_or(false));
3231 let body = result.structured_content.unwrap();
3232 assert_eq!(body["code"], "INVALID_REL_TYPE");
3233 assert_eq!(body["details"]["input"], "TOTALLY_MADE_UP");
3234 let allowed = body["details"]["allowed"]
3235 .as_array()
3236 .expect("allowed should be array");
3237 assert!(!allowed.is_empty(), "allowed[] must list real edges");
3238 }
3239
3240 #[test]
3241 fn relate_rejects_cross_mem_target() {
3242 let tmp = TempDir::new().unwrap();
3243 write_workspace(&tmp, "demo");
3244 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3245 let (from, _) = seed_via_mcp(&server, "Source");
3246
3247 let result = server.memstead_relate(Parameters(RelateParams {
3248 relations: vec![RelateOpInput {
3249 from: from.clone(),
3250 to: "other--thing".into(),
3251 r#type: "USES".into(),
3252 remove: None,
3253 description: None,
3254 }],
3255 note: None,
3256 role: None,
3257 dry_run: None,
3258 }));
3259 assert!(result.is_error.unwrap_or(false));
3260 assert_eq!(
3261 result.structured_content.unwrap()["code"],
3262 "CROSS_MEM_LINK_NOT_ALLOWED"
3263 );
3264 }
3265
3266 #[test]
3267 fn search_with_empty_query_returns_seeded_entities() {
3268 let tmp = TempDir::new().unwrap();
3269 write_workspace(&tmp, "demo");
3270 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3271 for title in ["Alpha", "Beta", "Gamma"] {
3272 seed_via_mcp(&server, title);
3273 }
3274
3275 let result = server.memstead_search(Parameters(SearchParams {
3276 query: None,
3277 mem: None,
3278 entity_type: None,
3279 expand_via: None,
3280 expand_depth: None,
3281 related_to: None,
3282 depth: None,
3283 edge_type: None,
3284 limit: None,
3285 offset: None,
3286 filters: None,
3287 range_filters: None,
3288 stub: Some(false),
3289 token_budget: None,
3290 direction: None,
3291 }));
3292 assert!(!result.is_error.unwrap_or(false));
3293 let md = result
3294 .content
3295 .iter()
3296 .filter_map(|c| c.as_text().map(|t| t.text.as_str()))
3297 .collect::<Vec<_>>()
3298 .join("\n");
3299 assert!(md.contains("_total: 3"), "expected _total: 3 in: {md}");
3300 for title in ["Alpha", "Beta", "Gamma"] {
3301 assert!(md.contains(title), "expected title {title} in: {md}");
3302 }
3303 }
3304
3305 #[test]
3310 fn search_range_filter_malformed_key_surfaces_typed_warning() {
3311 let tmp = TempDir::new().unwrap();
3312 write_workspace(&tmp, "demo");
3313 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3314 seed_via_mcp(&server, "Seed");
3315
3316 let mut range_filters = std::collections::HashMap::new();
3317 range_filters.insert("malformedkey".to_string(), "10".to_string());
3318
3319 let result = server.memstead_search(Parameters(SearchParams {
3320 query: None,
3321 mem: None,
3322 entity_type: None,
3323 expand_via: None,
3324 expand_depth: None,
3325 related_to: None,
3326 depth: None,
3327 edge_type: None,
3328 limit: None,
3329 offset: None,
3330 filters: None,
3331 range_filters: Some(range_filters),
3332 stub: Some(false),
3333 token_budget: None,
3334 direction: None,
3335 }));
3336 let sc = result
3337 .structured_content
3338 .as_ref()
3339 .expect("range-filter warning must ride on structured_content");
3340 let warnings = sc["warnings"]
3341 .as_array()
3342 .expect("warnings array on the structured envelope");
3343 assert!(
3344 warnings
3345 .iter()
3346 .any(|w| w["code"] == "RANGE_FILTER_KEY_MALFORMED"),
3347 "expected RANGE_FILTER_KEY_MALFORMED warning, got: {warnings:?}",
3348 );
3349 }
3350
3351 #[test]
3354 fn search_range_filter_unknown_field_surfaces_typed_warning() {
3355 let tmp = TempDir::new().unwrap();
3356 write_workspace(&tmp, "demo");
3357 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3358 seed_via_mcp(&server, "Seed");
3359
3360 let mut range_filters = std::collections::HashMap::new();
3361 range_filters.insert("min_fake_field".to_string(), "10".to_string());
3362
3363 let result = server.memstead_search(Parameters(SearchParams {
3364 query: None,
3365 mem: None,
3366 entity_type: Some("spec".to_string()),
3367 expand_via: None,
3368 expand_depth: None,
3369 related_to: None,
3370 depth: None,
3371 edge_type: None,
3372 limit: None,
3373 offset: None,
3374 filters: None,
3375 range_filters: Some(range_filters),
3376 stub: Some(false),
3377 token_budget: None,
3378 direction: None,
3379 }));
3380 let sc = result.structured_content.as_ref().unwrap();
3381 let warnings = sc["warnings"].as_array().unwrap();
3382 assert!(
3383 warnings
3384 .iter()
3385 .any(|w| w["code"] == "UNKNOWN_RANGE_FILTER_FIELD"),
3386 "expected UNKNOWN_RANGE_FILTER_FIELD warning, got: {warnings:?}",
3387 );
3388 }
3389
3390 #[test]
3395 fn search_range_filter_field_not_range_filterable_surfaces_typed_warning() {
3396 let tmp = TempDir::new().unwrap();
3397 write_workspace(&tmp, "demo");
3398 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3399 seed_via_mcp(&server, "Seed");
3400
3401 let mut range_filters = std::collections::HashMap::new();
3402 range_filters.insert("min_level".to_string(), "M0".to_string());
3403
3404 let result = server.memstead_search(Parameters(SearchParams {
3405 query: None,
3406 mem: None,
3407 entity_type: Some("spec".to_string()),
3408 expand_via: None,
3409 expand_depth: None,
3410 related_to: None,
3411 depth: None,
3412 edge_type: None,
3413 limit: None,
3414 offset: None,
3415 filters: None,
3416 range_filters: Some(range_filters),
3417 stub: Some(false),
3418 token_budget: None,
3419 direction: None,
3420 }));
3421 let sc = result.structured_content.as_ref().unwrap();
3422 let warnings = sc["warnings"].as_array().unwrap();
3423 assert!(
3424 warnings
3425 .iter()
3426 .any(|w| w["code"] == "FIELD_NOT_RANGE_FILTERABLE"),
3427 "expected FIELD_NOT_RANGE_FILTERABLE warning, got: {warnings:?}",
3428 );
3429 }
3430
3431 #[test]
3434 fn search_without_range_filters_produces_no_range_warnings() {
3435 let tmp = TempDir::new().unwrap();
3436 write_workspace(&tmp, "demo");
3437 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3438 seed_via_mcp(&server, "Seed");
3439
3440 let result = server.memstead_search(Parameters(SearchParams {
3441 query: None,
3442 mem: None,
3443 entity_type: None,
3444 expand_via: None,
3445 expand_depth: None,
3446 related_to: None,
3447 depth: None,
3448 edge_type: None,
3449 limit: None,
3450 offset: None,
3451 filters: None,
3452 range_filters: None,
3453 stub: Some(false),
3454 token_budget: None,
3455 direction: None,
3456 }));
3457 let sc = result.structured_content.as_ref().unwrap();
3458 let warnings = sc["warnings"].as_array().unwrap_or(&Vec::new()).clone();
3459 for w in warnings {
3460 let code = w["code"].as_str().unwrap_or("");
3461 assert!(
3462 !code.starts_with("RANGE_FILTER_")
3463 && code != "UNKNOWN_RANGE_FILTER_FIELD"
3464 && code != "FIELD_NOT_RANGE_FILTERABLE",
3465 "no range-filter warning expected when range_filters omitted, got: {w}",
3466 );
3467 }
3468 }
3469
3470 #[test]
3471 fn search_filters_by_entity_type() {
3472 let tmp = TempDir::new().unwrap();
3473 write_workspace(&tmp, "demo");
3474 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3475 seed_via_mcp(&server, "OnlySpec");
3476
3477 let result = server.memstead_search(Parameters(SearchParams {
3479 query: None,
3480 mem: None,
3481 entity_type: Some("totally-not-a-type".into()),
3482 expand_via: None,
3483 expand_depth: None,
3484 related_to: None,
3485 depth: None,
3486 edge_type: None,
3487 limit: None,
3488 offset: None,
3489 filters: None,
3490 range_filters: None,
3491 stub: Some(false),
3492 token_budget: None,
3493 direction: None,
3494 }));
3495 let md = result
3496 .content
3497 .iter()
3498 .filter_map(|c| c.as_text().map(|t| t.text.as_str()))
3499 .collect::<Vec<_>>()
3500 .join("\n");
3501 assert!(md.contains("_total: 0"), "expected _total: 0 in: {md}");
3502 }
3503
3504 #[test]
3505 fn health_returns_workspace_summary() {
3506 let tmp = TempDir::new().unwrap();
3507 write_workspace(&tmp, "demo");
3508 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3509 seed_via_mcp(&server, "Healthy");
3510
3511 let result = server.memstead_health(Parameters(HealthParams {
3512 include: None,
3513 limit: None,
3514 mem: None,
3515 include_config: false,
3516 target_schema: None,
3517 token_budget: None,
3518 chunk: None,
3519 }));
3520 assert!(!result.is_error.unwrap_or(false));
3521 let body = result.structured_content.unwrap();
3522 assert!(body.is_object());
3526 assert!(!body.as_object().unwrap().is_empty());
3527 }
3528
3529 #[test]
3534 fn search_finds_identifier_shaped_metadata_value() {
3535 let tmp = TempDir::new().unwrap();
3536 write_workspace(&tmp, "demo");
3537 std::fs::write(
3540 tmp.path().join("akte.md"),
3541 "---\ntype: spec\naktenzeichen: 20/54/033\n---\n# Akte\n\n\
3542 ## Identity\n\nDie Akte selbst.\n\n## Purpose\n\nNachweis.\n",
3543 )
3544 .unwrap();
3545 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3546
3547 let result = server.memstead_search(Parameters(SearchParams {
3548 query: Some(memstead_base::ops::Query {
3549 any: vec!["20/54/033".into()],
3550 not: vec![],
3551 phrase: None,
3552 field: None,
3553 }),
3554 direction: None,
3555 mem: None,
3556 entity_type: None,
3557 expand_via: None,
3558 expand_depth: None,
3559 related_to: None,
3560 depth: None,
3561 edge_type: None,
3562 limit: None,
3563 offset: None,
3564 filters: None,
3565 range_filters: None,
3566 stub: None,
3567 token_budget: None,
3568 }));
3569 assert!(!result.is_error.unwrap_or(false), "{result:?}");
3570 let body = result.structured_content.unwrap();
3571 let hits = body["hits"].as_array().expect("hits array");
3572 assert_eq!(hits.len(), 1, "identifier found over MCP: {body}");
3573 assert_eq!(hits[0]["id"], "demo--akte");
3574 assert!(
3575 hits[0]["matched_terms"]
3576 .as_object()
3577 .into_iter()
3578 .flat_map(|m| m.values())
3579 .flat_map(|v| v.as_array().cloned().unwrap_or_default())
3580 .any(|tm| tm["field"] == "metadata"),
3581 "hit identifiable as a metadata match: {}",
3582 hits[0]
3583 );
3584 }
3585
3586 #[test]
3587 fn health_via_new_engine_reflects_post_boot_mutations() {
3588 let tmp = TempDir::new().unwrap();
3601 write_workspace(&tmp, "demo");
3602 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3603
3604 seed_via_mcp(&server, "Post Boot Spec");
3605
3606 let result = server.memstead_search(Parameters(SearchParams {
3614 query: Some(memstead_base::ops::Query {
3615 any: vec!["post-boot".into()],
3616 not: vec![],
3617 phrase: None,
3618 field: None,
3619 }),
3620 direction: None,
3621 mem: None,
3622 entity_type: None,
3623 expand_via: None,
3624 expand_depth: None,
3625 related_to: None,
3626 depth: None,
3627 edge_type: None,
3628 limit: None,
3629 offset: None,
3630 filters: None,
3631 range_filters: None,
3632 stub: None,
3633 token_budget: None,
3634 }));
3635 assert!(!result.is_error.unwrap_or(false));
3636 let text = result
3637 .content
3638 .first()
3639 .unwrap()
3640 .as_text()
3641 .unwrap()
3642 .text
3643 .clone();
3644 assert!(
3645 text.contains("demo--post-boot-spec") || text.contains("Post Boot"),
3646 "post-seed search must surface the seeded entity, got: {text}"
3647 );
3648 }
3649
3650 #[test]
3651 fn schema_returns_pinned_schema_when_name_matches() {
3652 let tmp = TempDir::new().unwrap();
3653 write_workspace(&tmp, "demo");
3654 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3655
3656 for name in ["default", "default@1.0.0"] {
3658 let result = server.memstead_schema(Parameters(SchemaParams {
3659 verbosity: None,
3660 name: Some(name.into()),
3661 mem: None,
3662 }));
3663 assert!(!result.is_error.unwrap_or(false), "name={name:?}");
3664 let body = result.structured_content.unwrap();
3665 assert_eq!(body["ref"], "default@1.0.0");
3669 assert!(body["types_summary"].is_array());
3670 assert!(body.get("types").is_none(), "default is lite, not full");
3671 assert!(body["used_by"].is_array());
3672 assert_eq!(body["used_by"][0], "demo");
3673 }
3674
3675 let result = server.memstead_schema(Parameters(SchemaParams {
3677 verbosity: Some("full".into()),
3678 name: Some("default".into()),
3679 mem: None,
3680 }));
3681 assert!(!result.is_error.unwrap_or(false));
3682 let body = result.structured_content.unwrap();
3683 assert!(body["types"].is_array());
3684
3685 let result = server.memstead_schema(Parameters(SchemaParams {
3687 verbosity: None,
3688 name: None,
3689 mem: Some("demo".into()),
3690 }));
3691 assert!(!result.is_error.unwrap_or(false));
3692 let body = result.structured_content.unwrap();
3693 assert_eq!(body["ref"], "default@1.0.0");
3694 }
3695
3696 #[test]
3701 fn schema_honours_verbosity_toggle() {
3702 let tmp = TempDir::new().unwrap();
3703 write_workspace(&tmp, "demo");
3704 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3705
3706 let lite = server.memstead_schema(Parameters(SchemaParams {
3707 verbosity: Some("lite".into()),
3708 name: None,
3709 mem: Some("demo".into()),
3710 }));
3711 assert!(!lite.is_error.unwrap_or(false));
3712 let lite_body = lite.structured_content.unwrap();
3713 assert!(
3714 lite_body["types_summary"].is_array(),
3715 "lite skeleton present"
3716 );
3717 assert!(lite_body.get("types").is_none(), "lite omits rich types");
3718 assert!(lite_body.get("description").is_none(), "lite drops prose");
3719 assert_eq!(lite_body["ref"], "default@1.0.0");
3720
3721 let unknown = server.memstead_schema(Parameters(SchemaParams {
3722 verbosity: Some("brief".into()),
3723 name: None,
3724 mem: Some("demo".into()),
3725 }));
3726 assert!(
3727 unknown.is_error.unwrap_or(false),
3728 "unknown verbosity refuses"
3729 );
3730 let env = unknown.structured_content.unwrap();
3731 assert_eq!(env["code"], "INVALID_INPUT");
3732 }
3733
3734 #[test]
3735 fn schema_rejects_both_name_and_mem() {
3736 let tmp = TempDir::new().unwrap();
3737 write_workspace(&tmp, "demo");
3738 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3739
3740 let result = server.memstead_schema(Parameters(SchemaParams {
3741 verbosity: None,
3742 name: Some("default".into()),
3743 mem: Some("demo".into()),
3744 }));
3745 assert!(result.is_error.unwrap_or(false));
3746 let body = result.content[0]
3747 .as_text()
3748 .map(|t| t.text.clone())
3749 .unwrap_or_default();
3750 assert!(body.contains("INVALID_INPUT"), "got: {body}");
3751 }
3752
3753 #[test]
3754 fn schema_rejects_unknown_mem() {
3755 let tmp = TempDir::new().unwrap();
3756 write_workspace(&tmp, "demo");
3757 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3758
3759 let result = server.memstead_schema(Parameters(SchemaParams {
3760 verbosity: None,
3761 name: None,
3762 mem: Some("nope".into()),
3763 }));
3764 assert!(result.is_error.unwrap_or(false));
3765 let body = result.content[0]
3766 .as_text()
3767 .map(|t| t.text.clone())
3768 .unwrap_or_default();
3769 assert!(body.contains("UNKNOWN_MEM"), "got: {body}");
3770 }
3771
3772 #[test]
3773 fn schema_rejects_unknown_name_with_typed_code() {
3774 let tmp = TempDir::new().unwrap();
3775 write_workspace(&tmp, "demo");
3776 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3777
3778 let result = server.memstead_schema(Parameters(SchemaParams {
3779 verbosity: None,
3780 name: Some("totally-not-a-schema".into()),
3781 mem: None,
3782 }));
3783 assert!(result.is_error.unwrap_or(false));
3784 let body = result.structured_content.unwrap();
3785 assert_eq!(body["code"], "ENTITY_NOT_FOUND");
3786 }
3787
3788 #[test]
3789 fn changes_since_returns_entries_after_timestamp() {
3790 let tmp = TempDir::new().unwrap();
3791 write_workspace(&tmp, "demo");
3792 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3793
3794 for title in ["A", "B", "C"] {
3796 seed_via_mcp(&server, title);
3797 }
3798
3799 let result = server.memstead_changes_since(Parameters(ChangesSinceParams {
3801 mem: "demo".into(),
3802 since: "".into(),
3803 rename_similarity: None,
3804 include_notes: false,
3805 }));
3806 assert!(!result.is_error.unwrap_or(false));
3807 let body = result.structured_content.unwrap();
3808 assert_eq!(body["count"], 3);
3809 let entries = body["entries"].as_array().unwrap();
3810 assert_eq!(entries.len(), 3);
3811 for entry in entries {
3812 assert_eq!(entry["kind"], "create");
3813 }
3814
3815 let result = server.memstead_changes_since(Parameters(ChangesSinceParams {
3817 mem: "demo".into(),
3818 since: "9999-01-01T00:00:00.000Z".into(),
3819 rename_similarity: None,
3820 include_notes: false,
3821 }));
3822 let body = result.structured_content.unwrap();
3823 assert_eq!(body["count"], 0);
3824 }
3825
3826 #[test]
3827 fn changes_since_handles_missing_changelog_file() {
3828 let tmp = TempDir::new().unwrap();
3831 write_workspace(&tmp, "demo");
3832 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3833 let result = server.memstead_changes_since(Parameters(ChangesSinceParams {
3834 mem: "demo".into(),
3835 since: "".into(),
3836 rename_similarity: None,
3837 include_notes: false,
3838 }));
3839 assert!(!result.is_error.unwrap_or(false));
3840 let body = result.structured_content.unwrap();
3841 assert_eq!(body["count"], 0);
3842 }
3843
3844 #[test]
3845 fn entity_includes_relations_when_flag_set() {
3846 let tmp = TempDir::new().unwrap();
3847 write_workspace(&tmp, "demo");
3848 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3849 let (from, _) = seed_via_mcp(&server, "Source");
3850 let (to, _) = seed_via_mcp(&server, "Target");
3851
3852 server.memstead_relate(Parameters(RelateParams {
3854 relations: vec![RelateOpInput {
3855 from: from.clone(),
3856 to: to.clone(),
3857 r#type: "USES".into(),
3858 remove: None,
3859 description: None,
3860 }],
3861 note: None,
3862 role: None,
3863 dry_run: None,
3864 }));
3865
3866 let result = server.memstead_entity(Parameters(EntityParams {
3868 id: from,
3869 sections: None,
3870 include_relations: Some(true),
3871 include_context: None,
3872 token_budget: None,
3873 chunk: None,
3874 include_provenance: None,
3875 }));
3876 assert!(!result.is_error.unwrap_or(false));
3877 let text = result
3878 .content
3879 .first()
3880 .unwrap()
3881 .as_text()
3882 .unwrap()
3883 .text
3884 .clone();
3885 assert!(text.contains("## Relations"));
3886 }
3887
3888 #[test]
3889 fn entity_includes_context_when_flag_set() {
3890 let tmp = TempDir::new().unwrap();
3891 write_workspace(&tmp, "demo");
3892 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3893 let (id, _) = seed_via_mcp(&server, "Lonely");
3894
3895 let result = server.memstead_entity(Parameters(EntityParams {
3898 id,
3899 sections: None,
3900 include_relations: None,
3901 include_context: Some(true),
3902 token_budget: None,
3903 chunk: None,
3904 include_provenance: None,
3905 }));
3906 assert!(!result.is_error.unwrap_or(false));
3907 let text = result
3908 .content
3909 .first()
3910 .unwrap()
3911 .as_text()
3912 .unwrap()
3913 .text
3914 .clone();
3915 assert!(text.contains("## Community Context"));
3916 }
3917
3918 #[test]
3919 fn search_returns_results_for_text_query() {
3920 let tmp = TempDir::new().unwrap();
3921 write_workspace(&tmp, "demo");
3922 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
3923
3924 let mut secs = indexmap::IndexMap::new();
3929 secs.insert(
3930 "identity".to_string(),
3931 "Discusses the architecture of the universe.".to_string(),
3932 );
3933 secs.insert("purpose".to_string(), "match purpose".to_string());
3934 server.memstead_create(Parameters(CreateParams {
3935 anchors: None,
3936 title: "Match".into(),
3937 entity_type: "spec".into(),
3938 mem: None,
3939 sections: Some(secs),
3940 metadata: None,
3941 relations: None,
3942 dry_run: None,
3943 note: None,
3944 role: None,
3945 }));
3946 let mut other_secs = indexmap::IndexMap::new();
3947 other_secs.insert("identity".to_string(), "other identity".to_string());
3948 other_secs.insert("purpose".to_string(), "other purpose".to_string());
3949 server.memstead_create(Parameters(CreateParams {
3950 anchors: None,
3951 title: "Other".into(),
3952 entity_type: "spec".into(),
3953 mem: None,
3954 sections: Some(other_secs),
3955 metadata: None,
3956 relations: None,
3957 dry_run: None,
3958 note: None,
3959 role: None,
3960 }));
3961
3962 use memstead_base::ops::Query;
3964 let result = server.memstead_search(Parameters(SearchParams {
3965 query: Some(Query {
3966 any: vec!["architecture".into()],
3967 not: vec![],
3968 phrase: None,
3969 field: None,
3970 }),
3971 direction: None,
3972 mem: None,
3973 entity_type: None,
3974 expand_via: None,
3975 expand_depth: None,
3976 related_to: None,
3977 depth: None,
3978 edge_type: None,
3979 limit: None,
3980 offset: None,
3981 filters: None,
3982 range_filters: None,
3983 stub: Some(false),
3984 token_budget: None,
3985 }));
3986 assert!(!result.is_error.unwrap_or(false));
3987 let text = result
3988 .content
3989 .first()
3990 .unwrap()
3991 .as_text()
3992 .unwrap()
3993 .text
3994 .clone();
3995 assert!(text.contains("demo--match") || text.contains("Match"));
4000 assert!(!text.contains("demo--other"));
4001 }
4002
4003 #[test]
4004 fn search_metadata_only_returns_seeded_entities() {
4005 let tmp = TempDir::new().unwrap();
4009 write_workspace(&tmp, "demo");
4010 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
4011 seed_via_mcp(&server, "Alpha");
4012 seed_via_mcp(&server, "Beta");
4013
4014 let result = server.memstead_search(Parameters(SearchParams {
4015 query: None,
4016 mem: None,
4017 entity_type: None,
4018 expand_via: None,
4019 expand_depth: None,
4020 related_to: None,
4021 depth: None,
4022 edge_type: None,
4023 limit: None,
4024 offset: None,
4025 filters: None,
4026 range_filters: None,
4027 stub: Some(false),
4028 token_budget: None,
4029 direction: None,
4030 }));
4031 assert!(!result.is_error.unwrap_or(false));
4032 let text = result
4033 .content
4034 .first()
4035 .unwrap()
4036 .as_text()
4037 .unwrap()
4038 .text
4039 .clone();
4040 assert!(text.contains("demo--alpha") || text.contains("Alpha"));
4042 assert!(text.contains("demo--beta") || text.contains("Beta"));
4043 }
4044
4045 #[test]
4046 fn overview_returns_schema_and_mem_sections() {
4047 let tmp = TempDir::new().unwrap();
4048 write_workspace(&tmp, "demo");
4049 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
4050 seed_via_mcp(&server, "Alpha");
4051 seed_via_mcp(&server, "Beta");
4052
4053 let result = server.memstead_overview(Parameters(OverviewParams {
4054 rebuild: None,
4055 chunk: None,
4056 mem: None,
4057 include: None,
4058 token_budget: None,
4059 }));
4060 assert!(!result.is_error.unwrap_or(false));
4061 let text = result
4062 .content
4063 .first()
4064 .unwrap()
4065 .as_text()
4066 .unwrap()
4067 .text
4068 .clone();
4069 assert!(text.contains("_overview_mode:"));
4076 assert!(text.contains("## Schemas"));
4077 assert!(text.contains("## Mems"));
4078 assert!(text.contains("### demo"));
4079 assert!(text.contains("- **Entities:** 2"));
4080 assert!(text.contains("## Communities"));
4081 assert!(
4082 !text.contains("## Lifecycle Namespaces"),
4083 "lean surface has no mem-lifecycle tools: {text}"
4084 );
4085 assert!(
4086 !text.contains("_mem_schema:"),
4087 "_mem_schema is emitted only under a mem filter: {text}"
4088 );
4089
4090 let scoped = server.memstead_overview(Parameters(OverviewParams {
4092 rebuild: None,
4093 chunk: None,
4094 mem: Some("demo".into()),
4095 include: None,
4096 token_budget: None,
4097 }));
4098 assert!(!scoped.is_error.unwrap_or(false));
4099 let stext = scoped
4100 .content
4101 .first()
4102 .unwrap()
4103 .as_text()
4104 .unwrap()
4105 .text
4106 .clone();
4107 assert!(
4108 stext.contains("_mem_schema: default@1.0.0"),
4109 "a mem-scoped overview anchors the schema: {stext}"
4110 );
4111 }
4112
4113 #[test]
4114 fn overview_rejects_unknown_mem_filter() {
4115 let tmp = TempDir::new().unwrap();
4116 write_workspace(&tmp, "demo");
4117 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
4118
4119 let result = server.memstead_overview(Parameters(OverviewParams {
4120 rebuild: None,
4121 chunk: None,
4122 mem: Some("not-the-mem".into()),
4123 include: None,
4124 token_budget: None,
4125 }));
4126 assert!(result.is_error.unwrap_or(false));
4127 let body = result.structured_content.unwrap();
4128 assert_eq!(body["code"], "UNKNOWN_MEM");
4131 assert_eq!(body["details"]["visible_mems"][0], "demo");
4132 }
4133
4134 #[test]
4135 fn overview_include_community_members_renders_member_ids() {
4136 let tmp = TempDir::new().unwrap();
4137 write_workspace(&tmp, "demo");
4138 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
4139 let (a, _) = seed_via_mcp(&server, "Alpha");
4140 let (b, _) = seed_via_mcp(&server, "Beta");
4141 server.memstead_relate(Parameters(RelateParams {
4143 relations: vec![RelateOpInput {
4144 from: a.clone(),
4145 to: b.clone(),
4146 r#type: "USES".into(),
4147 remove: None,
4148 description: None,
4149 }],
4150 note: None,
4151 role: None,
4152 dry_run: None,
4153 }));
4154
4155 let result = server.memstead_overview(Parameters(OverviewParams {
4156 rebuild: None,
4157 chunk: None,
4158 mem: None,
4159 include: Some(vec!["community_members".into()]),
4160 token_budget: None,
4161 }));
4162 assert!(!result.is_error.unwrap_or(false));
4163 let text = result
4164 .content
4165 .first()
4166 .unwrap()
4167 .as_text()
4168 .unwrap()
4169 .text
4170 .clone();
4171 assert!(text.contains(&format!("- {a}")));
4175 assert!(text.contains(&format!("- {b}")));
4176 }
4177
4178 #[test]
4185 fn overview_dangling_links_surfaces_stub_targets() {
4186 let tmp = TempDir::new().unwrap();
4187 write_workspace(&tmp, "demo");
4188 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
4189 let (id, hash) = seed_via_mcp(&server, "Anchor");
4190
4191 let mut sections = indexmap::IndexMap::new();
4197 sections.insert(
4198 "identity".to_string(),
4199 "Refers to [[gone]] in prose.".to_string(),
4200 );
4201 let upd = server.memstead_update(Parameters(UpdateParams {
4202 anchors: None,
4203 relations_unset: None,
4204 anchors_unset: None,
4205 id: id.clone(),
4206 expected_hash: hash,
4207 sections: Some(sections),
4208 append_sections: None,
4209 patch_sections: None,
4210 metadata: None,
4211 metadata_unset: None,
4212 dry_run: None,
4213 note: Some("seed dangling link".into()),
4214 role: None,
4215 declare_relations: None,
4219 }));
4220 assert!(!upd.is_error.unwrap_or(false), "{upd:?}");
4221
4222 let result = server.memstead_overview(Parameters(OverviewParams {
4223 rebuild: None,
4224 chunk: None,
4225 mem: None,
4226 include: Some(vec!["dangling_links".into()]),
4227 token_budget: None,
4228 }));
4229 assert!(!result.is_error.unwrap_or(false));
4230 let text = result
4231 .content
4232 .first()
4233 .unwrap()
4234 .as_text()
4235 .unwrap()
4236 .text
4237 .clone();
4238 assert!(
4239 text.contains("## Dangling Links"),
4240 "overview must render the Dangling Links section when an opt-in caller finds one: {text}"
4241 );
4242 assert!(
4243 text.contains(&id),
4244 "Dangling Links must name the linking entity ({id}): {text}"
4245 );
4246 assert!(
4247 text.contains("demo--gone"),
4248 "Dangling Links must name the dangling target: {text}"
4249 );
4250 }
4251
4252 #[test]
4253 fn overview_unknown_include_key_emits_warning() {
4254 let tmp = TempDir::new().unwrap();
4255 write_workspace(&tmp, "demo");
4256 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
4257 seed_via_mcp(&server, "Alpha");
4258
4259 let result = server.memstead_overview(Parameters(OverviewParams {
4260 rebuild: None,
4261 chunk: None,
4262 mem: None,
4263 include: Some(vec!["totally-bogus".into()]),
4264 token_budget: None,
4265 }));
4266 assert!(!result.is_error.unwrap_or(false));
4267 let text = result
4268 .content
4269 .first()
4270 .unwrap()
4271 .as_text()
4272 .unwrap()
4273 .text
4274 .clone();
4275 assert!(text.contains("## Warnings"));
4276 assert!(text.contains("UNKNOWN_INCLUDE_KEY"));
4277 }
4278
4279 #[test]
4280 fn overview_rejects_legacy_schema_types_include() {
4281 let tmp = TempDir::new().unwrap();
4282 write_workspace(&tmp, "demo");
4283 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
4284
4285 let result = server.memstead_overview(Parameters(OverviewParams {
4286 rebuild: None,
4287 chunk: None,
4288 mem: None,
4289 include: Some(vec!["schema_types".into()]),
4290 token_budget: None,
4291 }));
4292 assert!(result.is_error.unwrap_or(false));
4293 let body = result.structured_content.unwrap();
4294 assert_eq!(body["code"], "INVALID_INPUT");
4295 }
4296
4297 #[test]
4298 fn rename_changes_id_and_persists_through_disk() {
4299 let tmp = TempDir::new().unwrap();
4300 write_workspace(&tmp, "demo");
4301 let server = FilesystemMcpServer::from_workspace_root(tmp.path()).unwrap();
4302 let (id, hash) = seed_via_mcp(&server, "Old Title");
4303
4304 let result = server.memstead_rename(Parameters(RenameParams {
4305 id,
4306 new_title: "New Title".into(),
4307 expected_hash: hash,
4308 note: Some("renamed".into()),
4309 role: None,
4310 }));
4311 assert!(!result.is_error.unwrap_or(false));
4312 let body = result.structured_content.unwrap();
4313 assert_eq!(body["new_id"], "demo--new-title");
4314 assert_eq!(body["new_file_path"], "new-title.md");
4315 assert!(tmp.path().join("new-title.md").is_file());
4316 assert!(!tmp.path().join("old-title.md").exists());
4317 }
4318}