Skip to main content

velesdb_memory/
mcp.rs

1//! MCP transport: exposes the memory service as MCP tools over stdio.
2//!
3//! Only **memory semantics** are exposed (`remember / recall / relate / forget
4//! / why`) — never raw database capabilities. See [`crate`] docs for the
5//! license boundary this enforces.
6
7use std::sync::Arc;
8
9use rmcp::handler::server::router::tool::ToolRouter;
10use rmcp::handler::server::wrapper::{Json, Parameters};
11use rmcp::model::{ErrorCode, Implementation, ServerCapabilities, ServerInfo};
12use rmcp::{tool, tool_handler, tool_router, ErrorData, ServerHandler};
13
14use crate::limits::MAX_RECALL_LIMIT;
15use crate::service::{LiveGenerationSlot, MemoryService};
16
17/// Default number of memories returned by `recall`.
18const DEFAULT_RECALL_LIMIT: usize = 10;
19
20const UNREPORTED_MODEL: &str = "unreported";
21
22// The boxed embedder and the shared, runtime-attached extraction backend the
23// server stores — imported for internal use only. The canonical public paths are
24// `velesdb_memory::DynEmbedder` / `velesdb_memory::DynExtractor` (crate root).
25use crate::embedder::DynEmbedder;
26use crate::extract::DynExtractor;
27
28// --- Tool parameter / result DTOs ------------------------------------------
29//
30// The request envelopes, small id-results, and the id-echoing wire wrappers
31// (`RecollectionDto`, `ExplanationDto` — the `id_str` twins of issue #1468)
32// live in their own module so this file stays focused on the server and tool
33// wiring; the domain types in `crate::model` are unchanged.
34/// The context compiler's eight tools — a second `#[tool_router]` block whose
35/// router is combined with the main one below, extending the ONE server.
36#[cfg(feature = "context")]
37mod context_tools;
38
39mod advanced_tools;
40mod dto;
41mod extraction_job_model;
42mod extraction_job_store;
43mod extraction_jobs;
44mod extractor_resolver;
45mod migration_tools;
46mod status;
47mod wire;
48use dto::{
49    EntityParams, EntityProfileDto, FeedbackParams, FeedbackResult, ForgetParams, ForgetResult,
50    RecallParams, RecallResult, RecallWhereParams, RelateParams, RelateResult, RememberParams,
51    RememberResult, UnrelateParams, UnrelateResult,
52};
53use extraction_jobs::{ExtractionJobs, JobError};
54use extractor_resolver::ExtractorResolver;
55
56/// Le constructeur de schema d'ENTREE, unique pour tous les outils :
57/// [`crate::schema::wire_safe_input_schema`].
58///
59/// `keys` nomme les proprietes que CET outil accepte en chaine decimale
60/// (`relate`/`unrelate` : `from`/`to` ; `forget`/`feedback` : `id` ;
61/// `remember` : le `links[].target` imbrique) — la tolerance d'un id est une
62/// connaissance de l'outil, jamais une regle globale :
63/// `explain_compilation.fragment_id` est un `u64` STRICT et reste annonce
64/// `integer`.
65///
66/// Il y avait deux constructeurs, celui-ci et un `wire_safe_input_schema`
67/// gate sur `context` a cle `"id"` figee ; ils appliquaient la meme suite de
68/// passes a une virgule pres. Il n'y en a plus qu'un.
69use crate::schema::wire_safe_input_schema as id_wire_input_schema;
70
71// --- The server ------------------------------------------------------------
72
73/// MCP server wrapping a [`MemoryService`].
74#[derive(Clone)]
75pub struct McpServer {
76    service: Arc<LiveGenerationSlot<DynEmbedder>>,
77    /// Join guard of the background autograph worker (#1846) — present iff
78    /// an autograph extractor is configured. Held only for its `Drop`: the
79    /// server going down closes the queue and joins the worker — the job in
80    /// flight completes, still-queued ones are skipped and counted, so exit
81    /// waits for at most ONE generation.
82    _autograph_worker: Option<Arc<crate::service::AutographWorkerHandle>>,
83    /// Daemon-level default and per-call selection for `remember_extracted`.
84    extractors: Arc<parking_lot::RwLock<ExtractorResolver>>,
85    /// Durable receipt/status state machine for `remember_extracted`.
86    /// Configured by the native daemon once its store path is known.
87    extraction_jobs: Option<ExtractionJobs>,
88    /// Daemon-owned online embedding migration control plane.
89    online_migration: Option<Arc<crate::service::OnlineMigrationManager<DynEmbedder>>>,
90    /// Default time-to-live (seconds) applied to `remember`d facts that don't
91    /// specify their own `ttl_seconds`. `None` (the default) stores permanently.
92    /// Set from `VELESDB_MEMORY_DEFAULT_TTL` by the binary.
93    default_ttl: Option<u64>,
94    /// The store directory, for reading the embedding-provenance record
95    /// (#1751) in `memory_status`. `None` disables the provenance block
96    /// (reported as unrecorded — a store nobody can locate has no readable
97    /// record either way).
98    store_dir: Option<std::path::PathBuf>,
99    /// Allowlisted filesystem roots for `path`-referenced context fragments
100    /// (V2b-1). `None` (the default) disables path ingestion entirely — every
101    /// `path` fragment fails with an explicit error. Set from
102    /// `VELESDB_MEMORY_INGEST_ROOTS` by the binary via [`Self::with_ingest_roots`].
103    #[cfg(all(feature = "context", not(target_arch = "wasm32")))]
104    ingest_roots: Option<crate::context::IngestRoots>,
105    tool_router: ToolRouter<McpServer>,
106}
107
108#[tool_router]
109impl McpServer {
110    /// Wrap a memory service as an MCP server.
111    #[must_use]
112    pub fn new(service: MemoryService<DynEmbedder>) -> Self {
113        let service = Arc::new(LiveGenerationSlot::new(service, UNREPORTED_MODEL));
114        // Autograph leaves the response path here (#1846): with an extractor
115        // configured, ONE background worker consumes a bounded queue and
116        // `remember` returns as soon as the fact is stored — measured 46-52 s
117        // inline against a 0.12 s embedding. The handle rides the server so
118        // shutdown finishes the job in flight and skips the rest, counted.
119        let autograph_worker = if matches!(service.inspect(MemoryService::has_autograph), Ok(true))
120        {
121            match service.spawn_autograph_worker(crate::limits::MAX_AUTOGRAPH_QUEUE) {
122                Ok(handle) => Some(Arc::new(handle)),
123                Err(error) => {
124                    tracing::warn!(%error, "autograph worker not spawned; falling back inline");
125                    None
126                }
127            }
128        } else {
129            None
130        };
131        Self {
132            service,
133            _autograph_worker: autograph_worker,
134            extractors: Arc::new(parking_lot::RwLock::new(ExtractorResolver::default())),
135            extraction_jobs: None,
136            online_migration: None,
137            default_ttl: None,
138            store_dir: None,
139            #[cfg(all(feature = "context", not(target_arch = "wasm32")))]
140            ingest_roots: None,
141            tool_router: Self::combined_router(),
142        }
143    }
144
145    /// Declare the running embedder's identity (model name + vector width)
146    /// so `memory_status` can name it — and say whether recall is semantic.
147    /// Undeclared, the status reports the embedder as unreported rather than
148    /// guessing from the service, which only ever sees `&[f32]`.
149    #[must_use]
150    pub fn with_embedder_identity(self, model: impl Into<String>, _dimension: usize) -> Self {
151        self.service.declare_model(model);
152        self
153    }
154
155    /// Point `memory_status` at the store directory so it can relay the
156    /// embedding-provenance record (#1751). Without it the provenance block
157    /// reports `recorded: false`.
158    #[must_use]
159    pub fn with_store_dir(mut self, dir: impl Into<std::path::PathBuf>) -> Self {
160        self.store_dir = Some(dir.into());
161        self
162    }
163
164    /// Attach the daemon-owned online migration control plane.
165    ///
166    /// # Errors
167    /// Returns an error when its durable control directory cannot be created safely.
168    pub fn with_online_migration<F>(
169        mut self,
170        source: impl Into<std::path::PathBuf>,
171        factory: F,
172    ) -> Result<Self, crate::MemoryError>
173    where
174        F: Fn(&str) -> Result<(DynEmbedder, String), crate::MemoryError> + Send + Sync + 'static,
175    {
176        let targets = Arc::new(move |backend: &str| {
177            factory(backend).map(|(embedder, model)| crate::service::JobTarget { embedder, model })
178        });
179        self.online_migration = Some(crate::service::OnlineMigrationManager::new(
180            Arc::clone(&self.service),
181            source,
182            targets,
183        )?);
184        Ok(self)
185    }
186
187    /// The full tool router: the memory tools, plus the context compiler's
188    /// tools when that feature is on. Combined here — rmcp routers add — so
189    /// there is exactly ONE server whichever features are enabled.
190    ///
191    /// **Et le point de passage unique du durcissement d'entree.** Mesure du
192    /// 2026-07-29 : 10 outils sur 20 ne declaraient AUCUN `input_schema`
193    /// (`recall`, `recall_fused`, `entity`, `why`, `remember_extracted`,
194    /// `context_savings`, `retrieve_context_source`, `load_working_context`,
195    /// `list_working_contexts`, `suggest_budget`) — leur schema etait celui
196    /// derive par rmcp, que rien ne post-traitait. Un durcissement declare
197    /// outil par outil laisse donc chaque route future non protegee, et rien
198    /// ne le signale : c'est une omission, pas une erreur. Ici, une route
199    /// nouvelle est couverte parce qu'elle EXISTE.
200    ///
201    /// L'attribut `#[tool(input_schema = …)]` garde ce qui, lui, est
202    /// vraiment per-outil : les cles d'id que l'outil accepte en chaine.
203    fn combined_router() -> ToolRouter<McpServer> {
204        #[cfg(feature = "context")]
205        let mut router = Self::tool_router()
206            + Self::advanced_tool_router()
207            + Self::status_tool_router()
208            + Self::migration_tool_router()
209            + Self::context_tool_router();
210        #[cfg(not(feature = "context"))]
211        let mut router = Self::tool_router()
212            + Self::advanced_tool_router()
213            + Self::status_tool_router()
214            + Self::migration_tool_router();
215
216        // `reharden_tool_input` prend l'outil, pas un schema : `Tool` type
217        // ses deux schemas identiquement, donc c'est la signature — et non
218        // une convention de nommage — qui rend la sortie inatteignable ici.
219        for route in router.map.values_mut() {
220            crate::schema::reharden_tool_input(&mut route.attr);
221        }
222        assert_every_input_slot_is_typed(&router);
223        router
224    }
225
226    /// Attach an extraction backend, enabling the `remember_extracted` tool.
227    /// Without it the tool reports that extraction is not configured.
228    #[must_use]
229    pub fn with_extractor(self, extractor: DynExtractor) -> Self {
230        *self.extractors.write() = ExtractorResolver::unnamed(extractor);
231        self
232    }
233
234    /// Attach the named daemon-level default used when `remember_extracted`
235    /// omits its per-call `extractor` choice.
236    ///
237    /// # Errors
238    /// Returns an error when `backend` is unknown or disables extraction.
239    pub fn with_named_extractor(
240        self,
241        backend: impl Into<String>,
242        extractor: DynExtractor,
243    ) -> Result<Self, String> {
244        *self.extractors.write() = ExtractorResolver::named(backend.into(), extractor)?;
245        Ok(self)
246    }
247
248    /// Enable the durable extraction-job worker under the native store root.
249    ///
250    /// Call this after opening the store and before serving requests. Accepted
251    /// and in-flight records are recovered immediately; corrupt durable state
252    /// fails startup instead of silently losing a receipt.
253    ///
254    /// # Errors
255    /// Returns a descriptive error if the job directory cannot be created,
256    /// validated, read, or if the recovery worker cannot start.
257    pub fn with_extraction_jobs(
258        mut self,
259        store_root: impl AsRef<std::path::Path>,
260    ) -> Result<Self, String> {
261        self.extraction_jobs = Some(
262            ExtractionJobs::open(
263                store_root.as_ref(),
264                Arc::clone(&self.service),
265                Arc::clone(&self.extractors),
266            )
267            .map_err(|error| error.to_string())?,
268        );
269        Ok(self)
270    }
271
272    /// Apply a default TTL (seconds) to `remember`d facts that don't carry their
273    /// own `ttl_seconds`. `0` is treated as "no default" (permanent).
274    #[must_use]
275    pub fn with_default_ttl(mut self, ttl_seconds: u64) -> Self {
276        self.default_ttl = (ttl_seconds > 0).then_some(ttl_seconds);
277        self
278    }
279
280    /// Enable path ingestion (V2b-1): `compile_context` and
281    /// `explain_compilation` fragments carrying `path` are resolved against
282    /// this allowlist before compilation. Without this (the default), every
283    /// `path` fragment fails with an explicit "ingestion disabled" error —
284    /// same pattern as [`Self::with_extractor`].
285    #[cfg(all(feature = "context", not(target_arch = "wasm32")))]
286    #[must_use]
287    pub fn with_ingest_roots(mut self, roots: crate::context::IngestRoots) -> Self {
288        self.ingest_roots = Some(roots);
289        self
290    }
291
292    #[tool(
293        name = "remember",
294        // Sans declaration explicite, rmcp derive un schema de sortie qui
295        // conserve des $ref qu'un client aveugle aux $defs ne resout pas —
296        // or les SDK MCP valident structuredContent contre ce schema.
297        output_schema = crate::schema::wire_safe_output_schema::<RememberResult>(),
298        description = "Store a fact in durable local memory. Optionally link it to existing memories (graph) and tag it with structured metadata like project/author/type/status/date (ColumnStore) for later filtering — metadata is capped at 64 KiB serialized. A fact is capped at 2048 bytes: that is roughly what the embedding model's context window holds, and a longer one is REFUSED with its size, not silently mangled — split a long passage into several atomic facts, or compile it with `compile_context` and remember a summary. Set `ttl_seconds` to make the fact expire after a delay (a durable TTL that survives restarts); omit it for a permanent memory — `ttl_seconds: 0` is refused, not read as \"never\". Returns the fact's stable id. With the async autograph worker active, edges derived from a remember land asynchronously: an `entity`/`why` read immediately after may not see them yet — the fact itself is always immediately readable. Ids exceed 2^53 — always relay them as strings (`id_str`); passing a JSON-number id read from a previous response will fail on float-lossy clients.",
299        input_schema = id_wire_input_schema::<RememberParams>(&["target"])
300    )]
301    async fn remember(
302        &self,
303        Parameters(params): Parameters<RememberParams>,
304    ) -> Result<Json<RememberResult>, ErrorData> {
305        // No size pre-check here: `MemoryService::remember_with_ttl` refuses an
306        // over-long fact itself (`MAX_EMBEDDABLE_TEXT_BYTES`, far below
307        // `MAX_FACT_BYTES`), with a message naming the cap AND the received
308        // size — so every adapter reports the same thing, from one place.
309        let service = Arc::clone(&self.service);
310        let RememberParams {
311            fact,
312            links,
313            metadata,
314            ttl_seconds,
315        } = params;
316        let ttl = ttl_seconds.or(self.default_ttl);
317        let id = tokio::task::spawn_blocking(move || {
318            service.run(|current| current.remember_with_ttl(&fact, &links, metadata.as_ref(), ttl))
319        })
320        .await
321        .map_err(join_error)?
322        .map_err(to_error)?;
323        Ok(Json(RememberResult {
324            id,
325            id_str: id.to_string(),
326        }))
327    }
328
329    #[tool(
330        name = "recall",
331        // rmcp derives an output schema when none is given, and that
332        // derived form keeps `$ref`s a `$defs`-blind client cannot resolve.
333        output_schema = crate::schema::wire_safe_output_schema::<RecallResult>(),
334        description = "Recall memories semantically similar to a query (vector). Ranking blends similarity with each fact's learned confidence (see `feedback`), so the order is not pure similarity — the returned `score` is always the raw similarity, never the blended value. Optionally narrow to exact-match metadata via `filter` (ColumnStore), e.g. {\"project\":\"veles\",\"status\":\"resolved\"}. Ids exceed 2^53 — always relay them as strings (`id_str`); passing a JSON-number id read from a previous response will fail on float-lossy clients."
335    )]
336    async fn recall(
337        &self,
338        Parameters(params): Parameters<RecallParams>,
339    ) -> Result<Json<RecallResult>, ErrorData> {
340        let limit = params
341            .limit
342            .unwrap_or(DEFAULT_RECALL_LIMIT)
343            .min(MAX_RECALL_LIMIT);
344        let service = Arc::clone(&self.service);
345        let RecallParams { query, filter, .. } = params;
346        let memories = tokio::task::spawn_blocking(move || {
347            service.run(|current| current.recall(&query, limit, filter.as_ref()))
348        })
349        .await
350        .map_err(join_error)?
351        .map_err(to_error)?;
352        Ok(Json(RecallResult::new(memories)))
353    }
354
355    #[tool(
356        name = "recall_where",
357        // rmcp derives an output schema when none is given, and that
358        // derived form keeps `$ref`s a `$defs`-blind client cannot resolve.
359        output_schema = crate::schema::wire_safe_output_schema::<RecallResult>(),
360        description = "Fused recall: semantically similar memories (vector) constrained by structured ColumnStore predicates over metadata — ranges and comparisons, not just equality. Each filter is {field, op (eq/ne/lt/le/gt/ge), value}, ANDed. Use for time-windowed or numeric-scoped recall, e.g. facts about a topic with `ts` in a date range. Comparisons are TYPE-STRICT, with no runtime coercion: a filter value of 20230601 (a JSON number) never matches a fact stored with metadata {\"ts\": \"20230601\"} (a JSON string) — same value, different JSON type, no match, no error. Store comparable values like dates NUMERICALLY at `remember` time (e.g. 20230601, not \"20230601\") so `recall_where` filters actually match them. Most similar first. Returns your own memories ONLY: entity hubs and the context compiler's artefacts (stored sources, compilation events, working contexts and their index) are internal scaffolding and never come back, whatever the predicate — including a `ne` one, which matches facts that lack the field entirely.",
361        // No id-named parameter here (hence the empty `keys`) — this goes
362        // through the shared helper purely for its `$ref` inlining, so
363        // `filters[]` advertises `ColumnFilter`'s own fields instead of a
364        // `$ref` a `$defs`-blind harness reads as "array of anything".
365        input_schema = id_wire_input_schema::<RecallWhereParams>(&[])
366    )]
367    async fn recall_where(
368        &self,
369        Parameters(params): Parameters<RecallWhereParams>,
370    ) -> Result<Json<RecallResult>, ErrorData> {
371        let limit = params
372            .limit
373            .unwrap_or(DEFAULT_RECALL_LIMIT)
374            .min(MAX_RECALL_LIMIT);
375        let service = Arc::clone(&self.service);
376        let RecallWhereParams { query, filters, .. } = params;
377        let memories = tokio::task::spawn_blocking(move || {
378            service.run(|current| current.recall_where(&query, limit, &filters))
379        })
380        .await
381        .map_err(join_error)?
382        .map_err(to_error)?;
383        Ok(Json(RecallResult::new(memories)))
384    }
385
386    #[tool(
387        name = "feedback",
388        // Sans declaration explicite, rmcp derive un schema de sortie qui
389        // conserve des $ref qu'un client aveugle aux $defs ne resout pas —
390        // or les SDK MCP valident structuredContent contre ce schema.
391        output_schema = crate::schema::wire_safe_output_schema::<FeedbackResult>(),
392        description = "Reinforce a recalled memory with an outcome: `success=true` if the fact was useful, `false` if it was noise. This durably updates the fact's learned confidence, which `recall` uses to re-rank future results — over repeated feedback, useful facts drift up and noise drifts down, so the memory improves with use without retraining the model. Returns the fact's new confidence in [0,1].",
393        input_schema = id_wire_input_schema::<FeedbackParams>(&["id"])
394    )]
395    async fn feedback(
396        &self,
397        Parameters(params): Parameters<FeedbackParams>,
398    ) -> Result<Json<FeedbackResult>, ErrorData> {
399        let service = Arc::clone(&self.service);
400        let FeedbackParams { id, success } = params;
401        let confidence = tokio::task::spawn_blocking(move || {
402            service.run(|current| current.feedback(id, success))
403        })
404        .await
405        .map_err(join_error)?
406        .map_err(to_error)?;
407        Ok(Json(FeedbackResult {
408            id,
409            id_str: id.to_string(),
410            confidence,
411        }))
412    }
413
414    #[tool(
415        name = "relate",
416        // Sans declaration explicite, rmcp derive un schema de sortie qui
417        // conserve des $ref qu'un client aveugle aux $defs ne resout pas —
418        // or les SDK MCP valident structuredContent contre ce schema.
419        output_schema = crate::schema::wire_safe_output_schema::<RelateResult>(),
420        description = "Create a typed, directional link between two memories (`from` → `to`) labeled by `relation`. These links are the graph edges that `why` and `recall_fused` later traverse to surface connected facts that share no words with the query — build the graph with `relate` so multi-hop reasoning works (e.g. link a decision to its cause, a fact to its source, a task to the person it concerns). Direction matters: traversal follows OUTGOING edges only, so point `from` at the memory you will later ask `why` about and `to` at its evidence (decision → cause, fact → source) — an edge pointing INTO a memory is invisible to `why(that memory)`. Idempotent per (from, relation, to); `from` and `to` must be DIFFERENT memories — a self-loop states nothing and only adds noise to `why`'s evidence trail, so it is refused. Returns the edge id as `edge_id`, plus `edge_id_str` for clients without u64-safe JSON number parsing — the one already there when this exact relation exists, since the call is idempotent. Ids exceed 2^53 — always relay them as strings (`edge_id_str`); passing a JSON-number id read from a previous response will fail on float-lossy clients.",
421        input_schema = id_wire_input_schema::<RelateParams>(&["from", "to"])
422    )]
423    async fn relate(
424        &self,
425        Parameters(params): Parameters<RelateParams>,
426    ) -> Result<Json<RelateResult>, ErrorData> {
427        let service = Arc::clone(&self.service);
428        let RelateParams { from, to, relation } = params;
429        let edge_id = tokio::task::spawn_blocking(move || {
430            service.run(|current| current.relate(from, to, &relation))
431        })
432        .await
433        .map_err(join_error)?
434        .map_err(to_error)?;
435        Ok(Json(RelateResult {
436            edge_id,
437            edge_id_str: edge_id.to_string(),
438        }))
439    }
440
441    #[tool(
442        name = "unrelate",
443        // Sans declaration explicite, rmcp derive un schema de sortie qui
444        // conserve des $ref qu'un client aveugle aux $defs ne resout pas —
445        // or les SDK MCP valident structuredContent contre ce schema.
446        output_schema = crate::schema::wire_safe_output_schema::<UnrelateResult>(),
447        description = "Remove the typed link `from` -relation-> `to` — `relate`'s exact undo, so a mistaken edge no longer costs the facts at its endpoints. Only the edge is removed: the two memories, and any entity, are untouched. Idempotent: removing an absent edge answers `found: false` instead of erroring, so a cleanup can be replayed safely; `removed` counts the edges actually deleted. It refuses exactly what `relate` refuses (empty relation, `from` == `to`). Scope: the store does not distinguish a link you created with `relate` from one auto-derived from a passage, so `unrelate` removes both alike — to correct an auto-derived link, prefer `forget` + `remember` of the source fact, otherwise remembering the same passage again can rebuild the edge removed here. Same id wire contract as `relate`: pass ids as decimal strings (`id_str`) — a JSON-number id above 2^53 loses precision on float-lossy clients.",
448        input_schema = id_wire_input_schema::<UnrelateParams>(&["from", "to"])
449    )]
450    async fn unrelate(
451        &self,
452        Parameters(params): Parameters<UnrelateParams>,
453    ) -> Result<Json<UnrelateResult>, ErrorData> {
454        let service = Arc::clone(&self.service);
455        let UnrelateParams { from, to, relation } = params;
456        let outcome = tokio::task::spawn_blocking(move || {
457            service.run(|current| current.unrelate(from, to, &relation))
458        })
459        .await
460        .map_err(join_error)?
461        .map_err(to_error)?;
462        Ok(Json(UnrelateResult {
463            found: outcome.found,
464            removed: outcome.removed,
465        }))
466    }
467
468    #[tool(
469        name = "forget",
470        // Sans declaration explicite, rmcp derive un schema de sortie qui
471        // conserve des $ref qu'un client aveugle aux $defs ne resout pas —
472        // or les SDK MCP valident structuredContent contre ce schema.
473        output_schema = crate::schema::wire_safe_output_schema::<ForgetResult>(),
474        description = "Permanently delete a memory by its `id` (as returned by `remember` or `recall`), removing the fact and its graph links. The deletion is durable and cannot be undone — use it to retract or correct stored knowledge. For automatic time-based expiry instead, set a TTL when calling `remember`. Returns the requested id plus `found`: `true` if a memory actually existed and was deleted, `false` if nothing was stored under that id (a stale id or a typo) — a no-op, not an error, but distinguishable from a real deletion.",
475        input_schema = id_wire_input_schema::<ForgetParams>(&["id"])
476    )]
477    async fn forget(
478        &self,
479        Parameters(params): Parameters<ForgetParams>,
480    ) -> Result<Json<ForgetResult>, ErrorData> {
481        let service = Arc::clone(&self.service);
482        let id = params.id;
483        let found = tokio::task::spawn_blocking(move || service.run(|current| current.forget(id)))
484            .await
485            .map_err(join_error)?
486            .map_err(to_error)?;
487        Ok(Json(ForgetResult {
488            id,
489            id_str: id.to_string(),
490            found,
491        }))
492    }
493
494    #[tool(
495        name = "entity",
496        // Sans declaration explicite, rmcp derive un schema de sortie qui
497        // conserve des $ref qu'un client aveugle aux $defs ne resout pas —
498        // or les SDK MCP valident structuredContent contre ce schema.
499        output_schema = crate::schema::wire_safe_output_schema::<EntityProfileDto>(),
500        description = "Look up everything the memory graph knows about a NAMED ENTITY (a person, a place, an organisation): the attributes it carries, the typed edges leaving it (`relations`) and the typed edges pointing AT it (`relations_in`). Both directions come back, because a question is only answerable from one side: with `camille --sister of--> theo` recorded, asking what Theo's OUTGOING edges say never finds Camille — she is in his `relations_in`. Use this for questions ABOUT a thing rather than about a sentence — \"how old is Theo\", \"who is Theo's father\", \"where does he live\" — where `recall` would only return sentences that happen to mention the name. Entities and their edges are built automatically by `remember_extracted`, which reads relationships (`X is the father of Y`) and properties (`Y is 15`) out of plain text; attributes land in ColumnStore metadata with their JSON type preserved, so a number stays a number. The name is matched case-insensitively, so `\"Theo Durand\"` and `\"theo durand\"` are the same entity — the id is content-addressed, so it is stable across sessions. Returns `found: false` when nothing has ever mentioned that name; `name` is echoed back in its canonical (trimmed, lowercased) form either way, so several lookups can be told apart. With the async autograph worker active, edges derived from a `remember` land asynchronously: an entity read immediately after that remember may not see them yet — the fact itself is always immediately readable. Ids exceed 2^53 — always relay them as strings (`id_str`)."
501    )]
502    async fn entity(
503        &self,
504        Parameters(params): Parameters<EntityParams>,
505    ) -> Result<Json<EntityProfileDto>, ErrorData> {
506        let service = Arc::clone(&self.service);
507        let EntityParams { name } = params;
508        let looked_up = name.clone();
509        let profile = tokio::task::spawn_blocking(move || {
510            service.run(|current| current.entity_profile(&looked_up))
511        })
512        .await
513        .map_err(join_error)?
514        .map_err(to_error)?;
515        Ok(Json(EntityProfileDto::from_lookup(&name, profile)))
516    }
517}
518
519/// `#[tool_handler]` generates `list_tools` from the router — `call_tool` is
520/// written by hand below (see its doc comment) and the macro skips what
521/// already exists. `get_info` is overridden so the server identifies itself
522/// as `velesdb-memory` (the macro default falls back to rmcp's own
523/// identity). Per-tool guidance lives in each `#[tool(description = …)]`.
524/// The server's one-shot vitrine to a connecting agent (V2a-1 quick win):
525/// must cover every tool family, not just memory — a `#[cfg(feature =
526/// "context")]` variant since the context-compiler tools only exist in that
527/// build.
528#[cfg(feature = "context")]
529const SERVER_INSTRUCTIONS: &str = "Local-first memory and context engineering for AI agents, four tool families: (1) durable memory — remember, remember_extracted, extraction_status, recall, recall_fused, recall_where, relate, unrelate, forget, feedback, entity, and why — explainable (why returns the evidence trail) and self-improving (feedback re-ranks future recall); remember_extracted durably accepts a passage, then reads the entities, typed edges and attributes it STATES and wires them into the graph in the background: keep its request_id and poll extraction_status until committed or failed, reusing one idempotency_key across transport retries; entity(name) answers a question ABOUT a named thing rather than about the sentences mentioning it; memory_status reports the server's health — which embedder runs and whether recall is semantic, extraction wiring, and graph size; list_memories audits the store page by page — what recall cannot answer, because what resembles no query stays invisible; (2) online embedding migration — migration_start returns after durable acceptance, migration_status reports progress and recovery, migration_cancel is safe only while the source is authoritative, and migration_recover resumes a stopped pre-cutover job; (3) the deterministic context compiler — compile_context, compile_transcript, explain_compilation, retrieve_context_source, context_savings, and suggest_budget — token-budgets and audits prompt context with no LLM call, ever; (4) cross-session working-context resumption — save_working_context, load_working_context, and list_working_contexts. compile_context/explain_compilation fragments accept a `path` instead of inline `content` to ingest a file by reference — disabled unless the server is started with VELESDB_MEMORY_INGEST_ROOTS set to an allowlist of directories (compile_transcript's own `path` field uses the same allowlist). compile_transcript is a one-call shortcut over compile_context for a raw agent-session transcript: it segments plain or JSONL text into turns before compiling, so an agent no longer needs to segment a transcript by hand. Nothing ever leaves the machine.";
530
531#[cfg(not(feature = "context"))]
532const SERVER_INSTRUCTIONS: &str = "Local-first memory for AI agents: remember facts, recall them \
533     semantically, relate them, forget them, ask why a decision was made (connected subgraph), \
534     submit durable remember_extracted jobs and poll extraction_status to completion, \
535     read memory_status for the server's health — embedder semantics, extraction wiring, \
536     graph size — audit the store page by page with list_memories, and control daemon-owned \
537     online embedding migration with migration_start/status/cancel/recover.";
538
539#[tool_handler(router = self.tool_router)]
540impl ServerHandler for McpServer {
541    fn get_info(&self) -> ServerInfo {
542        let mut info = ServerInfo::default();
543        info.server_info = Implementation::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
544        info.capabilities = ServerCapabilities::builder().enable_tools().build();
545        let mut instructions = SERVER_INSTRUCTIONS.to_owned();
546        // The one channel a client is REQUIRED to read. The stderr warning
547        // for the same fact is swallowed by every mainstream MCP harness, so
548        // a degraded server that only warned there was indistinguishable
549        // from a healthy one — the audit finding memory_status closes, and
550        // this note is its push half (the tool is the pull half).
551        if self
552            .service
553            .with_generation(|generation| generation.model() == "hash")
554            .unwrap_or(false)
555        {
556            instructions.push_str(
557                " NOTE: this server is running the offline 'hash' embedder — recall matches \
558                 surface form, NOT meaning. If recall quality matters to the user, say so: a \
559                 semantic embedder is an env-var switch away (call memory_status for details).",
560            );
561        }
562        info.instructions = Some(instructions);
563        info
564    }
565
566    /// One trace event per tool call (#1780): tool name, session id, verdict,
567    /// duration — never an argument or fact content
568    /// (`tests/daemon_logging.rs` holds a canary against that). Written by
569    /// hand so the event wraps the dispatch — `#[tool_handler]` sees the
570    /// method already exists and only generates `list_tools`; the dispatch
571    /// below is exactly what the macro would have generated.
572    async fn call_tool(
573        &self,
574        request: rmcp::model::CallToolRequestParams,
575        context: rmcp::service::RequestContext<rmcp::RoleServer>,
576    ) -> Result<rmcp::model::CallToolResponse, ErrorData> {
577        let tool = request.name.clone();
578        let session = http_session_id(&context.extensions);
579        let started = std::time::Instant::now();
580        let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
581        let outcome = self.tool_router.call(tcc).await;
582        log_tool_call(&tool, session.as_deref(), &outcome, started);
583        outcome
584    }
585}
586
587/// The tool-level trace event (#1780), split from `call_tool` so the verdict
588/// taxonomy is readable in one place. `Err` is a protocol-level failure, but
589/// a *refused* tool call comes back as `Ok` with `is_error` set INSIDE a
590/// valid result — reading only the outer `Result` would log every refusal as
591/// a success, the exact misreading this event exists to prevent.
592fn log_tool_call(
593    tool: &str,
594    session: Option<&str>,
595    outcome: &Result<rmcp::model::CallToolResponse, ErrorData>,
596    started: std::time::Instant,
597) {
598    use rmcp::model::CallToolResponse;
599    let verdict = match outcome {
600        Err(_) => "error",
601        Ok(CallToolResponse::Complete(result)) if result.is_error == Some(true) => "tool_error",
602        Ok(CallToolResponse::Complete(_)) => "ok",
603        // rmcp 3's InputRequired/Task responses (SEP-2663) carry no verdict:
604        // the call has not completed. No tool of this server produces them —
605        // if one ever appears here, "pending" keeps the event truthful
606        // instead of misreporting an unfinished call as a success.
607        Ok(_) => "pending",
608    };
609    // `%` (Display) rather than the default Debug capture: Debug renders
610    // strings quoted (`tool="recall"`), and these lines exist to be grepped
611    // (`grep tool=recall`) by an operator mid-incident. The target is pinned
612    // explicitly for the same operators: a module refactor must not silently
613    // rename the lines their tooling matches on.
614    tracing::info!(
615        target: "velesdb_memory::mcp",
616        tool = %tool,
617        session = %session.unwrap_or(crate::logging::NO_SESSION),
618        verdict = %verdict,
619        elapsed_ms = crate::logging::elapsed_millis(started),
620        "mcp tool call"
621    );
622}
623
624/// The `mcp-session-id` this call arrived under, read from the HTTP request
625/// parts rmcp injects into the request's extensions (its streamable-HTTP
626/// tower service does this for every incoming `POST`). HTTP transport only —
627/// there is no session id on stdio, and the event then reports `-`.
628#[cfg(feature = "http")]
629fn http_session_id(extensions: &rmcp::model::Extensions) -> Option<String> {
630    extensions
631        .get::<axum::http::request::Parts>()
632        .and_then(|parts| crate::http::session_from_headers(&parts.headers))
633}
634
635/// Without the HTTP transport nothing ever injects request parts, so there
636/// is no session id to read — same cfg-pair shape as the binary's
637/// transport-dependent helpers.
638#[cfg(not(feature = "http"))]
639fn http_session_id(_extensions: &rmcp::model::Extensions) -> Option<String> {
640    None
641}
642
643/// La post-condition du point de passage, verifiee SUR PLACE.
644///
645/// Un test lointain constate ; ici on refuse. Les schemas annonces sont
646/// statiques — ils ne dependent ni du store, ni de l'horloge, ni d'une
647/// entree — donc une violation est deterministe : elle ne peut pas se
648/// produire chez un utilisateur sans se produire aussi au premier
649/// `McpServer::new` de la suite de tests. Echouer a la construction dit ou
650/// est le probleme ; laisser passer un slot intypable le fait ressortir
651/// plusieurs jours plus tard, dans un aller-retour de deserialisation chez
652/// un agent.
653///
654/// # Panics
655/// Si une route publie un slot d'entree qui n'annonce ni `type`, ni `enum`,
656/// ni `const`.
657fn assert_every_input_slot_is_typed(router: &ToolRouter<McpServer>) {
658    let mut offenders: Vec<String> = Vec::new();
659    for route in router.map.values() {
660        for slot in crate::schema::untyped_input_slots(&route.attr.input_schema) {
661            offenders.push(format!("  {}: {slot}", route.attr.name));
662        }
663    }
664    assert!(
665        offenders.is_empty(),
666        "{} slot(s) d'entree n'annoncent aucun type — un harnais client les rend `{{}}`, le \
667         client envoie ce qu'il devine, et le serveur le refuse :\n{}",
668        offenders.len(),
669        offenders.join("\n")
670    );
671}
672
673#[allow(clippy::needless_pass_by_value)]
674fn job_error(error: JobError) -> ErrorData {
675    let code = match error {
676        JobError::Invalid(_) | JobError::Conflict | JobError::NotFound(_) => {
677            ErrorCode::INVALID_PARAMS
678        }
679        JobError::AtCapacity | JobError::BackendNotConfigured | JobError::Storage(_) => {
680            ErrorCode::INTERNAL_ERROR
681        }
682    };
683    ErrorData::new(code, error.to_string(), None)
684}
685
686/// Map a `spawn_blocking` join failure (a panicked or cancelled tool task) to an
687/// MCP error. Every tool body runs on the blocking pool, so they all funnel
688/// through this on the (rare) task-failure path.
689///
690/// Takes the error by value so it can be used as `.map_err(join_error)`.
691#[allow(clippy::needless_pass_by_value)]
692fn join_error(join: tokio::task::JoinError) -> ErrorData {
693    ErrorData::new(
694        ErrorCode::INTERNAL_ERROR,
695        format!("memory task failed: {join}"),
696        None,
697    )
698}
699
700/// Map a domain error to an MCP error.
701///
702/// Map a [`MemoryError`](crate::error::MemoryError) onto a JSON-RPC error,
703/// driven by its transport-neutral [`ErrorCategory`](crate::error::ErrorCategory)
704/// so the MCP taxonomy can never drift from the bindings'. Client-input errors
705/// become `invalid_params` (-32602); genuine faults `internal_error` (-32603).
706/// JSON-RPC defines no "not found" code, so a missing id is reported as
707/// `invalid_params` (a bad id is, from the protocol's view, a bad parameter).
708///
709/// Takes the error by value so it can be used as `.map_err(to_error)` at every
710/// call site without a per-site closure.
711#[allow(clippy::needless_pass_by_value)]
712fn to_error(err: crate::error::MemoryError) -> ErrorData {
713    use crate::error::ErrorCategory;
714    let code = match err.category() {
715        ErrorCategory::InvalidInput | ErrorCategory::NotFound => ErrorCode::INVALID_PARAMS,
716        // A capability gap is the server's to own, not the caller's: the
717        // request was well-formed, this backend just cannot serve it.
718        // JSON-RPC has no "unsupported" code, so it rides internal_error
719        // with the message naming the missing capability.
720        ErrorCategory::Internal | ErrorCategory::Unsupported => ErrorCode::INTERNAL_ERROR,
721    };
722    ErrorData::new(code, err.to_string(), None)
723}
724
725#[cfg(all(test, feature = "persistence"))]
726#[path = "mcp/generation_tests.rs"]
727mod generation_tests;
728#[cfg(test)]
729#[path = "mcp/server_tests.rs"]
730mod tests;