Skip to main content

memstead_mcp/
filesystem_server.rs

1//! filesystem-mem MCP server — parallel module to [`crate::server`].
2//!
3//! Boots when `memstead-mcp/src/main.rs` runs without the `mem-repo`
4//! feature against a `.memstead/workspace.toml` workspace that carries
5//! only folder + archive mounts (no `mem-repo/.git/`). Wraps the
6//! unified [`memstead_base::Engine`] behind the same rmcp ServerHandler
7//! shape the mem-repo `McpServer` uses, but ports only the subset
8//! of tools that make sense in a single-mem history-free context.
9//!
10//! Per-mutation provenance lands in `.memstead/changes.jsonl` (the
11//! filesystem-mem analogue of the commit-body trailer the
12//! mem-repo server writes).
13
14use 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/// MCP server backed by the unified [`memstead_base::Engine`].
44///
45/// Constructed via [`Self::from_workspace_root`].
46#[derive(Clone)]
47pub struct FilesystemMcpServer {
48    /// Persistent unified engine. Mutations invalidate its memo
49    /// caches via the engine's own hooks; reads see fresh state on
50    /// every lock without needing a re-init from disk.
51    engine: Arc<Mutex<Engine>>,
52    /// Workspace root captured at construction time. Used by
53    /// `memstead_changes_since` (which still reads JSONL directly off
54    /// disk) — the unified engine does not expose a workspace_root
55    /// accessor because mounts can be heterogeneous.
56    workspace_root: PathBuf,
57    /// Captured `clientInfo` from the initialize handshake. Used to
58    /// stamp the changelog `client` field on every mutation. Same
59    /// `OnceLock` shape as `crate::server::McpServer::client`.
60    client: Arc<OnceLock<ClientId>>,
61}
62
63impl FilesystemMcpServer {
64    /// Construct from a workspace root. Boots the unified
65    /// [`Engine`] via [`Engine::from_workspace_root`] (lean path —
66    /// folder + archive backends only). The error envelope wraps
67    /// every layer (layout dispatch, store load, backend
68    /// instantiation, engine construction) under one [`BootError`].
69    ///
70    /// Production callers (main.rs, every test fixture) reach the
71    /// server through this constructor directly.
72    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    /// Construct directly from a pre-built [`Engine`] — e.g. a sealed
83    /// read-only archive mount stood up by an embedding service. `workspace_root`
84    /// is consulted only by `memstead_changes_since` (which reads JSONL off
85    /// disk); surfaces that do not expose that tool may pass any path.
86    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    /// Export the server's single mem as `.mem` archive bytes. Used by
95    /// embedding services (the session server) to hand a visitor a
96    /// self-describing copy of the mem their agent built. The server's
97    /// engine carries exactly one mem (filesystem / session mems are
98    /// single-mem by design); this exports it.
99    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    /// Count of real (non-stub) entities across the server's mem. Used
116    /// by embedding services (the session server) to enforce a
117    /// per-session resource cap before admitting a create.
118    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    /// Run a read closure against the locked engine. The escape hatch for
127    /// embedding services that need engine reads the tool surface does not
128    /// expose — e.g. the session server's live graph projection and its
129    /// change-event subscription. Keeps the engine itself private; callers
130    /// get a borrow only for the duration of `f`.
131    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
147/// Build a typed tool-error envelope. The text channel is
148/// `ERROR [<CODE>]: <message>` (consumers reading only
149/// `result.content[0].text` recover the code with one regex) and the
150/// `structured_content` channel carries `{code, message}` so agents
151/// branching on the structured shape get the typed code without parsing
152/// text. Mirror of `crate::error_envelope::tool_error_with_payload`'s
153/// payload-less shape — the per-flavour symmetry is what makes the
154/// wire-byte contract uniform across lean and full. Pre-fix the text
155/// channel emitted a JSON-stringified `{code, message}` payload; that
156/// form parsed for machine consumers but missed the documented
157/// prefix-form contract.
158fn tool_error(code: &str, message: &str) -> CallToolResult {
159    tool_error_with_details(code, message, None)
160}
161
162/// Same as [`tool_error`] but additionally embeds a structured `details`
163/// payload under `structured_content.details`. Text channel format is
164/// identical (`ERROR [<CODE>]: <message>`); recovery payloads (current
165/// hash, declared sections, referrer list) live exclusively on the
166/// structured channel.
167fn 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
186/// Pair rendered markdown on the text channel with a structured
187/// envelope on `structured_content`:
188/// tools whose response has a canonical human-readable form (entity,
189/// search) ship the markdown to terminal/inline consumers and the
190/// typed JSON to branching agents in one call.
191fn 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
205/// Whether the named mem's storage is durable (persists past restart /
206/// session-TTL eviction), derived from its mount's `MountStorage` kind.
207/// On the ephemeral in-memory sketch this returns `false` — the per-write
208/// `durable` echo on every mutation response is how an agent learns its
209/// `commit_sha` denotes nothing durable. Defaults to `false` for an
210/// unresolvable mem: the engine never claims a durability it can't vouch
211/// for.
212fn 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
221/// Refuse params the filesystem-mem MCP surface does not honour rather
222/// than silently dropping them (Plan 03, Part B). Each `(name, meaningful)`
223/// pair flags a param this surface hardwires off; `meaningful` is true when
224/// the caller passed it with an effect they expect (a non-empty map/list, or
225/// `dry_run: true`). When any such param was meaningfully supplied the call
226/// refuses UP FRONT — before any mutation — with `UNSUPPORTED_PARAM` naming
227/// every dropped param in `details.params`, so an agent can never believe a
228/// no-op succeeded (the worst case being `dry_run: true`, which would
229/// otherwise commit a real write the agent thought was a preview). A
230/// defaulted-empty / absent / `false` param is left alone — the caller
231/// intended no effect, so the call proceeds unchanged (backward-compatible).
232/// Returns `None` when nothing meaningful was dropped.
233/// Resolve a per-call `role` parameter (agent-trust plan 13) on the
234/// lean flavour. No session default here (the lean binary carries no
235/// `--role`); absent records unspecified. Unknown values refuse typed
236/// with the declarable vocabulary named — same contract as the full
237/// flavour.
238fn 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
279/// Map an [`EngineError`] to an MCP error envelope. Codes match the
280/// mem-repo error-code vocabulary (`HASH_MISMATCH`, `ENTITY_NOT_FOUND`,
281/// `ENTITY_ALREADY_EXISTS`, etc.) so agents that handle mem-repo
282/// errors get the same shape here.
283///
284/// Variants that should never trip on a single-mem filesystem-mem
285/// boot path (`DuplicateMem`, `UnknownMem`, `ReadOnlyMount`) still
286/// fall into a generic `INTERNAL` envelope so the wire shape is total —
287/// a future bug that produced one of those wouldn't crash the handler.
288/// Schema-resolution failures (`SchemaNotFound`, `SchemaResolverInit`)
289/// surface as their own typed codes via [`EngineError::code()`] so
290/// callers see the same wire contract here as on the mem-repo server.
291fn engine_op_error(err: EngineError) -> CallToolResult {
292    // Pre-compute the canonical Display string. Variants that take the
293    // engine's Display rendering verbatim use this — variants that build
294    // their own customised message (e.g. stub-aware `HashMismatch`) keep
295    // doing so in-arm.
296    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            // Display names the occupying title; ship the structured
357            // payload too so the lean server matches the full wire.
358            &e.to_string(),
359            Some(e.details()),
360        ),
361        // Block-tier declared-constraint refusals — code and recovery
362        // payload come from the error itself so the lean server ships
363        // the same wire contract as the full server.
364        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            // Stub-aware message — pre-fix code printed `current is `
378            // with an empty trailing value when the entity was a stub
379            // and misdirected toward hash-recovery. Surface
380            // `details.is_stub` and a corrective-action message
381            // ("pass expected_hash: \"\"") for stubs; the prior
382            // contract holds for real entities.
383            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            // Same text-channel format as `tool_error_with_payload`:
400            // `ERROR [<CODE>]: <message>`. Pre-Item-01 this site emitted
401            // a JSON-stringified payload on the text channel.
402            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            // Single-mem filesystem boot path never produces
432            // MemHasIncomingRefs in practice — mem-delete is a
433            // full-only operation. The arm is here for exhaustiveness;
434            // the envelope shape matches the full-side mapping so
435            // wire-byte parity holds if the filesystem flavour ever
436            // gains a mem-delete surface.
437            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            // Path-aware wording — create path renders "not provided"
743            // (caller never supplied the field), update path renders
744            // "cannot unset" (caller asked to remove a required field).
745            // The typed code stays `REQUIRED_FIELD_UNSET` on both
746            // paths so code-key consumers branch unchanged.
747            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            // `details.missing[]` carries every required-no-default
755            // field unset on the create path.
756            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        // Codes follow `EngineError::code()` — the single wire-code
831        // source every surface (full MCP, CLI, wasm) shares. These two
832        // historically drifted (`MEM_WRITER_ERROR` / `PARSE_AFTER_WRITE`);
833        // `lean_backend_and_parse_after_write_codes_follow_code_contract`
834        // pins them to `code()` so a re-divergence fails the build.
835        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        // Schema-resolution, boot-path, and lifecycle variants surface
842        // as their own typed codes so the wire contract matches the
843        // mem-repo server. Pre-fix this set collapsed to `INTERNAL`
844        // — a lean-fireable variant (multi-folder workspaces can trip
845        // DuplicateMem / UnknownMem; cross_mem_links policy can
846        // trip ReadOnlyMount; generic input validation produces
847        // InvalidInput) shipped as INTERNAL instead of its typed code,
848        // breaking the agent contract that the structured code matches
849        // `EngineError::code()`.
850        // Description-posture variants ship structured details so MCP
851        // callers branch on `details.rel_type`/`details.from_id`/
852        // `details.to_id` instead of parsing the message — bit-identical
853        // wire shape with the full-server typed envelope.
854        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        // Typed refusal when
954        // `export_markdown` targets a mem whose active backend
955        // doesn't support markdown regeneration. Filesystem-backed
956        // workspaces don't reach this arm today (single folder mount),
957        // but the variant must be handled to keep the match exhaustive.
958        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        // A bad `since` cursor on `memstead_changes_since`. The folder backend
983        // keys `since` off timestamps rather than commit SHAs, so this
984        // arm isn't reached on a filesystem-mem workspace today, but the
985        // variant must be handled to keep the match exhaustive.
986        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        // Review-mark diff on a markless mem — typed refusal.
994        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        // Malformed `anchors[]` element on create/update: typed
1000        // `INVALID_ANCHOR` with the wrapped anchor error's recovery detail.
1001        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
1011/// Map a runtime [`memstead_base::runtime_validator::ValidationError`] to
1012/// the MCP wire envelope. Thin delegation to the shared
1013/// [`crate::error_envelopes::validation_envelope`] so the wire shape
1014/// stays bit-identical with the mem-repo `server.rs` handlers.
1015fn 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                // A quarantined mem's entities are deliberately absent
1038                // — the read names the quarantine, not a phantom miss.
1039                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        // Inject `_hash` as the first frontmatter field so callers
1048        // can pin it to `expected_hash` on the next mutation.
1049        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        // Append `## Relations` when the caller asked for it. Mirrors
1056        // the mem-repo entity handler — outgoing + incoming edges
1057        // grouped by direction, rendered as a Markdown table.
1058        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        // Append `## Community Context` when the caller asked for it
1069        // — the entity's cluster + neighbour list. The community
1070        // detection is lazy (memoised per engine) and invalidated on
1071        // every successful mutation, so this is cheap on a static
1072        // graph and pays the Louvain cost once after each write.
1073        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        // Structured envelope
1084        // alongside the markdown text channel; same shape both MCP
1085        // flavours emit so agents branch on `structured_content`
1086        // uniformly regardless of which backend served the read.
1087        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        // Mutation provenance (agent-trust plan 13), opt-in — same
1103        // block and key the full flavour serves; default responses
1104        // are byte-unchanged.
1105        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        // Part B: this surface hardwires `dry_run` off and ignores inline
1129        // `relations`. Refuse up front when either was meaningfully supplied
1130        // rather than committing a real write the agent thought was a preview
1131        // (or dropping edges it thought it wired).
1132        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        // Resolve the target mem. An explicit, non-empty `mem` is
1148        // honoured verbatim — so a multi-mount engine (e.g. a read-only
1149        // content mem alongside a writable sketch mem) can be targeted
1150        // by name, and a create aimed at a read-only mount surfaces the
1151        // engine's READ_ONLY_MOUNT refusal rather than being silently
1152        // redirected to the writable mount. Omitted → the default writable
1153        // mem (first writable mount in declaration order), falling back to
1154        // the first mount so a read-only-only engine still resolves a name
1155        // (the create then refuses with READ_ONLY_MOUNT, never panics on an
1156        // empty mem). Single-mem filesystem workspaces are unaffected:
1157        // the sole mem is both the first and the default writable one.
1158        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            // The filesystem-mem MCP surface doesn't (yet)
1179            // accept inline relations on the wire — pass empty;
1180            // operators wire edges via memstead_relate post-create.
1181            relations: Vec::new(),
1182            // dry_run not exposed on the filesystem-mem tool
1183            // surface; operators preview changes by reading first
1184            // and inspecting on the agent side.
1185            dry_run: false,
1186        };
1187        match engine.create_entity(args, actor, client.as_ref(), p.note.as_deref()) {
1188            Ok(outcome) => {
1189                // WarningHint's Serialize impl produces the same
1190                // `{code, message, details}` envelope the manual
1191                // synthesis used to emit. commit_sha + title +
1192                // mem are now first-class on the outcome.
1193                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        // Part B: this surface hardwires `append_sections` / `patch_sections`
1223        // off and `dry_run` off. Refuse up front when any was meaningfully
1224        // supplied rather than dropping the edit silently (an agent that
1225        // patched believes it patched). `sections` (replace), `metadata`,
1226        // `metadata_unset`, `declare_relations`, and `relations_unset` ARE
1227        // honoured and pass through untouched.
1228        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            // The filesystem-mem tool doesn't expose
1273            // append_sections / patch_sections on the wire yet;
1274            // pass empty.
1275            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                    // Typed warnings ride out on `outcome.warnings` —
1303                    // the engine emits `NOTE_MISSING` here under
1304                    // `[mutations].require_notes`, matching create/relate.
1305                    "warnings": outcome.warnings,
1306                    // Orphan-stub GC: when this update removed a body
1307                    // wiki-link that was a stub target's last referrer,
1308                    // the engine GC'd the stub and lists it here. Always
1309                    // present (empty array when nothing orphaned),
1310                    // matching the relate-remove and delete shape so
1311                    // consumers don't branch on field presence.
1312                    "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            // The MCP delete shape carries a required `expected_hash` String;
1344            // an empty string is the documented "stub delete" path. The
1345            // engine takes Option — pass `None` only for empty
1346            // strings to preserve the no-hash-check semantics.
1347            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                    // Engine-emitted warnings (residual-stub demotion,
1362                    // and `NOTE_MISSING` under `require_notes`).
1363                    "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        // idempotent_hint = true: relate's duplicate-add and
1375        // remove-nonexistent paths are typed-warning no-ops, so a retry
1376        // converges. Matches the mem-repo server's annotation —
1377        // `relate_annotation_is_idempotent_on_lean` pins parity.
1378        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        // This surface hardwires `dry_run` off — refuse up front when
1382        // meaningfully supplied so a rehearsal can never accidentally
1383        // land a real write (same posture as create / update).
1384        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        // Relate is hash-stable on the section bodies but the
1400        // Relationships section regenerates, so `_hash` per entry is
1401        // read back post-commit. `expected_hash` is omitted on relate
1402        // across both flavours.
1403        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        // A list of one routes through the single-op engine path —
1424        // byte-identical semantics to the historical single call,
1425        // wrapped in the same plural envelope larger lists produce.
1426        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        // Snapshot which targets are absent pre-call so applied
1464        // auto-stubs can surface the same AUTO_STUB_CREATED warning
1465        // the single call emitted.
1466        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            // Report-all refusal — nothing committed. A list of one
1481            // surfaces its entry's own typed envelope; larger lists
1482            // wrap under BATCH_REFUSED with per-entry envelopes.
1483            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                    // Real batch only (this flavour has no relate
1550                    // rehearsal): the stub exists post-commit.
1551                    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            // Thread the
1618            // agent's `range_filters` through to the engine arg —
1619            // mirrors the full server's wiring so both servers expose the
1620            // typed range-filter warnings the engine already produces.
1621            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        // Structured envelope
1638        // on `structured_content`, rendered markdown on the text
1639        // channel; lean MCP mirrors full's split so cross-flavour
1640        // agents see the same wire contract.
1641        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        // Validate include keys against the shared catalogue. Unknown
1662        // keys surface as a typed `UNKNOWN_INCLUDE_KEY` warning — the
1663        // same shape full emits and the same shape the CLI consumes.
1664        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        // `dangling_links` opt-in: the engine's `HealthSummary` carries
1679        // a `dangling_links: Option<...>` slot that handlers populate
1680        // (the kernel's `compute_health` leaves it `None`). Populating
1681        // it from the lean surface gives agents the documented
1682        // include-key without forcing them through the full engine.
1683        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        // Conformance axis (`conformance`), or both axes
1689        // (`integrity`) — same `findings` slot and `{ id, axis, code,
1690        // detail }` shape as the mem-repo flavour. The scan iterates every
1691        // mounted mem's schema (`engine.schemas().keys()`), so on a
1692        // two-mount session engine (writable sketch + read-only content) it
1693        // covers both mounts — the summary counts and roster come straight
1694        // from `engine.health()`, which is mount-aware, so nothing is
1695        // misreported on a multi-mount engine.
1696        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        // `include=["anchors"]` (per-mem four-state counts),
1735        // `include=["constraints"]` (standing declared-constraint
1736        // violations), and `include=["friction"]` (the refusal
1737        // ledger's summary — agent-trust plan 08) — from the shared
1738        // base helpers, patched onto the serialized report so
1739        // combined includes compose.
1740        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        // filesystem-mem is single-mem by design; the new engine
1804        // carries one schemas[] entry. Pick it.
1805        let Some((mem_name, schema)) = engine.schemas().iter().next() else {
1806            // Genuinely-systemic: the filesystem boot path always
1807            // mounts exactly one mem and pins exactly one schema. An
1808            // empty `schemas()` map means engine construction itself
1809            // is inconsistent — no agent-side recovery applies, so
1810            // `INTERNAL` is the honest wire code (this class of
1811            // genuinely-systemic failure is the legitimate `INTERNAL`
1812            // use case).
1813            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        // Validate (`name`, `mem`) input shape; either resolves to a
1823        // string the lookup below checks against the pinned schema.
1824        // The `mem` path is provided for parity with the mem-repo
1825        // flavour; in filesystem-mem the single mem always maps
1826        // to the single schema.
1827        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() // matches pinned schema by default
1843            }
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        // Verbosity toggle — `lite` (default) or the full body, mirroring
1856        // the mem-repo server's default. An unrecognized value refuses
1857        // with a typed INVALID_INPUT naming the bad value rather than
1858        // silently falling back.
1859        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        // One shared, transport-neutral builder for every schema-read
1873        // surface (mem-repo MCP, the HTTP `/api/schema` endpoint, and
1874        // this filesystem-mem flavour) — no second divergent renderer
1875        // to drift. `ref` carries the canonical `name@version`, and
1876        // `alias_target_rel_type` rides along, so the public surface now
1877        // advertises the body-wiki-link edge-authoring rule the full
1878        // schema response always carried.
1879        // Trust origin governs de-framing: a third-party schema is served
1880        // structural-only regardless of the requested `verbosity`.
1881        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        // The unified engine doesn't expose a workspace_root accessor
1931        // (mounts can be heterogeneous); use the captured field.
1932        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, // skip malformed lines silently
1954            };
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                    // Engine-emitted warnings (slug-noop, and
2006                    // `NOTE_MISSING` under `require_notes`).
2007                    "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        // The lean surface renders the identical overview through the one shared
2082        // composer in `memstead_base::overview` (relocated there so this
2083        // no-`memstead-engine` build can reach it). The hand-rolled single-mem
2084        // renderer is gone: with one rendering authority the roster,
2085        // `_entity_count`, and the communities section can never disagree, and a
2086        // read-only mount appears with its `Access: read-only` / `Origin` lines
2087        // instead of being dropped. `suppress_lifecycle` is set because this
2088        // surface carries no mem-lifecycle tools, so naming them would be false.
2089        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
2132/// The lean (filesystem-mem) server's session-start instructions —
2133/// one named const so the registry-honesty tests read the SAME string
2134/// the handler serves. Built with `concat!` so the engine version is
2135/// baked in at compile time; the roster must name every tool this
2136/// flavour registers (bidirectionally test-enforced).
2137pub 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    /// Hand-written so `instructions` can be the named
2147    /// [`FS_SERVER_INSTRUCTIONS`] const (the macro only accepts string
2148    /// literals) and the serverInfo version is the engine's full
2149    /// build version (semver + git build sha for dev builds) by
2150    /// construction — the historical hardcoded `"0.1.0"` cannot recur,
2151    /// and two dev builds between releases stay distinguishable.
2152    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    /// Friction-ledger seam (agent-trust plan 08), mirroring the full
2196    /// flavour: every dispatched typed refusal appends one
2197    /// content-free ledger entry, best-effort, after the response is
2198    /// built.
2199    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    /// The lean MCP error map must emit the same wire code as
2238    /// `EngineError::code()` — the single source every surface (full MCP,
2239    /// CLI, wasm) follows. `Backend` and `ParseAfterWrite` historically
2240    /// shipped `MEM_WRITER_ERROR` / `PARSE_AFTER_WRITE` here, diverging
2241    /// from `code()`'s `MEM_ERROR` / `PARSE_ERROR`. Pin them so a
2242    /// re-divergence fails the build.
2243    #[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    /// `memstead_relate` is idempotent on the lean surface, matching the
2267    /// mem-repo server: duplicate-add and remove-nonexistent are
2268    /// typed-warning no-ops, so a retry converges. The full-side
2269    /// annotation meta-test is `mem-repo`-gated; this lean-side test
2270    /// pins the parity so the two flavours cannot silently re-diverge.
2271    #[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        // Two-layer file adapter markers — `Engine::from_workspace_root`
2294        // recognises a workspace by `.memstead/workspace.toml` plus the
2295        // mount list in `.memstead/state/mounts.json`.
2296        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        // Poison the engine mutex for real: a thread panics while
2329        // holding the guard.
2330        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        // memstead_create — the engine refuses on missing required
2362        // sections, so seed `identity` + `purpose` so the spec lands.
2363        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        // Changelog has the note.
2392        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        // memstead_entity
2397        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    /// Build a two-mount engine — a writable in-memory `sketch` mem
2420    /// (declared first) and a read-only in-memory `content` mem — so the
2421    /// create handler's multi-mount mem resolution is exercised at this
2422    /// layer. Mirrors the session server's two-tier shape.
2423    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    /// Multi-mount create-targeting: with a writable `sketch` and a
2473    /// read-only `content` mem mounted together, an omitted `mem` lands
2474    /// in the writable mount (not the alphabetically-first read-only one);
2475    /// an explicit read-only target is refused with READ_ONLY_MOUNT rather
2476    /// than silently redirected; an explicit writable target is honoured.
2477    #[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        // Omitted mem → default writable mount (`sketch`).
2483        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        // Explicit read-only mem → typed refusal, not a redirect.
2496        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        // Explicit writable mem → honoured.
2508        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    /// Plan 03, Part B: params this surface hardwires off are REFUSED with
2522    /// a typed `UNSUPPORTED_PARAM` naming them — never silently dropped. The
2523    /// `dry_run` case is the load-bearing one: silently treating it as a real
2524    /// write would land an entity the agent thought was a preview.
2525    #[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        // create + dry_run: true → refused up front; nothing lands.
2546        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        // The refusal precedes the engine, so the preview entity never lands.
2552        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        // create + non-empty relations → refused, naming relations.
2567        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        // update + each unsupported param → refused naming it.
2578        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        // relate + dry_run: true → refused up front (the unified
2620        // engine's rehearsal contract does NOT silently erode into a
2621        // real write here); dry_run absent / false stays served.
2622        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        // dry_run: false / absent is not a meaningful supply — the
2640        // call proceeds to the engine (and fails only on the missing
2641        // source entity, not on UNSUPPORTED_PARAM).
2642        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    /// The `open_questions` axis on the lean flavour (agent-trust
2651    /// plan 11): include-gated — absent without the include, an
2652    /// empty per-mem worklist with it, never an error on a hole-free
2653    /// mem.
2654    #[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    /// Refusal complement (Part B): a defaulted-empty / absent unsupported
2683    /// param is left alone — the surface stays backward-compatible for a
2684    /// caller that harmlessly passes nothing. A plain create/update with the
2685    /// supported params still succeeds.
2686    #[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        // create with dry_run: None / relations: None → no UNSUPPORTED_PARAM.
2693        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        // dry_run: Some(false) is the caller intending no preview — also fine.
2699        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    /// Smoke-test probe A: a memo created with sections that belong
2732    /// to a different type (here `identity` + `purpose`, which are
2733    /// `spec` sections, not `memo`'s `claim` + `context`) must reject
2734    /// with `UNKNOWN_SECTION` before any disk write lands.
2735    #[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        // The first offender hits first; either key would be valid.
2761        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        // No file should have been written.
2767        assert!(
2768            !tmp.path().join("stray-memo.md").exists(),
2769            "stray memo should not have been persisted"
2770        );
2771    }
2772
2773    /// Smoke-test probe B: a memo with no sections at all should
2774    /// succeed (required-section gaps are Tier-2 warnings, not hard
2775    /// errors), but the response must carry a `MISSING_REQUIRED_SECTION`
2776    /// warning per missing required section so the agent can
2777    /// self-correct.
2778    #[test]
2779    fn create_refuses_missing_required_section_with_typed_envelope() {
2780        // `memstead_create` refuses on missing required sections instead
2781        // of emitting warnings. Pre-fix the entity landed with empty
2782        // placeholders for each missing required section and the
2783        // install-time strict validator could later refuse the
2784        // resulting archive — the export-then-install round-trip
2785        // broke. Now the refusal fires at the write boundary.
2786        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    /// Smoke-test probe C: an out-of-enum metadata value (`level: "Z3"`
2820    /// against the `M0|M1|M2|M3` allowed set) must reject with
2821    /// `INVALID_ENUM_VALUE`.
2822    #[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    /// Unknown metadata fields surface `UNKNOWN_METADATA_FIELD`. The
2851    /// mem-repo path emits the same code; this guard pins the
2852    /// filesystem-mem contract so future refactors don't silently
2853    /// drop the gate.
2854    #[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        // The
2903        // engine refuses on missing required sections. Seed the
2904        // `spec` type's required `identity` + `purpose` sections so
2905        // wire-shape tests using this helper continue to land valid
2906        // entities.
2907        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    /// `memstead_update` must reject any attempt to mutate the read-only
2996    /// metadata triple (`mem`, `id`, `type`) on either set or unset
2997    /// — the entity-id contract depends on those staying stable.
2998    #[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    /// `metadata_unset` is the asymmetric half of the reservation:
3035    /// unsetting a reserved key is ALLOWED (the sanctioned repair for a
3036    /// historically smuggled key — on a healthy entity it is a
3037    /// committed-nothing no-op, and `type` is engine-re-seeded so the
3038    /// entity never goes typeless), while the engine-stamped timestamp
3039    /// fields stay refused on unset.
3040    #[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        // Reserved triple: unset succeeds. On this healthy entity it is
3067        // a no-op (nothing was smuggled), surfaced as UPDATE_NOOP.
3068        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        // Engine-stamped timestamps: unset still refuses.
3084        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    /// The virtual `relationships` surface is managed by `memstead_relate`,
3092    /// not `memstead_update`. Writes there reject with
3093    /// `SECTION_NOT_UPDATABLE` so an agent does not bypass the
3094    /// rel-validation pipeline by treating relationships as a section.
3095    #[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        // The relationships sneak path is gated by validate_section_keys
3123        // first (the schema doesn't declare a `relationships` section),
3124        // so it fires UNKNOWN_SECTION. Either gate is correct — both
3125        // close the bypass.
3126        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    /// Strict-mode schemas (the default) reject undeclared
3206    /// relationship names with `INVALID_REL_TYPE`. The recovery
3207    /// envelope carries the canonical vocabulary on
3208    /// `details.allowed[]` so the agent can self-correct in one
3209    /// round trip without a follow-up `memstead_overview`.
3210    #[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    /// Malformed `range_filters` key (no `min_`/`max_`/`*_before`/`*_after`
3306    /// shape) refuses with `RANGE_FILTER_KEY_MALFORMED`. An earlier
3307    /// MCP `SearchParams` had no `range_filters` field at all so the
3308    /// engine never saw the input — silent no-op.
3309    #[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    /// Unknown range-filter field surfaces
3352    /// `UNKNOWN_RANGE_FILTER_FIELD` with the derived field name.
3353    #[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    /// Range-filter against a field that exists on the
3391    /// type's schema but is not declared `filterable: range` surfaces
3392    /// `FIELD_NOT_RANGE_FILTERABLE`. `level` exists on the default
3393    /// `spec` type with `filterable: equality` — perfect probe.
3394    #[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    /// Omitting `range_filters` produces no
3432    /// range-filter warnings (the parameter is optional).
3433    #[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        // Filter to a non-existent type → empty hits.
3478        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        // The summary structure carries totals and a per-mem map.
3523        // We do not pin field names here (memstead-base owns the shape),
3524        // just assert the response is a non-empty JSON object.
3525        assert!(body.is_object());
3526        assert!(!body.as_object().unwrap().is_empty());
3527    }
3528
3529    /// Plan 08 duplicate check (MCP leg): an identifier-shaped value
3530    /// living only in an entity's metadata is findable by a plain
3531    /// free-text `memstead_search`, and the hit reports the metadata
3532    /// match in its matched-terms breakdown.
3533    #[test]
3534    fn search_finds_identifier_shaped_metadata_value() {
3535        let tmp = TempDir::new().unwrap();
3536        write_workspace(&tmp, "demo");
3537        // Seed a file carrying the identifier in an UNDECLARED
3538        // metadata field (tolerated on load; now findable).
3539        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        // Pins the migration template's "boot fresh per call" property:
3589        // seed entities through the legacy engine after server boot,
3590        // then assert memstead_health (which now routes through a fresh
3591        // memstead_base::Engine) reflects them. Without the per-call boot
3592        // the health response would be stale relative to the legacy
3593        // engine's mutations.
3594        //
3595        // `memstead_create` refuses on missing required sections,
3596        // so `seed_via_mcp` seeds `identity` + `purpose`. The
3597        // seeded entity no longer surfaces as missing_fields, but
3598        // the broader invariant — health reflects post-boot
3599        // mutations — still holds via the total entity count.
3600        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        // Health must reflect the post-boot mutation — the seeded
3607        // entity is visible via the stats projection (stub_count and
3608        // missing_fields can both be 0 on a fresh mem with a
3609        // well-formed seed). Use a query for the entity directly:
3610        // memstead_search by title term proves the engine re-boot sees
3611        // the new entity. If `health` had been boot-cached, the
3612        // search index would lag.
3613        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        // Bare-name and canonical pin both work.
3657        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            // Converged onto the shared `build_schema_payload`: the
3666            // canonical `ref` subsumes the former top-level `name`/`version`.
3667            // The omitted-verbosity default is the lite skeleton.
3668            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        // Explicit full verbosity still ships the rich catalogue.
3676        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        // `mem` shortcut resolves the same schema.
3686        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    /// Surface parity: the public filesystem `/mcp` flavour honours the
3697    /// same `verbosity` toggle as the mem-repo surface (Plan 01) — it
3698    /// converged onto the shared `build_schema_payload`, so lite is the
3699    /// identical structural skeleton and an unknown value refuses typed.
3700    #[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        // Three creates → three changelog lines.
3795        for title in ["A", "B", "C"] {
3796            seed_via_mcp(&server, title);
3797        }
3798
3799        // Empty `since` → all three entries.
3800        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        // `since` set to a far-future timestamp → empty.
3816        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        // Fresh workspace with no mutations → no changelog file
3829        // exists. Must return an empty result, not an error.
3830        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        // Add a relation.
3853        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        // Read with include_relations.
3867        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        // Read with include_context. The community cache runs Louvain
3896        // on first call; a single-node graph has a trivial cluster.
3897        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        // Seed two entities — one matches the query body, one does not.
3925        // The
3926        // engine refuses on missing required sections, so seed both
3927        // `identity` + `purpose` for each create.
3928        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        // Issue a search using the structured query shape.
3963        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        // Markdown response — rendered by `render_search_markdown`.
3996        // The matching entity's title or id appears; the
3997        // non-matching one should not (asserts that the text
3998        // predicate actually filtered).
3999        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        // Empty query → falls through to metadata-only / list semantics.
4006        // Asserts the no-text-predicate branch returns hits without
4007        // tripping on the index path.
4008        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        // Both seeded entities appear when there is no text filter.
4041        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        // Produced by the shared composer now: an `_overview_mode` frontmatter
4070        // line and the standard section headings, with the single mem in the
4071        // roster and its own entity count. `_mem_schema` is emitted only under a
4072        // `mem` filter (composer behaviour), so it is absent from this
4073        // unscoped call — asserted below. The lean surface carries no
4074        // mem-lifecycle tools, so the lifecycle section is suppressed.
4075        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        // Scoping to the one visible mem succeeds and now anchors `_mem_schema`.
4091        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        // The shared composer returns the typed unknown-mem error, and its mem
4129        // list names every visible mem (here, the workspace's single mem).
4130        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        // Edge so the cluster has structure to discuss.
4142        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        // With community_members forced, the rendered cluster lists
4172        // each member entity id as a bullet — both Alpha and Beta
4173        // should appear under Communities.
4174        assert!(text.contains(&format!("- {a}")));
4175        assert!(text.contains(&format!("- {b}")));
4176    }
4177
4178    /// `memstead_overview include=["dangling_links"]` lists every non-stub
4179    /// entity whose section body wiki-links resolve to a stub or
4180    /// missing target. Pre-fix the lean overview surface hardcoded
4181    /// `[]` here and the `## Dangling Links` block never rendered,
4182    /// even when the health surface populated the same
4183    /// view. The test fails against that state.
4184    #[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        // Rewrite Identity to carry a body wiki-link to a slug with
4192        // no on-disk file, backed by an atomic REFERENCES declaration
4193        // (forward-reference auto-stub). The stub is the dangling
4194        // signal — the dangling-links surface flags wiki-links whose
4195        // target resolves to a stub entity.
4196        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            // Body wiki-link `[[gone]]` is auto-emitted as REFERENCES
4216            // via the alias-synthesis pass — explicit author refused
4217            // under the schema's `manual_authoring: forbidden` posture.
4218            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}