//! The context compiler's MCP tools — an *extension* of the one existing
//! server (never a second server): a second `#[tool_router]` block whose
//! router is combined with the main one in `McpServer::new`.
//!
//! Wire shapes reuse the domain types from [`crate::context`] directly
//! (`CompileRequest` *is* the tool input, `CompiledContext` the output) —
//! the only DTOs here are the thin request envelopes of the seven smaller
//! tools. Same conventions as every other tool: `spawn_blocking` around the
//! sync service, errors mapped through the transport-neutral category.
use std::sync::Arc;
use rmcp::handler::server::wrapper::{Json, Parameters};
use rmcp::model::ErrorCode;
use rmcp::{tool, tool_router, ErrorData};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;
use super::{join_error, to_error, McpServer};
use crate::context::wire::stringify_id_fields;
use crate::context::{
fragment_id, segment_transcript, suggest_token_budget, CompilePolicy, CompileRequest,
CompiledContext, ContextCompiler, ContextDecision, ContextFragment, ContextSavings,
LoadedWorkingContext, MediaRef, SegmentFormat, SegmentKind, SegmentationPolicy,
SuggestedBudget, WorkingContext, WorkingContextSession,
};
/// Serialize `payload`, opt-in rewriting every id field into decimal-string
/// form ([`CompilePolicy::ids_as_strings`]) — the shared response-side half
/// of the wire-compat contract, reused by both `compile_context` and
/// `explain_compilation` so the id rewrite is expressed exactly once.
fn to_wire_value<T: serde::Serialize>(
payload: &T,
ids_as_strings: bool,
) -> Result<Value, ErrorData> {
let mut value = serde_json::to_value(payload).map_err(|err| {
ErrorData::internal_error(
format!("Failed to serialize structured content: {err}"),
None,
)
})?;
if ids_as_strings {
stringify_id_fields(&mut value);
}
Ok(value)
}
fn segment_for_compilation(
text: &str,
policy: &SegmentationPolicy,
) -> Result<(Vec<ContextFragment>, SegmentationReport), ErrorData> {
let outcome = segment_transcript(text, policy).map_err(to_error)?;
let segments = outcome
.segments
.iter()
.enumerate()
.map(|(index, segment)| SegmentInfo {
index,
turn: segment.turn,
role: segment.role.clone(),
kind: segment.kind,
byte_start: segment.byte_start,
byte_end: segment.byte_end,
fragment_id: fragment_id(&segment.fragment.content),
})
.collect();
let fragments = outcome
.segments
.into_iter()
.map(|segment| segment.fragment)
.collect();
let report = SegmentationReport {
format_detected: outcome.format_detected,
segments,
merged_segments: outcome.merged_segments,
};
Ok((fragments, report))
}
/// The advertised-schema half of the [`CompilePolicy::ids_as_strings`]
/// contract: the response may carry each [`ID_KEYS`] field as an integer OR
/// a decimal string, and the official MCP SDKs validate `structuredContent`
/// against the advertised `outputSchema` (spec 2025-06-18) — so those
/// fields must be typed `["integer", "string"]`, or every opted-in response
/// would fail client-side validation for exactly the clients the option
/// exists for.
pub(super) use crate::schema::wire_safe_output_schema;
/// Input-side counterpart: `fragments[].id` accepts an integer or a decimal
/// string ([`crate::context::wire::deserialize_optional_id`]), so the
/// advertised input schema announces the string form — a client generating
/// requests from the schema must be able to discover it.
///
/// Le jeu de cles n'est plus fige a `"id"` : il est passe par l'outil, comme
/// dans `mcp.rs`, parce que `save_working_context` porte des ids sous
/// d'autres noms (`fragment_id`, `memory_id`, imbriques dans
/// `WorkingContext`) tandis que `explain_compilation.fragment_id` est un
/// `u64` STRICT qu'annoncer `string` serait une promesse fausse. Un seul
/// constructeur, donc, mais toujours une decision par outil.
pub(super) use crate::schema::wire_safe_input_schema;
// --- Thin request envelopes --------------------------------------------------
/// Input of the `context_savings` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct ContextSavingsParams {
/// Restrict the aggregation to this project facet.
pub project: Option<String>,
}
/// Input of the `explain_compilation` tool.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(transform = crate::schema::strip_int_formats)]
pub(super) struct ExplainCompilationParams {
/// The compile request to explain (compilation is deterministic, so
/// re-submitting the request reproduces the exact decisions).
#[serde(deserialize_with = "super::wire::lenient")]
pub request: CompileRequest,
// Aller-retour casse jusqu'au 2026-07-29, et casse depuis toujours :
// `fragment_id` est le SELECTEUR d'une decision, et la decision d'ou le
// client le tire lui arrive en CHAINE decimale des que la requete porte
// `policy.ids_as_strings` (`fragment_id` est dans
// `context::wire::ID_KEYS`). Ce champ etait un `u64` nu : l'outil
// refusait litteralement les octets qu'il venait d'emettre, et comme un
// `fragment_id` est un FNV-1a 64 — au-dela de 2^53 dans ~99,95 % des cas
// — le repli « renvoyer un nombre » etait deja arrondi chez un client
// JSON a nombres flottants. Les deux formes echouaient : sur un tel
// client, l'outil etait inatteignable par son propre selecteur.
//
// Ce n'etait pas une omission : trois tests et deux jeux de cles d'id
// epinglaient la forme stricte comme voulue. Ce que personne n'avait
// fait, c'est l'aller-retour.
/// The fragment whose decision to return, matched on
/// `ContextDecision::fragment_id` UNLESS `fragment_index` is also given.
/// Accepts a JSON number OR decimal string, so a `fragment_id` received
/// under `ids_as_strings` relays back unchanged.
#[serde(deserialize_with = "crate::model::deserialize_id")]
pub fragment_id: u64,
/// Optional 0-based position of the fragment in `request.fragments`;
/// when given, TAKES PRIORITY over `fragment_id`:
/// `decisions[fragment_index]` is unambiguous even when byte-identical
/// fragments share one content-addressed `fragment_id` (a plain lookup
/// returns the FIRST, the dedup survivor — never a dropped twin's).
/// Absent: lookup by `fragment_id` alone.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "super::wire::lenient"
)]
pub fragment_index: Option<usize>,
}
/// Input of the `retrieve_context_source` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct RetrieveContextSourceParams {
/// A `ctx://source/<hash>` handle from a compiled context.
pub handle: String,
}
/// Output of the `retrieve_context_source` tool.
#[derive(Debug, serde::Serialize, JsonSchema)]
pub(super) struct RetrieveContextSourceResult {
/// The handle that was resolved.
pub handle: String,
/// The original fragment content, byte for byte.
pub content: String,
/// The original media payload, when the fragment carried one (US-009,
/// PR2). Absent for every text-only source — the exact pre-PR2 shape.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media: Option<MediaRef>,
}
/// Input of the `save_working_context` tool.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(transform = crate::schema::strip_int_formats)]
pub(super) struct SaveWorkingContextParams {
/// Project facet this working context belongs to (matches `remember`'s
/// `project` metadata convention).
pub project: String,
/// Session identifier — pick something stable for the agent run you want
/// to resume later (e.g. a conversation id).
pub session: String,
/// The distilled state to persist: goal, active constraints, verified
/// facts, open hypotheses, decisions taken, exact evidence, and pending
/// actions.
#[serde(deserialize_with = "super::wire::lenient")]
pub working: WorkingContext,
}
/// Output of the `save_working_context` tool.
#[derive(Debug, serde::Serialize, JsonSchema)]
#[schemars(transform = crate::schema::strip_int_formats)]
pub(super) struct SaveWorkingContextResult {
/// Id of the stored system fact backing this working context.
pub id: u64,
/// Decimal-string twin of `id` (same contract as `remember`'s). The id
/// is content-addressed (FNV-1a 64), past 2^53 — relay THIS to
/// `forget`/`feedback`, never `id`.
pub id_str: String,
}
/// Input of the `load_working_context` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct LoadWorkingContextParams {
/// Project facet the working context was saved under.
pub project: String,
/// Session identifier the working context was saved under.
pub session: String,
}
/// Input of the `list_working_contexts` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct ListWorkingContextsParams {
/// Project facet to list saved working-context sessions for (same
/// convention as `save_working_context`'s `project`).
pub project: String,
}
/// Output of the `list_working_contexts` tool.
#[derive(Debug, serde::Serialize, JsonSchema)]
pub(super) struct ListWorkingContextsResult {
/// Every session saved under this project, most-recently-saved first.
/// Empty (not an error) when the project never saved anything.
pub sessions: Vec<WorkingContextSession>,
}
/// Input of the `compile_transcript` tool.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(transform = crate::schema::strip_int_formats)]
pub(super) struct CompileTranscriptParams {
/// What the agent is working on — drives relevance scoring, exactly like
/// `compile_context`'s `query`.
pub query: String,
/// The raw transcript text (plain, marker-based, or JSONL). Exactly one
/// of `transcript` or `path` must be set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transcript: Option<String>,
/// Read the transcript from this absolute path instead of inline
/// `transcript` — same `VELESDB_MEMORY_INGEST_ROOTS` allowlist and
/// security pipeline as a fragment's `path` (V2b-1), except capped at
/// `MAX_TRANSCRIPT_BYTES` (8 MiB); the transcript is segmented into
/// sub-1-MiB pieces right after the read.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
/// Hard token ceiling for the assembled content, same as
/// `compile_context`'s `token_budget`.
#[serde(deserialize_with = "super::wire::lenient")]
pub token_budget: u64,
/// Project facet, recorded in provenance.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
/// Target model name, for cost insights.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_model: Option<String>,
/// Per-request compile policy override, same as `compile_context`'s
/// `policy`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy: Option<CompilePolicy>,
/// Tuning knobs for the transcript segmentation step itself (format,
/// merge threshold, system-turn caching). `None` uses
/// [`SegmentationPolicy::default`].
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "super::wire::lenient"
)]
pub segmentation: Option<SegmentationPolicy>,
}
/// One entry of [`SegmentationReport::segments`] — the audit trail of how
/// `compile_transcript` cut the transcript up, independent of what
/// `compile_context` then did with the resulting fragments.
#[derive(Debug, serde::Serialize, serde::Deserialize, JsonSchema)]
#[schemars(transform = crate::schema::strip_int_formats)]
pub(super) struct SegmentInfo {
/// Position of this segment in `segmentation.segments`, in transcript
/// order.
pub index: usize,
/// Which turn (0-based) this segment belongs to.
pub turn: usize,
/// The turn's role, when one was determined. `null` for a `plain`
/// transcript with no matching marker at all.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
/// `"body"`, `"code"`, or `"log"`.
pub kind: SegmentKind,
/// Start byte offset (inclusive) in the original transcript. For `jsonl`
/// this is a slice of the raw JSON line's span, not of decoded
/// `content` — when one line's decoded content is re-split, each child's
/// range is a proportional, non-overlapping share of the raw range, not
/// byte-exact. Ranges still never overlap.
pub byte_start: usize,
/// End byte offset (exclusive) in the original transcript. Same caveat
/// as `byte_start`.
pub byte_end: usize,
/// The id this segment's fragment carries into `context.decisions` —
/// content-addressed, same formula as every other `compile_context`
/// fragment with no caller-supplied `id`.
pub fragment_id: u64,
}
/// The segmentation audit trail returned alongside `context`.
#[derive(Debug, serde::Serialize, serde::Deserialize, JsonSchema)]
pub(super) struct SegmentationReport {
/// `"plain"` or `"jsonl"` — the format actually used, never `"auto"`
/// even when the request asked for it.
pub format_detected: SegmentFormat,
/// Every segment, in transcript order, with byte ranges that partition
/// the transcript exactly — no gaps, no overlaps. Edge case: a `jsonl`
/// line whose decoded content exceeds 1 MiB is re-split, and each child
/// gets a proportional share of the raw line span, not a byte-exact one.
pub segments: Vec<SegmentInfo>,
/// How many segments [`SegmentationPolicy::min_segment_bytes`] merging
/// eliminated.
pub merged_segments: usize,
}
/// Output of the `compile_transcript` tool.
#[derive(Debug, serde::Serialize, serde::Deserialize, JsonSchema)]
pub(super) struct CompileTranscriptResult {
/// The compiled context — byte-compatible with `compile_context`'s
/// output.
pub context: CompiledContext,
/// How the transcript was cut into fragments before compilation.
pub segmentation: SegmentationReport,
}
/// Input of the `suggest_budget` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct SuggestBudgetParams {
/// The model name to look up in the static window table (e.g.
/// `"claude-sonnet-4-5"`). Matched case-insensitively.
pub target_model: String,
/// Tokens to reserve for the response, subtracted from the model's
/// window (default `0`) — mirrors
/// [`CompilePolicy::response_reserve_tokens`].
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "super::wire::lenient"
)]
pub reserve_tokens: Option<u64>,
}
#[tool_router(router = context_tool_router, vis = "pub(super)")]
impl McpServer {
/// Resolve every `path`-carrying fragment of `fragments` against this
/// server's configured ingest roots (V2b-1), turning `path` into
/// ordinary `content` in place before the request reaches the compiler
/// — the adapter-side pre-pass `context::ingest` describes. A no-op
/// when no fragment carries a `path`. Shared by `compile_context` and
/// `explain_compilation`, the only two tools that accept a `path`
/// fragment.
#[cfg(not(target_arch = "wasm32"))]
fn resolve_ingest(&self, fragments: &mut [ContextFragment]) -> Result<(), ErrorData> {
crate::context::ingest::resolve_fragments(fragments, self.ingest_roots.as_ref())
.map_err(to_error)
}
/// This crate never targets `wasm32` with the `mcp` feature on (the
/// server pulls in `rmcp`/`tokio`), so this arm exists only to keep the
/// call site uniform if that ever changes — a `path` fragment simply
/// reports the same "ingestion disabled" error the pure compiler core
/// would (see `context::validate`), since there is no adapter here to
/// resolve it.
#[cfg(target_arch = "wasm32")]
fn resolve_ingest(&self, fragments: &mut [ContextFragment]) -> Result<(), ErrorData> {
if fragments.iter().any(|f| f.path.is_some()) {
return Err(to_error(crate::error::MemoryError::IngestDisabled));
}
Ok(())
}
/// Resolve a `compile_transcript` `path` field against this server's
/// configured ingest roots (V2b-2) — the same security pipeline as
/// [`Self::resolve_ingest`], but through
/// [`crate::context::ingest::resolve_transcript_path`] so the byte cap
/// is [`crate::limits::MAX_TRANSCRIPT_BYTES`], not the ordinary 1 MiB
/// fragment ceiling.
#[cfg(not(target_arch = "wasm32"))]
fn resolve_transcript_path(&self, path: &str) -> Result<String, ErrorData> {
let roots = self
.ingest_roots
.as_ref()
.filter(|roots| roots.is_enabled())
.ok_or_else(|| to_error(crate::error::MemoryError::IngestDisabled))?;
crate::context::ingest::resolve_transcript_path(path, roots).map_err(to_error)
}
/// `mcp` never targets `wasm32` (see [`Self::resolve_ingest`]'s wasm
/// arm) — kept for call-site uniformity.
#[cfg(target_arch = "wasm32")]
fn resolve_transcript_path(&self, _path: &str) -> Result<String, ErrorData> {
Err(to_error(crate::error::MemoryError::IngestDisabled))
}
/// The text `compile_transcript` will segment: exactly one of `transcript`
/// (inline) or `path` (ingested through the allowlist), and never empty.
///
/// The emptiness check runs AFTER `path` is resolved, rather than being
/// folded into the match, so an inline empty string and a `path` that
/// resolves to an empty file are rejected identically — the ingest
/// pipeline itself happily reads a zero-byte file, so this is the one
/// place that catches "nothing to compile" whatever the source.
fn resolve_transcript_text(
&self,
transcript: Option<String>,
path: Option<String>,
) -> Result<String, ErrorData> {
let text = match (transcript, path) {
(Some(text), None) => text,
(None, Some(path)) => self.resolve_transcript_path(&path)?,
_ => {
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
"exactly one of `transcript` or `path` must be set".to_owned(),
None,
));
}
};
if text.is_empty() {
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
"the transcript is empty — `transcript` must be non-empty text, or `path` must \
point to a non-empty file"
.to_owned(),
None,
));
}
Ok(text)
}
#[tool(
name = "compile_context",
description = "Compile context fragments into a token-budgeted, provenance-audited prompt context — deterministic, no LLM call. Duplicates drop, repeated log lines collapse, code/URLs/numbers/negative constraints survive verbatim, over-budget content becomes retrievable ctx://source/ handles; `memory_scope` pulls stored memories in. Fragment `metadata` ≤64 KiB. A fragment may set `path` (absolute file, UTF-8 ≤1 MiB; requires VELESDB_MEMORY_INGEST_ROOTS) INSTEAD of `content` — `content`+`media` may combine (content = the image's caption). Returns the assembled content, one auditable decision per fragment (rule id, reason, risk), sources, handles, token-savings, and `warnings` (externalized fragments worth a second look — empty is NOT a clean bill of health; `decisions` is the exhaustive record, `risk` the cheap second signal). `policy.slim_response` drops sections/decisions (keep off when the audit trail matters); `policy.ids_as_strings` stringifies id fields for u64-unsafe clients.",
input_schema = wire_safe_input_schema::<CompileRequest>(&["id"]),
output_schema = wire_safe_output_schema::<CompiledContext>()
)]
async fn compile_context(
&self,
Parameters(mut request): Parameters<CompileRequest>,
) -> Result<Json<Value>, ErrorData> {
self.resolve_ingest(&mut request.fragments)?;
let ids_as_strings = request.policy.as_ref().is_some_and(|p| p.ids_as_strings);
let service = Arc::clone(&self.service);
let compiled = tokio::task::spawn_blocking(move || {
service.run(|current| {
current.compile_context(&ContextCompiler::new(CompilePolicy::default()), &request)
})
})
.await
.map_err(join_error)?
.map_err(to_error)?;
Ok(Json(to_wire_value(&compiled, ids_as_strings)?))
}
/// **Error taxonomy (issue #1516, m2 — refines the PR #1500 review
/// note):** a genuine budget/cap breach — oversized fence, too many
/// fragments after merging, transcript over
/// [`crate::limits::MAX_TRANSCRIPT_BYTES`] — surfaces as
/// [`crate::error::MemoryError::ContextOverLimit`]. A forced `jsonl`
/// format that fails to parse is a FORMAT failure, not a size breach, so
/// it surfaces as the distinct
/// [`crate::error::MemoryError::SegmentationError`] instead — no longer
/// the misleading "over limit" wording. Both variants still map to the
/// same `INVALID_PARAMS`-category MCP code (`ContextOverLimit` and
/// `SegmentationError` are both [`crate::error::ErrorCategory::InvalidInput`]),
/// so this only changes how a caller who inspects `MemoryError`
/// programmatically (e.g. via the Rust crate directly, not over MCP)
/// tells the two apart.
#[tool(
name = "compile_transcript",
description = "One-call shortcut over compile_context for a raw agent-session transcript: segments it into turns (markers System:/User:/Human:/Assistant:/AI:/Tool:, ### User/### Assistant, or JSONL one line per turn) then into code/log/body sub-segments, then compiles exactly like compile_context. Exactly one of `transcript` (inline) or `path` (absolute file under the VELESDB_MEMORY_INGEST_ROOTS allowlist, ≤8 MiB) must be set. `segmentation.format` forces plain/jsonl — a forced jsonl that fails to parse is a hard error. 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) plus `segmentation`: the detected format and a per-segment audit (turn, role, kind, byte range, fragment_id).",
input_schema = wire_safe_input_schema::<CompileTranscriptParams>(&[]),
output_schema = wire_safe_output_schema::<CompileTranscriptResult>()
)]
async fn compile_transcript(
&self,
Parameters(params): Parameters<CompileTranscriptParams>,
) -> Result<Json<Value>, ErrorData> {
let CompileTranscriptParams {
query,
transcript,
path,
token_budget,
project,
target_model,
policy,
segmentation,
} = params;
let transcript_text = self.resolve_transcript_text(transcript, path)?;
let segmentation_policy = segmentation.unwrap_or_default();
let (fragments, report) = segment_for_compilation(&transcript_text, &segmentation_policy)?;
let ids_as_strings = policy.as_ref().is_some_and(|p| p.ids_as_strings);
let request = CompileRequest {
query,
fragments,
project,
target_model,
token_budget,
memory_scope: None,
policy,
};
let service = Arc::clone(&self.service);
let compiled = tokio::task::spawn_blocking(move || {
service.run(|current| {
current.compile_context(&ContextCompiler::new(CompilePolicy::default()), &request)
})
})
.await
.map_err(join_error)?
.map_err(to_error)?;
let result = CompileTranscriptResult {
context: compiled,
segmentation: report,
};
Ok(Json(to_wire_value(&result, ids_as_strings)?))
}
#[tool(
name = "context_savings",
// Sans declaration explicite, rmcp derive un schema de sortie qui
// conserve des $ref qu'un client aveugle aux $defs ne resout pas —
// or les SDK MCP valident structuredContent contre ce schema.
output_schema = wire_safe_output_schema::<ContextSavings>(),
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."
)]
async fn context_savings(
&self,
Parameters(params): Parameters<ContextSavingsParams>,
) -> Result<Json<ContextSavings>, ErrorData> {
let service = Arc::clone(&self.service);
let savings = tokio::task::spawn_blocking(move || {
service.run(|current| current.context_savings(params.project.as_deref()))
})
.await
.map_err(join_error)?
.map_err(to_error)?;
Ok(Json(savings))
}
#[tool(
name = "explain_compilation",
description = "Explain why one fragment of a compile_context request was preserved, abstracted, externalized, dropped, or cached: the request is re-compiled deterministically (recording off) and the fragment's decision (rule id, reason, relevance, risk, handle) returned — no server state needed. Caveats: `memory_scope` recalls from CURRENT memory and a `path` fragment is re-read from CURRENT disk content — not necessarily what the original call saw. Prefer `fragment_index` (0-based) over `fragment_id` when fragments are byte-identical — a shared id resolves to the dedup survivor's decision. `policy.ids_as_strings` applies as in compile_context.",
input_schema = wire_safe_input_schema::<ExplainCompilationParams>(&["id", "fragment_id"]),
output_schema = wire_safe_output_schema::<ContextDecision>()
)]
async fn explain_compilation(
&self,
Parameters(params): Parameters<ExplainCompilationParams>,
) -> Result<Json<Value>, ErrorData> {
let service = Arc::clone(&self.service);
let ExplainCompilationParams {
mut request,
fragment_id,
fragment_index,
} = params;
self.resolve_ingest(&mut request.fragments)?;
let ids_as_strings = request.policy.as_ref().is_some_and(|p| p.ids_as_strings);
// The selection logic itself (record-off recompile + select by
// index/id) lives in the memory bridge now, shared with the Node and
// Python bindings — this tool only resolves `path` ingestion (a
// server-config concern) and maps the result onto the wire.
let decision = tokio::task::spawn_blocking(move || {
service
.run(|current| current.explain_compilation(&request, fragment_id, fragment_index))
})
.await
.map_err(join_error)?
.map_err(to_error)?;
Ok(Json(to_wire_value(&decision, ids_as_strings)?))
}
#[tool(
name = "retrieve_context_source",
// Sans declaration explicite, rmcp derive un schema de sortie qui
// conserve des $ref qu'un client aveugle aux $defs ne resout pas —
// or les SDK MCP valident structuredContent contre ce schema.
output_schema = wire_safe_output_schema::<RetrieveContextSourceResult>(),
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."
)]
async fn retrieve_context_source(
&self,
Parameters(params): Parameters<RetrieveContextSourceParams>,
) -> Result<Json<RetrieveContextSourceResult>, ErrorData> {
let service = Arc::clone(&self.service);
let RetrieveContextSourceParams { handle } = params;
let lookup = handle.clone();
let source = tokio::task::spawn_blocking(move || {
service.run(|current| current.retrieve_context_source(&lookup))
})
.await
.map_err(join_error)?
.map_err(to_error)?;
Ok(Json(RetrieveContextSourceResult {
handle,
content: source.content,
media: source.media,
}))
}
#[tool(
name = "save_working_context",
// Sans declaration explicite, rmcp derive un schema de sortie qui
// conserve des $ref qu'un client aveugle aux $defs ne resout pas —
// or les SDK MCP valident structuredContent contre ce schema.
output_schema = wire_safe_output_schema::<SaveWorkingContextResult>(),
description = "Persist this session's distilled working state (goal, constraints, verified facts, hypotheses, decisions, evidence, pending actions) under a project + session id — so a LATER session resumes instead of re-deriving. Call near session end, or on meaningful change. Same project+session = idempotent upsert; an empty `working` is REFUSED (it would wipe a previous save). ≤1 MiB. Returns the fact's id. ON TIMEOUT, THE SAVE MAY NOT HAVE HAPPENED — check `list_working_contexts` for an advanced `saved_at`, then re-send if needed (upsert = safe).",
input_schema = wire_safe_input_schema::<SaveWorkingContextParams>(&["fragment_id", "memory_id"])
)]
async fn save_working_context(
&self,
Parameters(params): Parameters<SaveWorkingContextParams>,
) -> Result<Json<SaveWorkingContextResult>, ErrorData> {
let service = Arc::clone(&self.service);
let SaveWorkingContextParams {
project,
session,
working,
} = params;
let id = tokio::task::spawn_blocking(move || {
service.run(|current| current.save_working_context(&project, &session, &working))
})
.await
.map_err(join_error)?
.map_err(to_error)?;
Ok(Json(SaveWorkingContextResult {
id,
id_str: id.to_string(),
}))
}
#[tool(
name = "load_working_context",
// Sans declaration explicite, rmcp derive un schema de sortie qui
// conserve des $ref qu'un client aveugle aux $defs ne resout pas —
// or les SDK MCP valident structuredContent contre ce schema.
output_schema = wire_safe_output_schema::<LoadedWorkingContext>(),
description = "Resume a session: load the working context saved by save_working_context under the same project + session id. Call at session START. `found: false` (`working: null`) = nothing saved under that exact id — check `other_sessions`: a similar name means a typo, not a fresh start. `other_sessions` is filled on a hit too — a likelier session listed means you may have resumed the WRONG one. `list_working_contexts` browses a project's sessions up front."
)]
async fn load_working_context(
&self,
Parameters(params): Parameters<LoadWorkingContextParams>,
) -> Result<Json<Value>, ErrorData> {
let LoadWorkingContextParams { project, session } = params;
let service = Arc::clone(&self.service);
// L'enveloppe entiere — `found`, `working`, `other_sessions` — est
// composee par le pont, pas ici : les deux regles de politique
// ("lister meme sur un hit", "ne jamais reemettre la session
// demandee") sont les memes pour cet outil et pour les trois
// bindings, et une regle recopiee par surface diverge en silence.
let loaded = tokio::task::spawn_blocking(move || {
service.run(|current| current.resume_working_context(&project, &session))
})
.await
.map_err(join_error)?
.map_err(to_error)?;
// Les ids sortent en CHAINE decimale, sans option, contrairement au
// compilateur ou `ids_as_strings` est un choix de l'appelant.
//
// Ici il n'y a pas de choix a offrir : cet outil est la moitie
// LECTURE d'un aller-retour dont la moitie ECRITURE
// (`save_working_context`) n'annonce plus qu'une forme, la chaine —
// depuis que le schema d'entree ne peut plus publier d'union. Rendre
// un nombre la ou le jumeau n'accepte qu'une chaine oblige le client
// a convertir ; et sur un client JSON a nombres flottants, la valeur
// est deja arrondie a la LECTURE, donc il reecrit un id faux avec
// l'exactitude apparente d'une chaine. Un contexte de travail existe
// pour survivre a une perte de contexte : sa trace de provenance ne
// peut pas se rompre en silence entre deux sessions.
//
// Le schema de sortie annonce deja `["integer", "string"]` sur ces
// champs (`widen_id_properties`), donc emettre la branche chaine est
// valide au sens du schema publie : aucun SDK ne rejette la reponse.
let mut value = serde_json::to_value(loaded).map_err(|err| {
ErrorData::internal_error(
format!("Failed to serialize structured content: {err}"),
None,
)
})?;
stringify_id_fields(&mut value);
Ok(Json(value))
}
#[tool(
name = "list_working_contexts",
// Same reason as the four tools wired in `mcp.rs`: an rmcp-derived
// output schema keeps `$ref`s a `$defs`-blind client cannot resolve.
output_schema = wire_safe_output_schema::<ListWorkingContextsResult>(),
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."
)]
async fn list_working_contexts(
&self,
Parameters(params): Parameters<ListWorkingContextsParams>,
) -> Result<Json<ListWorkingContextsResult>, ErrorData> {
let service = Arc::clone(&self.service);
let ListWorkingContextsParams { project } = params;
let sessions = tokio::task::spawn_blocking(move || {
service.run(|current| current.list_working_contexts(&project))
})
.await
.map_err(join_error)?
.map_err(to_error)?;
Ok(Json(ListWorkingContextsResult { sessions }))
}
#[tool(
name = "suggest_budget",
// Sans declaration explicite, rmcp derive un schema de sortie qui
// conserve des $ref qu'un client aveugle aux $defs ne resout pas —
// or les SDK MCP valident structuredContent contre ce schema.
output_schema = wire_safe_output_schema::<SuggestedBudget>(),
description = "Suggest a starting token_budget for compile_context for a named model — looked up in a static, committed model→context-window table (never a network call). `reserve_tokens` reserves room for the response, mirroring `policy.response_reserve_tokens`. `window`/`suggested_budget` come back null for an unlisted model — an honest \"unknown\", never a guess."
)]
async fn suggest_budget(
&self,
Parameters(params): Parameters<SuggestBudgetParams>,
) -> Result<Json<SuggestedBudget>, ErrorData> {
let SuggestBudgetParams {
target_model,
reserve_tokens,
} = params;
Ok(Json(suggest_token_budget(
&target_model,
reserve_tokens.unwrap_or(0),
)))
}
}
#[cfg(test)]
#[path = "context_tools_tests.rs"]
mod tests;