1use 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
29fn 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
81pub(super) use crate::schema::wire_safe_output_schema;
89
90pub(super) use crate::schema::wire_safe_input_schema;
102
103#[derive(Debug, Deserialize, JsonSchema)]
107pub(super) struct ContextSavingsParams {
108 pub project: Option<String>,
110}
111
112#[derive(Debug, Deserialize, JsonSchema)]
114#[schemars(transform = crate::schema::strip_int_formats)]
115pub(super) struct ExplainCompilationParams {
116 #[serde(deserialize_with = "super::wire::lenient")]
119 pub request: CompileRequest,
120 #[serde(deserialize_with = "crate::model::deserialize_id")]
141 pub fragment_id: u64,
142 #[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#[derive(Debug, Deserialize, JsonSchema)]
161pub(super) struct RetrieveContextSourceParams {
162 pub handle: String,
164}
165
166#[derive(Debug, serde::Serialize, JsonSchema)]
168pub(super) struct RetrieveContextSourceResult {
169 pub handle: String,
171 pub content: String,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub media: Option<MediaRef>,
177}
178
179#[derive(Debug, Deserialize, JsonSchema)]
181#[schemars(transform = crate::schema::strip_int_formats)]
182pub(super) struct SaveWorkingContextParams {
183 pub project: String,
186 pub session: String,
189 #[serde(deserialize_with = "super::wire::lenient")]
193 pub working: WorkingContext,
194}
195
196#[derive(Debug, serde::Serialize, JsonSchema)]
198#[schemars(transform = crate::schema::strip_int_formats)]
199pub(super) struct SaveWorkingContextResult {
200 pub id: u64,
202 pub id_str: String,
209}
210
211#[derive(Debug, Deserialize, JsonSchema)]
213pub(super) struct LoadWorkingContextParams {
214 pub project: String,
216 pub session: String,
218}
219
220#[derive(Debug, Deserialize, JsonSchema)]
222pub(super) struct ListWorkingContextsParams {
223 pub project: String,
226}
227
228#[derive(Debug, serde::Serialize, JsonSchema)]
230pub(super) struct ListWorkingContextsResult {
231 pub sessions: Vec<WorkingContextSession>,
234}
235
236#[derive(Debug, Deserialize, JsonSchema)]
238#[schemars(transform = crate::schema::strip_int_formats)]
239pub(super) struct CompileTranscriptParams {
240 pub query: String,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub transcript: Option<String>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub path: Option<String>,
256 #[serde(deserialize_with = "super::wire::lenient")]
259 pub token_budget: u64,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub project: Option<String>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub target_model: Option<String>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub policy: Option<CompilePolicy>,
270 #[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#[derive(Debug, serde::Serialize, serde::Deserialize, JsonSchema)]
285#[schemars(transform = crate::schema::strip_int_formats)]
286pub(super) struct SegmentInfo {
287 pub index: usize,
290 pub turn: usize,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub role: Option<String>,
296 pub kind: SegmentKind,
298 pub byte_start: usize,
308 pub byte_end: usize,
311 pub fragment_id: u64,
315}
316
317#[derive(Debug, serde::Serialize, serde::Deserialize, JsonSchema)]
319pub(super) struct SegmentationReport {
320 pub format_detected: SegmentFormat,
323 pub segments: Vec<SegmentInfo>,
336 pub merged_segments: usize,
339}
340
341#[derive(Debug, serde::Serialize, serde::Deserialize, JsonSchema)]
343pub(super) struct CompileTranscriptResult {
344 pub context: CompiledContext,
347 pub segmentation: SegmentationReport,
349}
350
351#[derive(Debug, Deserialize, JsonSchema)]
353pub(super) struct SuggestBudgetParams {
354 pub target_model: String,
357 #[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 #[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 #[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 #[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 #[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 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 #[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 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 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 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 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 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 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 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 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 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;