1use std::sync::Arc;
2
3use rmcp::handler::server::wrapper::{Json, Parameters};
4use rmcp::{tool, tool_router, ErrorData};
5
6use super::dto::{
7 ExplanationDto, ExtractionJobStatusParams, ExtractionJobStatusResult, ListMemoriesParams,
8 ListMemoriesResult, ListedMemoryDto, RecallFusedParams, RecallFusedResult,
9 RememberExtractedParams, RememberExtractedResult, WhyParams,
10};
11use super::{id_wire_input_schema, job_error, join_error, to_error, McpServer};
12use crate::limits::{DEFAULT_WHY_HOPS, MAX_FACT_BYTES, MAX_RECALL_LIMIT, MAX_WHY_HOPS};
13use crate::model::FusionOptions;
14
15const DEFAULT_LIST_LIMIT: usize = 50;
16const DEFAULT_RECALL_LIMIT: usize = 10;
17
18#[tool_router(router = advanced_tool_router, vis = "pub(super)")]
19impl McpServer {
20 #[tool(
21 name = "recall_fused",
22 output_schema = crate::schema::wire_safe_output_schema::<RecallFusedResult>(),
23 description = "Fused vector + graph recall: like `recall`, but also walks the graph from the top vector hit and folds any connected fact into the ranking — the tri-engine ranking (vector similarity + ColumnStore filter + graph reach) measured on multi-hop and temporal benchmarks. Reach for this when an answer needs a fact the query doesn't mention directly but a stored `relate`/extracted link connects (multi-hop reasoning, temporal chains). `hops`/`graph_boost` tune the graph reach and `pool` the depth of the vector candidate pool fusion re-ranks; omit them for the proven defaults. Optionally narrow with an exact-match `filter`. Set `date_field` (the metadata key holding a YYYYMMDD date) to also get a `dated_context` timeline and a `now` anchor for temporal questions. Most relevant first."
24 )]
25 pub(super) async fn recall_fused(
26 &self,
27 Parameters(params): Parameters<RecallFusedParams>,
28 ) -> Result<Json<RecallFusedResult>, ErrorData> {
29 let k = params
30 .limit
31 .unwrap_or(DEFAULT_RECALL_LIMIT)
32 .min(MAX_RECALL_LIMIT);
33 let opts = FusionOptions::from_knobs(params.hops, params.graph_boost, params.pool);
34 let service = Arc::clone(&self.service);
35 let RecallFusedParams {
36 query,
37 filter,
38 date_field,
39 ..
40 } = params;
41 let (memories, dated_context, now) = if let Some(field) = date_field {
42 let (hits, ctx) = tokio::task::spawn_blocking(move || {
43 service.run(|current| {
44 current.recall_fused_dated(&query, k, filter.as_ref(), opts, &field)
45 })
46 })
47 .await
48 .map_err(join_error)?
49 .map_err(to_error)?;
50 (hits, Some(ctx.timeline), ctx.now)
51 } else {
52 let hits = tokio::task::spawn_blocking(move || {
53 service.run(|current| current.recall_fused(&query, k, filter.as_ref(), opts))
54 })
55 .await
56 .map_err(join_error)?
57 .map_err(to_error)?;
58 (hits, None, None)
59 };
60 Ok(Json(RecallFusedResult::new(memories, dated_context, now)))
61 }
62
63 #[tool(
64 name = "why",
65 output_schema = crate::schema::wire_safe_output_schema::<ExplanationDto>(),
66 description = "Explain a decision: find the best-matching memory (optionally scoped by a metadata `filter`, e.g. the current project) and return the connected subgraph of related memories reachable through typed links — fusing vector, ColumnStore, and graph to surface context a plain similarity search misses."
67 )]
68 pub(super) async fn why(
69 &self,
70 Parameters(params): Parameters<WhyParams>,
71 ) -> Result<Json<ExplanationDto>, ErrorData> {
72 let max_hops = params
73 .max_hops
74 .unwrap_or(DEFAULT_WHY_HOPS)
75 .min(MAX_WHY_HOPS);
76 let service = Arc::clone(&self.service);
77 let WhyParams {
78 decision, filter, ..
79 } = params;
80 let explanation = tokio::task::spawn_blocking(move || {
81 service.run(|current| current.why(&decision, max_hops, filter.as_ref()))
82 })
83 .await
84 .map_err(join_error)?
85 .map_err(to_error)?;
86 Ok(Json(ExplanationDto::from(explanation)))
87 }
88
89 #[tool(
90 name = "remember_extracted",
91 output_schema = crate::schema::wire_safe_output_schema::<RememberExtractedResult>(),
92 description = "Accept a passage for durable background extraction and return before model generation. Set `extractor` per call (`outline`, `ollama`, or `openai`), or omit it to use VELESDB_MEMORY_EXTRACTOR. Supply `idempotency_key` when retrying across a client timeout: the same key and payload reuse one job, while a changed payload is rejected. The receipt returns `request_id`, its initial `state` (`accepted`, or the persisted state of a reused request), and `reused`. Poll `extraction_status(request_id)` until `committed` or `failed`; accepted/running jobs survive process restart."
93 )]
94 pub(super) async fn remember_extracted(
95 &self,
96 Parameters(params): Parameters<RememberExtractedParams>,
97 ) -> Result<Json<RememberExtractedResult>, ErrorData> {
98 let RememberExtractedParams {
99 text,
100 metadata,
101 extractor,
102 idempotency_key,
103 } = params;
104 if text.len() > MAX_FACT_BYTES {
105 return Err(ErrorData::new(
106 rmcp::model::ErrorCode::INVALID_PARAMS,
107 format!("text exceeds maximum size of {MAX_FACT_BYTES} bytes"),
108 None,
109 ));
110 }
111 let jobs = self.extraction_jobs.clone().ok_or_else(|| {
112 ErrorData::new(
113 rmcp::model::ErrorCode::INTERNAL_ERROR,
114 "durable extraction jobs are not configured for this server",
115 None,
116 )
117 })?;
118 let receipt = tokio::task::spawn_blocking(move || {
119 jobs.submit(
120 &text,
121 metadata,
122 extractor.as_deref(),
123 idempotency_key.as_deref(),
124 )
125 })
126 .await
127 .map_err(join_error)?
128 .map_err(job_error)?;
129 Ok(Json(RememberExtractedResult {
130 request_id: receipt.request_id,
131 state: receipt.state,
132 reused: receipt.reused,
133 }))
134 }
135
136 #[tool(
137 name = "extraction_status",
138 output_schema = crate::schema::wire_safe_output_schema::<ExtractionJobStatusResult>(),
139 description = "Read one durable extraction job by the `request_id` returned from `remember_extracted`. Returns that `request_id`, its persisted `state` (`accepted`, `running`, `committed`, or `failed`), committed fact `ids` and their u64-safe decimal `ids_str` twins, `skipped_over_cap` after commit, and `error` after failure. While accepted/running, `ids` and `ids_str` are empty and both optional terminal fields are null."
140 )]
141 pub(super) async fn extraction_status(
142 &self,
143 Parameters(params): Parameters<ExtractionJobStatusParams>,
144 ) -> Result<Json<ExtractionJobStatusResult>, ErrorData> {
145 let jobs = self.extraction_jobs.clone().ok_or_else(|| {
146 ErrorData::new(
147 rmcp::model::ErrorCode::INTERNAL_ERROR,
148 "durable extraction jobs are not configured for this server",
149 None,
150 )
151 })?;
152 let view = tokio::task::spawn_blocking(move || jobs.status(¶ms.request_id))
153 .await
154 .map_err(join_error)?
155 .map_err(job_error)?;
156 let (ids, skipped_over_cap) = view.outcome.map_or_else(
157 || (Vec::new(), None),
158 |outcome| (outcome.ids, Some(outcome.skipped_over_cap)),
159 );
160 let ids_str = ids.iter().map(u64::to_string).collect();
161 Ok(Json(ExtractionJobStatusResult {
162 request_id: view.request_id,
163 state: view.state,
164 ids,
165 ids_str,
166 skipped_over_cap,
167 error: view.error,
168 }))
169 }
170
171 #[tool(
172 name = "list_memories",
173 output_schema = crate::schema::wire_safe_output_schema::<ListMemoriesResult>(),
174 input_schema = id_wire_input_schema::<ListMemoriesParams>(&["cursor"]),
175 description = "AUDIT the store: walk every stored fact, page by page — the question `recall` structurally cannot answer, because recall ranks by resemblance to a query and what resembles nothing you thought to ask stays invisible. Use it when the user asks what the memory contains ('what do you know about me / this project?'), to review or clean up before sharing a store, or to back up its contents. Returns `memories` (ids ascending — two audits of the same store see the same order; each entry carries `id`, `id_str`, `content`, `metadata`) and `next_cursor`: pass it back as `cursor` for the next page, `null` means the walk is complete. `filter` keeps only facts whose metadata equals every given key (e.g. {\"project\": \"acme\"}); a filtered page may come back sparse — KEEP following `next_cursor`, the walk stays exhaustive. Metadata follows recall's visibility rule (business keys plus the auto-stamped `_veles_date`; internal graph scaffolding excluded) unless `include_internal` is set, which lists everything verbatim. Ids exceed 2^53 — always relay them as strings (`id_str`, and `next_cursor` is already a string)."
176 )]
177 pub(super) async fn list_memories(
178 &self,
179 Parameters(params): Parameters<ListMemoriesParams>,
180 ) -> Result<Json<ListMemoriesResult>, ErrorData> {
181 let service = Arc::clone(&self.service);
182 let ListMemoriesParams {
183 cursor,
184 limit,
185 filter,
186 include_internal,
187 } = params;
188 let (memories, next) = tokio::task::spawn_blocking(move || {
189 service.run(|current| {
190 current.list(
191 cursor,
192 limit.unwrap_or(DEFAULT_LIST_LIMIT),
193 filter.as_ref(),
194 include_internal,
195 )
196 })
197 })
198 .await
199 .map_err(join_error)?
200 .map_err(to_error)?;
201 Ok(Json(ListMemoriesResult {
202 memories: memories
203 .into_iter()
204 .map(|memory| ListedMemoryDto {
205 id: memory.id,
206 id_str: memory.id.to_string(),
207 content: memory.content,
208 metadata: memory.metadata,
209 })
210 .collect(),
211 next_cursor: next.map(|id| id.to_string()),
212 }))
213 }
214}