Skip to main content

velesdb_memory/mcp/
context_tools.rs

1//! The context compiler's MCP tools — an *extension* of the one existing
2//! server (never a second server): a second `#[tool_router]` block whose
3//! router is combined with the main one in `McpServer::new`.
4//!
5//! Wire shapes reuse the domain types from [`crate::context`] directly
6//! (`CompileRequest` *is* the tool input, `CompiledContext` the output) —
7//! the only DTOs here are the thin request envelopes of the seven smaller
8//! tools. Same conventions as every other tool: `spawn_blocking` around the
9//! sync service, errors mapped through the transport-neutral category.
10
11use std::sync::Arc;
12
13use rmcp::handler::server::wrapper::{Json, Parameters};
14use rmcp::model::ErrorCode;
15use rmcp::{tool, tool_router, ErrorData};
16use schemars::JsonSchema;
17use serde::Deserialize;
18use serde_json::Value;
19
20use super::{join_error, to_error, McpServer};
21use crate::context::wire::stringify_id_fields;
22use crate::context::{
23    fragment_id, segment_transcript, suggest_token_budget, CompilePolicy, CompileRequest,
24    CompiledContext, ContextCompiler, ContextDecision, ContextFragment, ContextSavings,
25    LoadedWorkingContext, MediaRef, SegmentFormat, SegmentKind, SegmentationPolicy,
26    SuggestedBudget, WorkingContext, WorkingContextSession,
27};
28
29/// Serialize `payload`, opt-in rewriting every id field into decimal-string
30/// form ([`CompilePolicy::ids_as_strings`]) — the shared response-side half
31/// of the wire-compat contract, reused by both `compile_context` and
32/// `explain_compilation` so the id rewrite is expressed exactly once.
33fn to_wire_value<T: serde::Serialize>(
34    payload: &T,
35    ids_as_strings: bool,
36) -> Result<Value, ErrorData> {
37    let mut value = serde_json::to_value(payload).map_err(|err| {
38        ErrorData::internal_error(
39            format!("Failed to serialize structured content: {err}"),
40            None,
41        )
42    })?;
43    if ids_as_strings {
44        stringify_id_fields(&mut value);
45    }
46    Ok(value)
47}
48
49fn segment_for_compilation(
50    text: &str,
51    policy: &SegmentationPolicy,
52) -> Result<(Vec<ContextFragment>, SegmentationReport), ErrorData> {
53    let outcome = segment_transcript(text, policy).map_err(to_error)?;
54    let segments = outcome
55        .segments
56        .iter()
57        .enumerate()
58        .map(|(index, segment)| SegmentInfo {
59            index,
60            turn: segment.turn,
61            role: segment.role.clone(),
62            kind: segment.kind,
63            byte_start: segment.byte_start,
64            byte_end: segment.byte_end,
65            fragment_id: fragment_id(&segment.fragment.content),
66        })
67        .collect();
68    let fragments = outcome
69        .segments
70        .into_iter()
71        .map(|segment| segment.fragment)
72        .collect();
73    let report = SegmentationReport {
74        format_detected: outcome.format_detected,
75        segments,
76        merged_segments: outcome.merged_segments,
77    };
78    Ok((fragments, report))
79}
80
81/// The advertised-schema half of the [`CompilePolicy::ids_as_strings`]
82/// contract: the response may carry each [`ID_KEYS`] field as an integer OR
83/// a decimal string, and the official MCP SDKs validate `structuredContent`
84/// against the advertised `outputSchema` (spec 2025-06-18) — so those
85/// fields must be typed `["integer", "string"]`, or every opted-in response
86/// would fail client-side validation for exactly the clients the option
87/// exists for.
88pub(super) use crate::schema::wire_safe_output_schema;
89
90/// Input-side counterpart: `fragments[].id` accepts an integer or a decimal
91/// string ([`crate::context::wire::deserialize_optional_id`]), so the
92/// advertised input schema announces the string form — a client generating
93/// requests from the schema must be able to discover it.
94///
95/// Le jeu de cles n'est plus fige a `"id"` : il est passe par l'outil, comme
96/// dans `mcp.rs`, parce que `save_working_context` porte des ids sous
97/// d'autres noms (`fragment_id`, `memory_id`, imbriques dans
98/// `WorkingContext`) tandis que `explain_compilation.fragment_id` est un
99/// `u64` STRICT qu'annoncer `string` serait une promesse fausse. Un seul
100/// constructeur, donc, mais toujours une decision par outil.
101pub(super) use crate::schema::wire_safe_input_schema;
102
103// --- Thin request envelopes --------------------------------------------------
104
105/// Input of the `context_savings` tool.
106#[derive(Debug, Deserialize, JsonSchema)]
107pub(super) struct ContextSavingsParams {
108    /// Restrict the aggregation to this project facet.
109    pub project: Option<String>,
110}
111
112/// Input of the `explain_compilation` tool.
113#[derive(Debug, Deserialize, JsonSchema)]
114#[schemars(transform = crate::schema::strip_int_formats)]
115pub(super) struct ExplainCompilationParams {
116    /// The compile request to explain (compilation is deterministic, so
117    /// re-submitting the request reproduces the exact decisions).
118    #[serde(deserialize_with = "super::wire::lenient")]
119    pub request: CompileRequest,
120    // Aller-retour casse jusqu'au 2026-07-29, et casse depuis toujours :
121    // `fragment_id` est le SELECTEUR d'une decision, et la decision d'ou le
122    // client le tire lui arrive en CHAINE decimale des que la requete porte
123    // `policy.ids_as_strings` (`fragment_id` est dans
124    // `context::wire::ID_KEYS`). Ce champ etait un `u64` nu : l'outil
125    // refusait litteralement les octets qu'il venait d'emettre, et comme un
126    // `fragment_id` est un FNV-1a 64 — au-dela de 2^53 dans ~99,95 % des cas
127    // — le repli « renvoyer un nombre » etait deja arrondi chez un client
128    // JSON a nombres flottants. Les deux formes echouaient : sur un tel
129    // client, l'outil etait inatteignable par son propre selecteur.
130    //
131    // Ce n'etait pas une omission : trois tests et deux jeux de cles d'id
132    // epinglaient la forme stricte comme voulue. Ce que personne n'avait
133    // fait, c'est l'aller-retour.
134    /// The fragment whose decision to return. Looked up by matching
135    /// `ContextDecision::fragment_id`, UNLESS `fragment_index` is also
136    /// given (see there) — still required even then, since it is the only
137    /// disambiguator when `fragment_index` is absent. Accepts a JSON number
138    /// OR a decimal string, so a `fragment_id` received under
139    /// [`CompilePolicy::ids_as_strings`] can be relayed back unchanged.
140    #[serde(deserialize_with = "crate::model::deserialize_id")]
141    pub fragment_id: u64,
142    /// Optional, 0-based position of the fragment in `request.fragments`.
143    /// When given, TAKES PRIORITY over `fragment_id` for locating the
144    /// decision: `compile_context` records exactly one decision per input
145    /// fragment, in order, so `decisions[fragment_index]` is unambiguous
146    /// even when several fragments are byte-identical and therefore share
147    /// the same content-addressed `fragment_id` — a plain `fragment_id`
148    /// lookup always returns the FIRST such decision (the deduplication
149    /// survivor), never a dropped twin's. Absent (the default): behavior is
150    /// unchanged, the decision is found by `fragment_id` alone.
151    #[serde(
152        default,
153        skip_serializing_if = "Option::is_none",
154        deserialize_with = "super::wire::lenient"
155    )]
156    pub fragment_index: Option<usize>,
157}
158
159/// Input of the `retrieve_context_source` tool.
160#[derive(Debug, Deserialize, JsonSchema)]
161pub(super) struct RetrieveContextSourceParams {
162    /// A `ctx://source/<hash>` handle from a compiled context.
163    pub handle: String,
164}
165
166/// Output of the `retrieve_context_source` tool.
167#[derive(Debug, serde::Serialize, JsonSchema)]
168pub(super) struct RetrieveContextSourceResult {
169    /// The handle that was resolved.
170    pub handle: String,
171    /// The original fragment content, byte for byte.
172    pub content: String,
173    /// The original media payload, when the fragment carried one (US-009,
174    /// PR2). Absent for every text-only source — the exact pre-PR2 shape.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub media: Option<MediaRef>,
177}
178
179/// Input of the `save_working_context` tool.
180#[derive(Debug, Deserialize, JsonSchema)]
181#[schemars(transform = crate::schema::strip_int_formats)]
182pub(super) struct SaveWorkingContextParams {
183    /// Project facet this working context belongs to (matches `remember`'s
184    /// `project` metadata convention).
185    pub project: String,
186    /// Session identifier — pick something stable for the agent run you want
187    /// to resume later (e.g. a conversation id).
188    pub session: String,
189    /// The distilled state to persist: goal, active constraints, verified
190    /// facts, open hypotheses, decisions taken, exact evidence, and pending
191    /// actions.
192    #[serde(deserialize_with = "super::wire::lenient")]
193    pub working: WorkingContext,
194}
195
196/// Output of the `save_working_context` tool.
197#[derive(Debug, serde::Serialize, JsonSchema)]
198#[schemars(transform = crate::schema::strip_int_formats)]
199pub(super) struct SaveWorkingContextResult {
200    /// Id of the stored system fact backing this working context.
201    pub id: u64,
202    /// Decimal-string twin of `id`, same contract as
203    /// [`crate::mcp::dto::RememberResult::id_str`]: the id is content-addressed
204    /// (FNV-1a 64), so it is past 2^53 and a float-lossy JSON client rounds
205    /// `id` on arrival. This was the ONE tool handing back an id without its
206    /// twin while `forget`/`feedback` accept only the decimal string — the
207    /// caller had no way to build the form the schema demands.
208    pub id_str: String,
209}
210
211/// Input of the `load_working_context` tool.
212#[derive(Debug, Deserialize, JsonSchema)]
213pub(super) struct LoadWorkingContextParams {
214    /// Project facet the working context was saved under.
215    pub project: String,
216    /// Session identifier the working context was saved under.
217    pub session: String,
218}
219
220/// Input of the `list_working_contexts` tool.
221#[derive(Debug, Deserialize, JsonSchema)]
222pub(super) struct ListWorkingContextsParams {
223    /// Project facet to list saved working-context sessions for (same
224    /// convention as `save_working_context`'s `project`).
225    pub project: String,
226}
227
228/// Output of the `list_working_contexts` tool.
229#[derive(Debug, serde::Serialize, JsonSchema)]
230pub(super) struct ListWorkingContextsResult {
231    /// Every session saved under this project, most-recently-saved first.
232    /// Empty (not an error) when the project never saved anything.
233    pub sessions: Vec<WorkingContextSession>,
234}
235
236/// Input of the `compile_transcript` tool.
237#[derive(Debug, Deserialize, JsonSchema)]
238#[schemars(transform = crate::schema::strip_int_formats)]
239pub(super) struct CompileTranscriptParams {
240    /// What the agent is working on — drives relevance scoring, exactly like
241    /// `compile_context`'s `query`.
242    pub query: String,
243    /// The raw transcript text (plain, marker-based, or JSONL). Exactly one
244    /// of `transcript` or `path` must be set.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub transcript: Option<String>,
247    /// Read the transcript from this absolute filesystem path instead of
248    /// inline `transcript` — the same `VELESDB_MEMORY_INGEST_ROOTS`
249    /// allowlist and security pipeline as a `compile_context` fragment's
250    /// `path` (V2b-1), except capped at
251    /// [`crate::limits::MAX_TRANSCRIPT_BYTES`] (8 MiB) instead of the
252    /// ordinary 1 MiB fragment ceiling — the transcript is segmented into
253    /// sub-1-MiB pieces immediately after this read.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub path: Option<String>,
256    /// Hard token ceiling for the assembled content, same as
257    /// `compile_context`'s `token_budget`.
258    #[serde(deserialize_with = "super::wire::lenient")]
259    pub token_budget: u64,
260    /// Project facet, recorded in provenance.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub project: Option<String>,
263    /// Target model name, for cost insights.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub target_model: Option<String>,
266    /// Per-request compile policy override, same as `compile_context`'s
267    /// `policy`.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub policy: Option<CompilePolicy>,
270    /// Tuning knobs for the transcript segmentation step itself (format,
271    /// merge threshold, system-turn caching). `None` uses
272    /// [`SegmentationPolicy::default`].
273    #[serde(
274        default,
275        skip_serializing_if = "Option::is_none",
276        deserialize_with = "super::wire::lenient"
277    )]
278    pub segmentation: Option<SegmentationPolicy>,
279}
280
281/// One entry of [`SegmentationReport::segments`] — the audit trail of how
282/// `compile_transcript` cut the transcript up, independent of what
283/// `compile_context` then did with the resulting fragments.
284#[derive(Debug, serde::Serialize, serde::Deserialize, JsonSchema)]
285#[schemars(transform = crate::schema::strip_int_formats)]
286pub(super) struct SegmentInfo {
287    /// Position of this segment in `segmentation.segments`, in transcript
288    /// order.
289    pub index: usize,
290    /// Which turn (0-based) this segment belongs to.
291    pub turn: usize,
292    /// The turn's role, when one was determined. `null` for a `plain`
293    /// transcript with no matching marker at all.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub role: Option<String>,
296    /// `"body"`, `"code"`, or `"log"`.
297    pub kind: SegmentKind,
298    /// Start byte offset (inclusive) in the original transcript. For a
299    /// `jsonl` transcript this is a slice of the raw JSON line's span, not
300    /// an offset into the decoded `content` — a JSONL line's decoded text
301    /// has no byte-exact mapping back into the raw (JSON-escaped) source
302    /// bytes, so when a single line's decoded content is re-split (over
303    /// [`crate::limits::MAX_FRAGMENT_BYTES`]) each child's range is a
304    /// proportional, non-overlapping share of the line's raw range rather
305    /// than a byte-precise one. Every segment's range is still distinct and
306    /// non-overlapping (see [`SegmentationReport::segments`]'s struct docs).
307    pub byte_start: usize,
308    /// End byte offset (exclusive) in the original transcript. Same caveat
309    /// as `byte_start`.
310    pub byte_end: usize,
311    /// The id this segment's fragment carries into `context.decisions` —
312    /// content-addressed, same formula as every other `compile_context`
313    /// fragment with no caller-supplied `id`.
314    pub fragment_id: u64,
315}
316
317/// The segmentation audit trail returned alongside `context`.
318#[derive(Debug, serde::Serialize, serde::Deserialize, JsonSchema)]
319pub(super) struct SegmentationReport {
320    /// `"plain"` or `"jsonl"` — the format actually used, never `"auto"`
321    /// even when the request asked for it.
322    pub format_detected: SegmentFormat,
323    /// Every segment, in transcript order, with byte ranges that partition
324    /// the transcript exactly — no gaps, no overlaps, even for a `jsonl`
325    /// line whose decoded `content` alone exceeds
326    /// [`crate::limits::MAX_FRAGMENT_BYTES`] (1 MiB) and gets re-split into
327    /// several segments (`compile_context` still gets sub-1-MiB fragments):
328    /// each child's range is a proportional, non-overlapping share of the
329    /// original JSON line's raw span rather than a byte-exact one, since a
330    /// JSONL line's decoded text has no byte-aligned mapping back into the
331    /// raw (JSON-escaped) source bytes (see `resplit_body` in
332    /// `context::segment`). An extreme edge case (one transcript line over
333    /// 1 MiB of decoded content), but even then every segment keeps a
334    /// distinct, non-overlapping range.
335    pub segments: Vec<SegmentInfo>,
336    /// How many segments [`SegmentationPolicy::min_segment_bytes`] merging
337    /// eliminated.
338    pub merged_segments: usize,
339}
340
341/// Output of the `compile_transcript` tool.
342#[derive(Debug, serde::Serialize, serde::Deserialize, JsonSchema)]
343pub(super) struct CompileTranscriptResult {
344    /// The compiled context — byte-compatible with `compile_context`'s
345    /// output.
346    pub context: CompiledContext,
347    /// How the transcript was cut into fragments before compilation.
348    pub segmentation: SegmentationReport,
349}
350
351/// Input of the `suggest_budget` tool.
352#[derive(Debug, Deserialize, JsonSchema)]
353pub(super) struct SuggestBudgetParams {
354    /// The model name to look up in the static window table (e.g.
355    /// `"claude-sonnet-4-5"`). Matched case-insensitively.
356    pub target_model: String,
357    /// Tokens to reserve for the response, subtracted from the model's
358    /// window (default `0`) — mirrors
359    /// [`CompilePolicy::response_reserve_tokens`].
360    #[serde(
361        default,
362        skip_serializing_if = "Option::is_none",
363        deserialize_with = "super::wire::lenient"
364    )]
365    pub reserve_tokens: Option<u64>,
366}
367
368#[tool_router(router = context_tool_router, vis = "pub(super)")]
369impl McpServer {
370    /// Resolve every `path`-carrying fragment of `fragments` against this
371    /// server's configured ingest roots (V2b-1), turning `path` into
372    /// ordinary `content` in place before the request reaches the compiler
373    /// — the adapter-side pre-pass `context::ingest` describes. A no-op
374    /// when no fragment carries a `path`. Shared by `compile_context` and
375    /// `explain_compilation`, the only two tools that accept a `path`
376    /// fragment.
377    #[cfg(not(target_arch = "wasm32"))]
378    fn resolve_ingest(&self, fragments: &mut [ContextFragment]) -> Result<(), ErrorData> {
379        crate::context::ingest::resolve_fragments(fragments, self.ingest_roots.as_ref())
380            .map_err(to_error)
381    }
382
383    /// This crate never targets `wasm32` with the `mcp` feature on (the
384    /// server pulls in `rmcp`/`tokio`), so this arm exists only to keep the
385    /// call site uniform if that ever changes — a `path` fragment simply
386    /// reports the same "ingestion disabled" error the pure compiler core
387    /// would (see `context::validate`), since there is no adapter here to
388    /// resolve it.
389    #[cfg(target_arch = "wasm32")]
390    fn resolve_ingest(&self, fragments: &mut [ContextFragment]) -> Result<(), ErrorData> {
391        if fragments.iter().any(|f| f.path.is_some()) {
392            return Err(to_error(crate::error::MemoryError::IngestDisabled));
393        }
394        Ok(())
395    }
396
397    /// Resolve a `compile_transcript` `path` field against this server's
398    /// configured ingest roots (V2b-2) — the same security pipeline as
399    /// [`Self::resolve_ingest`], but through
400    /// [`crate::context::ingest::resolve_transcript_path`] so the byte cap
401    /// is [`crate::limits::MAX_TRANSCRIPT_BYTES`], not the ordinary 1 MiB
402    /// fragment ceiling.
403    #[cfg(not(target_arch = "wasm32"))]
404    fn resolve_transcript_path(&self, path: &str) -> Result<String, ErrorData> {
405        let roots = self
406            .ingest_roots
407            .as_ref()
408            .filter(|roots| roots.is_enabled())
409            .ok_or_else(|| to_error(crate::error::MemoryError::IngestDisabled))?;
410        crate::context::ingest::resolve_transcript_path(path, roots).map_err(to_error)
411    }
412
413    /// `mcp` never targets `wasm32` (see [`Self::resolve_ingest`]'s wasm
414    /// arm) — kept for call-site uniformity.
415    #[cfg(target_arch = "wasm32")]
416    fn resolve_transcript_path(&self, _path: &str) -> Result<String, ErrorData> {
417        Err(to_error(crate::error::MemoryError::IngestDisabled))
418    }
419
420    /// The text `compile_transcript` will segment: exactly one of `transcript`
421    /// (inline) or `path` (ingested through the allowlist), and never empty.
422    ///
423    /// The emptiness check runs AFTER `path` is resolved, rather than being
424    /// folded into the match, so an inline empty string and a `path` that
425    /// resolves to an empty file are rejected identically — the ingest
426    /// pipeline itself happily reads a zero-byte file, so this is the one
427    /// place that catches "nothing to compile" whatever the source.
428    fn resolve_transcript_text(
429        &self,
430        transcript: Option<String>,
431        path: Option<String>,
432    ) -> Result<String, ErrorData> {
433        let text = match (transcript, path) {
434            (Some(text), None) => text,
435            (None, Some(path)) => self.resolve_transcript_path(&path)?,
436            _ => {
437                return Err(ErrorData::new(
438                    ErrorCode::INVALID_PARAMS,
439                    "exactly one of `transcript` or `path` must be set".to_owned(),
440                    None,
441                ));
442            }
443        };
444        if text.is_empty() {
445            return Err(ErrorData::new(
446                ErrorCode::INVALID_PARAMS,
447                "the transcript is empty — `transcript` must be non-empty text, or `path` must \
448                 point to a non-empty file"
449                    .to_owned(),
450                None,
451            ));
452        }
453        Ok(text)
454    }
455
456    #[tool(
457        name = "compile_context",
458        description = "Compile context fragments into a token-budgeted, provenance-audited prompt context — deterministically, with no LLM call. Duplicates are dropped, repeated log lines collapse, code/URLs/numbers/negative constraints survive verbatim, over-budget content becomes retrievable ctx://source/ handles instead of silently vanishing, and `memory_scope` pulls relevant stored memories into the result. Each fragment's own `metadata` is capped at 64 KiB serialized. A fragment may set `path` (an absolute filesystem path) instead of inline `content` to ingest a file by reference — `path` is exclusive and cannot be combined with `content` or `media`, while `content` and `media` MAY travel together (the content is then the image's caption, and the only text lexical relevance can read); a fragment carrying none of the three is refused. Path ingestion requires the server to be started with VELESDB_MEMORY_INGEST_ROOTS set to an allowlist of directories, and the resolved file must be plain UTF-8 text under 1 MiB. Returns the assembled content plus one auditable decision per fragment (rule id, reason, risk), the sources, the retrieval handles, token-savings insights, and `warnings` — a mechanical shortlist of externalized fragments relevant enough to the query that they are worth a second look. An empty `warnings` is NOT a clean bill of health: only `retrieve` decisions at or above a relevance floor ever qualify, so a `preserve` fragment the packer could only fit partially, and an abstracted one, are real losses that never appear there — `decisions` stays the exhaustive record, and `risk` is the cheap second signal. `policy.slim_response` (default false) empties `sections`/`decisions` from the response — keep it off when you need the audit trail, or re-compile without it later (compilation is deterministic). `policy.ids_as_strings` (default false) rewrites every id field of the response into a decimal string, for MCP clients without u64-safe JSON number parsing.",
459        input_schema = wire_safe_input_schema::<CompileRequest>(&["id"]),
460        output_schema = wire_safe_output_schema::<CompiledContext>()
461    )]
462    async fn compile_context(
463        &self,
464        Parameters(mut request): Parameters<CompileRequest>,
465    ) -> Result<Json<Value>, ErrorData> {
466        self.resolve_ingest(&mut request.fragments)?;
467        let ids_as_strings = request.policy.as_ref().is_some_and(|p| p.ids_as_strings);
468        let service = Arc::clone(&self.service);
469        let compiled = tokio::task::spawn_blocking(move || {
470            service.run(|current| {
471                current.compile_context(&ContextCompiler::new(CompilePolicy::default()), &request)
472            })
473        })
474        .await
475        .map_err(join_error)?
476        .map_err(to_error)?;
477        Ok(Json(to_wire_value(&compiled, ids_as_strings)?))
478    }
479
480    /// **Error taxonomy (issue #1516, m2 — refines the PR #1500 review
481    /// note):** a genuine budget/cap breach — oversized fence, too many
482    /// fragments after merging, transcript over
483    /// [`crate::limits::MAX_TRANSCRIPT_BYTES`] — surfaces as
484    /// [`crate::error::MemoryError::ContextOverLimit`]. A forced `jsonl`
485    /// format that fails to parse is a FORMAT failure, not a size breach, so
486    /// it surfaces as the distinct
487    /// [`crate::error::MemoryError::SegmentationError`] instead — no longer
488    /// the misleading "over limit" wording. Both variants still map to the
489    /// same `INVALID_PARAMS`-category MCP code (`ContextOverLimit` and
490    /// `SegmentationError` are both [`crate::error::ErrorCategory::InvalidInput`]),
491    /// so this only changes how a caller who inspects `MemoryError`
492    /// programmatically (e.g. via the Rust crate directly, not over MCP)
493    /// tells the two apart.
494    #[tool(
495        name = "compile_transcript",
496        description = "One-call shortcut over compile_context for a raw agent-session transcript: deterministically segments it into turns (plain marker-based — System:/User:/Human:/Assistant:/AI:/Tool:/### User/### Assistant — or JSONL, one line per turn) and, within each turn, into code/log/body sub-segments (fenced code blocks stay atomic; runs of 8+ log-like lines collapse the same way abstract.log_dedup would), then compiles the result exactly like compile_context. Exactly one of `transcript` (inline) or `path` (an absolute filesystem path, same VELESDB_MEMORY_INGEST_ROOTS allowlist as compile_context's `path` fragments but capped at 8 MiB) must be set. `segmentation.format` forces plain or jsonl instead of auto-detecting; a forced jsonl format that fails to parse is a hard error, never a silent fallback. The first turn is tagged cache-eligible when it looks like a system prompt (segmentation.cache_system_turn, default true). Returns `context` (byte-compatible with compile_context's output) plus `segmentation` — the detected format and one audit entry (turn, role, kind, byte range, fragment_id) per segment, so a caller can see exactly how the transcript was cut before trusting the compiled result.",
497        input_schema = wire_safe_input_schema::<CompileTranscriptParams>(&[]),
498        output_schema = wire_safe_output_schema::<CompileTranscriptResult>()
499    )]
500    async fn compile_transcript(
501        &self,
502        Parameters(params): Parameters<CompileTranscriptParams>,
503    ) -> Result<Json<Value>, ErrorData> {
504        let CompileTranscriptParams {
505            query,
506            transcript,
507            path,
508            token_budget,
509            project,
510            target_model,
511            policy,
512            segmentation,
513        } = params;
514        let transcript_text = self.resolve_transcript_text(transcript, path)?;
515        let segmentation_policy = segmentation.unwrap_or_default();
516        let (fragments, report) = segment_for_compilation(&transcript_text, &segmentation_policy)?;
517        let ids_as_strings = policy.as_ref().is_some_and(|p| p.ids_as_strings);
518        let request = CompileRequest {
519            query,
520            fragments,
521            project,
522            target_model,
523            token_budget,
524            memory_scope: None,
525            policy,
526        };
527        let service = Arc::clone(&self.service);
528        let compiled = tokio::task::spawn_blocking(move || {
529            service.run(|current| {
530                current.compile_context(&ContextCompiler::new(CompilePolicy::default()), &request)
531            })
532        })
533        .await
534        .map_err(join_error)?
535        .map_err(to_error)?;
536        let result = CompileTranscriptResult {
537            context: compiled,
538            segmentation: report,
539        };
540        Ok(Json(to_wire_value(&result, ids_as_strings)?))
541    }
542
543    #[tool(
544        name = "context_savings",
545        // Sans declaration explicite, rmcp derive un schema de sortie qui
546        // conserve des $ref qu'un client aveugle aux $defs ne resout pas —
547        // or les SDK MCP valident structuredContent contre ce schema.
548        output_schema = wire_safe_output_schema::<ContextSavings>(),
549        description = "Aggregate the token (and cost) savings of past compile_context calls, optionally per project. Figures are local estimates recorded per compilation (metadata only, never content); `truncated` reports when the sweep hit the recall cap."
550    )]
551    async fn context_savings(
552        &self,
553        Parameters(params): Parameters<ContextSavingsParams>,
554    ) -> Result<Json<ContextSavings>, ErrorData> {
555        let service = Arc::clone(&self.service);
556        let savings = tokio::task::spawn_blocking(move || {
557            service.run(|current| current.context_savings(params.project.as_deref()))
558        })
559        .await
560        .map_err(join_error)?
561        .map_err(to_error)?;
562        Ok(Json(savings))
563    }
564
565    #[tool(
566        name = "explain_compilation",
567        description = "Explain why one fragment of a compile_context request was preserved, abstracted, externalized, dropped, or cached. Compilation is deterministic, so the request is re-compiled (with event/source recording off) and the fragment's exact decision (rule id, reason, relevance, risk, handle) is returned — no server-side state needed. Caveat: with a memory_scope the re-compile recalls from CURRENT memory, so decisions about pulled memories reflect the memory as it is now, not as it was; a `path` fragment is likewise re-read from disk, so the decision reflects the file's CURRENT content, not necessarily what the original compile_context call saw. Pass `fragment_index` (0-based position in request.fragments) instead of relying on `fragment_id` alone when fragments are byte-identical — a shared content-addressed id otherwise always resolves to the deduplication survivor's decision. `policy.ids_as_strings` on the request rewrites the response's id fields into decimal strings, like compile_context.",
568        input_schema = wire_safe_input_schema::<ExplainCompilationParams>(&["id", "fragment_id"]),
569        output_schema = wire_safe_output_schema::<ContextDecision>()
570    )]
571    async fn explain_compilation(
572        &self,
573        Parameters(params): Parameters<ExplainCompilationParams>,
574    ) -> Result<Json<Value>, ErrorData> {
575        let service = Arc::clone(&self.service);
576        let ExplainCompilationParams {
577            mut request,
578            fragment_id,
579            fragment_index,
580        } = params;
581        self.resolve_ingest(&mut request.fragments)?;
582        let ids_as_strings = request.policy.as_ref().is_some_and(|p| p.ids_as_strings);
583        // The selection logic itself (record-off recompile + select by
584        // index/id) lives in the memory bridge now, shared with the Node and
585        // Python bindings — this tool only resolves `path` ingestion (a
586        // server-config concern) and maps the result onto the wire.
587        let decision = tokio::task::spawn_blocking(move || {
588            service
589                .run(|current| current.explain_compilation(&request, fragment_id, fragment_index))
590        })
591        .await
592        .map_err(join_error)?
593        .map_err(to_error)?;
594        Ok(Json(to_wire_value(&decision, ids_as_strings)?))
595    }
596
597    #[tool(
598        name = "retrieve_context_source",
599        // Sans declaration explicite, rmcp derive un schema de sortie qui
600        // conserve des $ref qu'un client aveugle aux $defs ne resout pas —
601        // or les SDK MCP valident structuredContent contre ce schema.
602        output_schema = wire_safe_output_schema::<RetrieveContextSourceResult>(),
603        description = "Fetch back the exact original content behind a ctx://source/<hash> handle from a compiled context — what compile_context externalized or partially packed is recoverable, not lost."
604    )]
605    async fn retrieve_context_source(
606        &self,
607        Parameters(params): Parameters<RetrieveContextSourceParams>,
608    ) -> Result<Json<RetrieveContextSourceResult>, ErrorData> {
609        let service = Arc::clone(&self.service);
610        let RetrieveContextSourceParams { handle } = params;
611        let lookup = handle.clone();
612        let source = tokio::task::spawn_blocking(move || {
613            service.run(|current| current.retrieve_context_source(&lookup))
614        })
615        .await
616        .map_err(join_error)?
617        .map_err(to_error)?;
618        Ok(Json(RetrieveContextSourceResult {
619            handle,
620            content: source.content,
621            media: source.media,
622        }))
623    }
624
625    #[tool(
626        name = "save_working_context",
627        // Sans declaration explicite, rmcp derive un schema de sortie qui
628        // conserve des $ref qu'un client aveugle aux $defs ne resout pas —
629        // or les SDK MCP valident structuredContent contre ce schema.
630        output_schema = wire_safe_output_schema::<SaveWorkingContextResult>(),
631        description = "Persist this session's distilled working state (goal, active constraints, verified facts, open hypotheses, decisions, exact evidence, pending actions) under a project + session id — so a LATER session (a fresh agent run, a new conversation, a resumed process) can pick up exactly where this one left off instead of re-deriving context from scratch. Call this near the end of a session, or whenever the working state changes meaningfully. Saving again under the same project+session replaces the previous state (idempotent upsert) — so an entirely empty `working` is REFUSED rather than allowed to wipe what a previous save stored; fill at least one field. Serialized size is capped at 1 MiB. Returns the stored fact's id. IF THIS CALL TIMES OUT, THE SAVE MAY NOT HAVE HAPPENED — a timeout is not a slow success, and over the HTTP transport it usually means the request never reached this tool at all. Do not assume it was written: call `list_working_contexts` and check that this session's `saved_at` actually advanced, then re-send the identical call if it did not. Re-sending is safe — the write is an upsert on project + session, so it replaces rather than duplicates.",
632        input_schema = wire_safe_input_schema::<SaveWorkingContextParams>(&["fragment_id", "memory_id"])
633    )]
634    async fn save_working_context(
635        &self,
636        Parameters(params): Parameters<SaveWorkingContextParams>,
637    ) -> Result<Json<SaveWorkingContextResult>, ErrorData> {
638        let service = Arc::clone(&self.service);
639        let SaveWorkingContextParams {
640            project,
641            session,
642            working,
643        } = params;
644        let id = tokio::task::spawn_blocking(move || {
645            service.run(|current| current.save_working_context(&project, &session, &working))
646        })
647        .await
648        .map_err(join_error)?
649        .map_err(to_error)?;
650        Ok(Json(SaveWorkingContextResult {
651            id,
652            id_str: id.to_string(),
653        }))
654    }
655
656    #[tool(
657        name = "load_working_context",
658        // Sans declaration explicite, rmcp derive un schema de sortie qui
659        // conserve des $ref qu'un client aveugle aux $defs ne resout pas —
660        // or les SDK MCP valident structuredContent contre ce schema.
661        output_schema = wire_safe_output_schema::<LoadedWorkingContext>(),
662        description = "Resume a session: load back the working context previously saved by save_working_context under the same project + session id — the goal, constraints, verified facts, open hypotheses, decisions, exact evidence, and pending actions a PRIOR session left off with. Call this at the START of a new session before doing anything else, so work continues instead of restarting. `found: false` (with `working: null`) means nothing was ever saved under that exact project + session — not an error, but check `other_sessions`: if it lists a similarly-named session, `session` was likely a typo, not a genuinely fresh start. `other_sessions` is always filled in, on a hit too: if it lists a session that looks more like the one you meant, you may have just resumed the WRONG session. Use `list_working_contexts` to browse a project's sessions up front."
663    )]
664    async fn load_working_context(
665        &self,
666        Parameters(params): Parameters<LoadWorkingContextParams>,
667    ) -> Result<Json<Value>, ErrorData> {
668        let LoadWorkingContextParams { project, session } = params;
669        let service = Arc::clone(&self.service);
670        // L'enveloppe entiere — `found`, `working`, `other_sessions` — est
671        // composee par le pont, pas ici : les deux regles de politique
672        // ("lister meme sur un hit", "ne jamais reemettre la session
673        // demandee") sont les memes pour cet outil et pour les trois
674        // bindings, et une regle recopiee par surface diverge en silence.
675        let loaded = tokio::task::spawn_blocking(move || {
676            service.run(|current| current.resume_working_context(&project, &session))
677        })
678        .await
679        .map_err(join_error)?
680        .map_err(to_error)?;
681        // Les ids sortent en CHAINE decimale, sans option, contrairement au
682        // compilateur ou `ids_as_strings` est un choix de l'appelant.
683        //
684        // Ici il n'y a pas de choix a offrir : cet outil est la moitie
685        // LECTURE d'un aller-retour dont la moitie ECRITURE
686        // (`save_working_context`) n'annonce plus qu'une forme, la chaine —
687        // depuis que le schema d'entree ne peut plus publier d'union. Rendre
688        // un nombre la ou le jumeau n'accepte qu'une chaine oblige le client
689        // a convertir ; et sur un client JSON a nombres flottants, la valeur
690        // est deja arrondie a la LECTURE, donc il reecrit un id faux avec
691        // l'exactitude apparente d'une chaine. Un contexte de travail existe
692        // pour survivre a une perte de contexte : sa trace de provenance ne
693        // peut pas se rompre en silence entre deux sessions.
694        //
695        // Le schema de sortie annonce deja `["integer", "string"]` sur ces
696        // champs (`widen_id_properties`), donc emettre la branche chaine est
697        // valide au sens du schema publie : aucun SDK ne rejette la reponse.
698        let mut value = serde_json::to_value(loaded).map_err(|err| {
699            ErrorData::internal_error(
700                format!("Failed to serialize structured content: {err}"),
701                None,
702            )
703        })?;
704        stringify_id_fields(&mut value);
705        Ok(Json(value))
706    }
707
708    #[tool(
709        name = "list_working_contexts",
710        // Same reason as the four tools wired in `mcp.rs`: an rmcp-derived
711        // output schema keeps `$ref`s a `$defs`-blind client cannot resolve.
712        output_schema = wire_safe_output_schema::<ListWorkingContextsResult>(),
713        description = "List every session saved under a project via save_working_context, most-recently-saved first — so an agent can discover what is resumable before guessing a session id at load_working_context, or recover from a typo. Empty (not an error) when the project never saved anything."
714    )]
715    async fn list_working_contexts(
716        &self,
717        Parameters(params): Parameters<ListWorkingContextsParams>,
718    ) -> Result<Json<ListWorkingContextsResult>, ErrorData> {
719        let service = Arc::clone(&self.service);
720        let ListWorkingContextsParams { project } = params;
721        let sessions = tokio::task::spawn_blocking(move || {
722            service.run(|current| current.list_working_contexts(&project))
723        })
724        .await
725        .map_err(join_error)?
726        .map_err(to_error)?;
727        Ok(Json(ListWorkingContextsResult { sessions }))
728    }
729
730    #[tool(
731        name = "suggest_budget",
732        // Sans declaration explicite, rmcp derive un schema de sortie qui
733        // conserve des $ref qu'un client aveugle aux $defs ne resout pas —
734        // or les SDK MCP valident structuredContent contre ce schema.
735        output_schema = wire_safe_output_schema::<SuggestedBudget>(),
736        description = "Suggest a starting token_budget for compile_context, for a named target model — looked up in a static, committed model-name to context-window table (dated \"as of\", NEVER a network call). Pass `reserve_tokens` (default 0) to reserve room for the response, mirroring compile_context's own `policy.response_reserve_tokens`. `window`/`suggested_budget` come back null when the model is not in the table — an honest \"unknown\", never a guess; extend the table in a new release instead of relying on this for an unlisted model."
737    )]
738    async fn suggest_budget(
739        &self,
740        Parameters(params): Parameters<SuggestBudgetParams>,
741    ) -> Result<Json<SuggestedBudget>, ErrorData> {
742        let SuggestBudgetParams {
743            target_model,
744            reserve_tokens,
745        } = params;
746        Ok(Json(suggest_token_budget(
747            &target_model,
748            reserve_tokens.unwrap_or(0),
749        )))
750    }
751}
752
753#[cfg(test)]
754#[path = "context_tools_tests.rs"]
755mod tests;