1use 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
17const DEFAULT_RECALL_LIMIT: usize = 10;
19
20const UNREPORTED_MODEL: &str = "unreported";
21
22use crate::embedder::DynEmbedder;
26use crate::extract::DynExtractor;
27
28#[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
56use crate::schema::wire_safe_input_schema as id_wire_input_schema;
70
71#[derive(Clone)]
75pub struct McpServer {
76 service: Arc<LiveGenerationSlot<DynEmbedder>>,
77 _autograph_worker: Option<Arc<crate::service::AutographWorkerHandle>>,
83 extractors: Arc<parking_lot::RwLock<ExtractorResolver>>,
85 extraction_jobs: Option<ExtractionJobs>,
88 online_migration: Option<Arc<crate::service::OnlineMigrationManager<DynEmbedder>>>,
90 default_ttl: Option<u64>,
94 store_dir: Option<std::path::PathBuf>,
99 #[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 #[must_use]
112 pub fn new(service: MemoryService<DynEmbedder>) -> Self {
113 let service = Arc::new(LiveGenerationSlot::new(service, UNREPORTED_MODEL));
114 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 #[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 #[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 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 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 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 #[must_use]
229 pub fn with_extractor(self, extractor: DynExtractor) -> Self {
230 *self.extractors.write() = ExtractorResolver::unnamed(extractor);
231 self
232 }
233
234 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 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 #[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 #[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 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 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 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 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 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 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 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 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 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 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#[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 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 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
587fn 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 Ok(_) => "pending",
608 };
609 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#[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#[cfg(not(feature = "http"))]
639fn http_session_id(_extensions: &rmcp::model::Extensions) -> Option<String> {
640 None
641}
642
643fn 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#[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#[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 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;