vestige/crates/vestige-mcp/src/server.rs

2251 lines
100 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! MCP Server Core
//!
//! Handles the main MCP server logic, routing requests to appropriate
//! tool and resource handlers.
use chrono::Utc;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::{Mutex, broadcast};
use tracing::{debug, info, warn};
use crate::cognitive::CognitiveEngine;
use crate::dashboard::events::VestigeEvent;
use crate::protocol::messages::{
CallToolRequest, CallToolResult, InitializeRequest, InitializeResult, ListResourcesResult,
ListToolsResult, ReadResourceRequest, ReadResourceResult, ResourceDescription,
ServerCapabilities, ServerInfo, ToolDescription,
};
use crate::protocol::types::{JsonRpcError, JsonRpcRequest, JsonRpcResponse, MCP_VERSION};
use crate::resources;
use crate::tools;
use vestige_core::{OutputConfig, Storage, VestigeConfig};
/// Build the MCP `instructions` string injected into every connecting client's
/// system prompt.
///
/// Default ("minimal", 3 sentences) is safe for any user: competitive coders,
/// hobbyists saving recipes, Rails devs saving bug fixes, enterprise deployments.
/// It earns its per-session token cost by telling the client *how* to use
/// Vestige without imposing one maintainer's workflow on strangers.
///
/// The "full" variant is the composition mandate that enforces the
/// Composing / Never-composed / Recommendation response shape. It can misfire
/// on trivial retrievals for a general audience, so it is opt-in via
/// `VESTIGE_SYSTEM_PROMPT_MODE=full`.
///
/// Anything other than `full` falls back to minimal.
fn build_instructions() -> String {
let mode = std::env::var("VESTIGE_SYSTEM_PROMPT_MODE").unwrap_or_default();
if mode.eq_ignore_ascii_case("full") {
"Vestige is your long-term cognitive memory AND reasoning engine, not a RAG database. \
Every retrieval MUST be composed into a recommendation, never summarized.\
\n\nCOMPOSITION MANDATE: When you receive memories from search, deep_reference, \
cross_reference, or explore_connections, your response MUST follow this shape. \
(a) Composing: [memory IDs], followed by a brief composition rationale \
about how the memories relate, NOT a restatement of their contents). \
(b) Never-composed detected: list combinations of retrieved memories that share \
tags/topics but have never been referenced together, or write 'None.' \
(c) Recommendation: what the user should DO, as a concrete executable action. \
If your draft begins 'Memory A says X. Memory B says Y.' STOP and rewrite.\
\n\nBLOCKING PHRASE: If retrieved high-trust memories (retention > 0.7, reps > 0) \
contradict what you were about to say, start your response with 'Vestige is blocking this:' \
and surface the contradiction verbatim before proceeding. FSRS trust overrides fresh guesses.\
\n\nFEEDBACK: If the user confirms a memory was helpful, call memory(action='promote'). \
If they correct it, call memory(action='demote'). Do not ask permission, just act."
.to_string()
} else {
"Vestige is your long-term memory system. Compose retrievals into recommendations \
rather than listing their contents when the user is making a decision. \
On user feedback, call memory(action='promote') for helpful retrievals and \
memory(action='demote') for wrong ones — do not ask permission, just act."
.to_string()
}
}
fn supported_protocol_versions() -> &'static [&'static str] {
&["2024-11-05", "2025-03-26", "2025-06-18", MCP_VERSION]
}
/// MCP Server implementation
pub struct McpServer {
storage: Arc<Storage>,
cognitive: Arc<Mutex<CognitiveEngine>>,
initialized: bool,
/// Tool call counter for inline consolidation trigger (every 100 calls)
tool_call_count: AtomicU64,
/// Optional event broadcast channel for dashboard real-time updates.
event_tx: Option<broadcast::Sender<VestigeEvent>>,
/// Resolved output config from `<data_dir>/vestige.toml` (Phase 2). Tools
/// use it as the fallback for detail/limit when no explicit MCP param is
/// given; explicit params always win.
output_config: Arc<OutputConfig>,
}
/// Load `vestige.toml` from the storage's data directory and resolve it to an
/// effective [`OutputConfig`]. A missing/malformed file yields the built-in
/// default, which preserves historical behavior.
fn load_output_config(storage: &Arc<Storage>) -> Arc<OutputConfig> {
let config = VestigeConfig::load_from_data_dir(storage.data_dir());
Arc::new(config.output())
}
impl McpServer {
#[allow(dead_code)]
pub fn new(storage: Arc<Storage>, cognitive: Arc<Mutex<CognitiveEngine>>) -> Self {
let output_config = load_output_config(&storage);
Self {
storage,
cognitive,
initialized: false,
tool_call_count: AtomicU64::new(0),
event_tx: None,
output_config,
}
}
/// Create an MCP server that broadcasts events to the dashboard.
pub fn new_with_events(
storage: Arc<Storage>,
cognitive: Arc<Mutex<CognitiveEngine>>,
event_tx: broadcast::Sender<VestigeEvent>,
) -> Self {
let output_config = load_output_config(&storage);
Self {
storage,
cognitive,
initialized: false,
tool_call_count: AtomicU64::new(0),
event_tx: Some(event_tx),
output_config,
}
}
/// Emit an event to the dashboard (no-op if no event channel).
fn emit(&self, event: VestigeEvent) {
if let Some(ref tx) = self.event_tx {
let _ = tx.send(event);
}
}
/// Handle an incoming JSON-RPC request
pub async fn handle_request(&mut self, request: JsonRpcRequest) -> Option<JsonRpcResponse> {
debug!("Handling request: {}", request.method);
if request.id.is_none() {
if request.method != "notifications/initialized" {
debug!("Dropping JSON-RPC notification '{}'", request.method);
}
return None;
}
// Check initialization for non-initialize requests
if !self.initialized
&& request.method != "initialize"
&& request.method != "notifications/initialized"
{
warn!(
"Rejecting request '{}': server not initialized",
request.method
);
return Some(JsonRpcResponse::error(
request.id,
JsonRpcError::server_not_initialized(),
));
}
let result = match request.method.as_str() {
"initialize" => self.handle_initialize(request.params).await,
"notifications/initialized" => Err(JsonRpcError::invalid_request(
"notifications/initialized must be sent without an id",
)),
"tools/list" => self.handle_tools_list().await,
"tools/call" => self.handle_tools_call(request.params).await,
"resources/list" => self.handle_resources_list().await,
"resources/read" => self.handle_resources_read(request.params).await,
"ping" => Ok(serde_json::json!({})),
method => {
warn!("Unknown method: {}", method);
Err(JsonRpcError::method_not_found())
}
};
Some(match result {
Ok(result) => JsonRpcResponse::success(request.id, result),
Err(error) => JsonRpcResponse::error(request.id, error),
})
}
/// Handle initialize request
async fn handle_initialize(
&mut self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value, JsonRpcError> {
let request: InitializeRequest = match params {
Some(p) => serde_json::from_value(p)
.map_err(|e| JsonRpcError::invalid_params(&e.to_string()))?,
None => {
return Err(JsonRpcError::invalid_params(
"initialize params are required",
));
}
};
let negotiated_version =
if supported_protocol_versions().contains(&request.protocol_version.as_str()) {
info!(
"Client requested supported protocol version {}, using it",
request.protocol_version
);
request.protocol_version.clone()
} else {
info!(
"Client requested unsupported protocol version {}, using {}",
request.protocol_version, MCP_VERSION
);
MCP_VERSION.to_string()
};
self.initialized = true;
info!(
"MCP session initialized with protocol version {}",
negotiated_version
);
let result = InitializeResult {
protocol_version: negotiated_version,
server_info: ServerInfo {
name: "vestige".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
capabilities: ServerCapabilities {
tools: Some({
let mut map = HashMap::new();
map.insert("listChanged".to_string(), serde_json::json!(false));
map
}),
resources: Some({
let mut map = HashMap::new();
map.insert("listChanged".to_string(), serde_json::json!(false));
map
}),
prompts: None,
},
instructions: Some(build_instructions()),
};
serde_json::to_value(result).map_err(|e| JsonRpcError::internal_error(&e.to_string()))
}
/// Handle tools/list request
async fn handle_tools_list(&self) -> Result<serde_json::Value, JsonRpcError> {
// v2.1.21: 25 tools (verified by the `tools.len() == 25` assertion in the
// handle_tools_list test below — the `suppress` tool landed in v2.0.5).
// Deprecated tools still work via redirects in handle_tools_call.
let mut tools = vec![
// ================================================================
// UNIFIED TOOLS (v1.1+)
// ================================================================
ToolDescription {
name: "search".to_string(),
description: Some("Unified search tool. Uses hybrid search (keyword + semantic + convex combination fusion) internally. Auto-strengthens memories on access (Testing Effect).".to_string()),
input_schema: tools::search_unified::schema(),
..Default::default()
},
ToolDescription {
name: "memory".to_string(),
description: Some("Unified memory management tool. Actions: 'get' (retrieve full node), 'purge' (irreversibly remove content/embeddings with confirm=true), 'delete' (legacy alias for purge), 'state' (get accessibility state), 'promote' (thumbs up — increases retrieval strength), 'demote' (thumbs down — decreases retrieval strength, does NOT delete), 'edit' (update content in-place, preserves FSRS state).".to_string()),
input_schema: tools::memory_unified::schema(),
..Default::default()
},
ToolDescription {
name: "codebase".to_string(),
description: Some("Unified codebase tool. Actions: 'remember_pattern' (store code pattern), 'remember_decision' (store architectural decision), 'get_context' (retrieve patterns and decisions).".to_string()),
input_schema: tools::codebase_unified::schema(),
..Default::default()
},
ToolDescription {
name: "intention".to_string(),
description: Some("Unified intention management tool. Actions: 'set' (create), 'check' (find triggered), 'update' (complete/snooze/cancel), 'list' (show intentions).".to_string()),
input_schema: tools::intention_unified::schema(),
..Default::default()
},
// ================================================================
// CORE MEMORY (v1.7: smart_ingest absorbs ingest + checkpoint)
// ================================================================
ToolDescription {
name: "smart_ingest".to_string(),
description: Some("INTELLIGENT memory ingestion with Prediction Error Gating. Single mode: provide 'content' to auto-decide CREATE/UPDATE/SUPERSEDE. Batch mode: provide 'items' array (max 20) for session-end saves — each item runs the full cognitive pipeline (importance scoring, intent detection, synaptic tagging).".to_string()),
input_schema: tools::smart_ingest::schema(),
..Default::default()
},
// ================================================================
// EXTERNAL-SOURCE CONNECTORS (#57)
// ================================================================
ToolDescription {
name: "source_sync".to_string(),
description: Some("Index an external system into Vestige as a durable, offline, semantically-searchable index that cites back to the canonical record. GitHub: source='github', repo='owner/name' (auth via GITHUB_TOKEN env). Redmine: source='redmine', project='<id>' (host via REDMINE_URL, auth via REDMINE_API_KEY env). Idempotent: re-running updates changed issues without duplicating; set reconcile=true to tombstone issues removed upstream.".to_string()),
input_schema: tools::source_sync::schema(),
..Default::default()
},
// ================================================================
// TEMPORAL TOOLS (v1.2+)
// ================================================================
ToolDescription {
name: "memory_timeline".to_string(),
description: Some("Browse memories chronologically. Returns memories in a time range, grouped by day. Defaults to last 7 days.".to_string()),
input_schema: tools::timeline::schema(),
..Default::default()
},
ToolDescription {
name: "memory_changelog".to_string(),
description: Some("View audit trail of memory changes. Per-memory: state transitions. System-wide: consolidations + recent state changes.".to_string()),
input_schema: tools::changelog::schema(),
..Default::default()
},
// ================================================================
// MAINTENANCE TOOLS (v1.7: system_status replaces health_check + stats)
// ================================================================
ToolDescription {
name: "system_status".to_string(),
description: Some("Combined system health and statistics. Returns status (healthy/degraded/critical/empty), full stats, FSRS preview, cognitive module health, state distribution, warnings, and recommendations.".to_string()),
input_schema: tools::maintenance::system_status_schema(),
..Default::default()
},
ToolDescription {
name: "consolidate".to_string(),
description: Some("Run FSRS-6 memory consolidation cycle. Applies decay, generates embeddings, and performs maintenance. Use when memories seem stale.".to_string()),
input_schema: tools::maintenance::consolidate_schema(),
..Default::default()
},
ToolDescription {
name: "backup".to_string(),
description: Some("Create a SQLite database backup. Returns the backup file path.".to_string()),
input_schema: tools::maintenance::backup_schema(),
..Default::default()
},
ToolDescription {
name: "export".to_string(),
description: Some("Export memories as JSON or JSONL. Supports tag and date filters.".to_string()),
input_schema: tools::maintenance::export_schema(),
..Default::default()
},
ToolDescription {
name: "gc".to_string(),
description: Some("Garbage collect stale memories below retention threshold. Defaults to dry_run=true for safety.".to_string()),
input_schema: tools::maintenance::gc_schema(),
..Default::default()
},
// ================================================================
// AUTO-SAVE & DEDUP TOOLS (v1.3+)
// ================================================================
ToolDescription {
name: "importance_score".to_string(),
description: Some("Score content importance using 4-channel neuroscience model (novelty/arousal/reward/attention). Returns composite score, channel breakdown, encoding boost, and explanations.".to_string()),
input_schema: tools::importance::schema(),
..Default::default()
},
ToolDescription {
name: "find_duplicates".to_string(),
description: Some("Find duplicate and near-duplicate memory clusters using cosine similarity on embeddings. Returns clusters with suggested actions (merge/review). Use to clean up redundant memories.".to_string()),
input_schema: tools::dedup::schema(),
..Default::default()
},
// ================================================================
// MERGE / SUPERSEDE CONTROLS (v2.1.25 — Phase 3)
// Diff-previewed, confidence-gated, reversible, never silent.
// ================================================================
ToolDescription {
name: "merge_candidates".to_string(),
description: Some("Surface likely duplicate/overlapping memory clusters with confidence scores and the signals behind each (Fellegi-Sunter match/possible/non-match). Read-only — nothing is changed.".to_string()),
input_schema: tools::merge::merge_candidates_schema(),
..Default::default()
},
ToolDescription {
name: "plan_merge".to_string(),
description: Some("Produce a previewable MERGE plan (a diff: combined content/tags/provenance) for 2+ memories WITHOUT applying it. Returns a plan_id for apply_plan. Protected members block the merge.".to_string()),
input_schema: tools::merge::plan_merge_schema(),
..Default::default()
},
ToolDescription {
name: "plan_supersede".to_string(),
description: Some("Preview superseding memory A with B — bitemporal invalidation (stamps valid_until, keeps A queryable for audit) WITHOUT applying. Returns a plan_id for apply_plan.".to_string()),
input_schema: tools::merge::plan_supersede_schema(),
..Default::default()
},
ToolDescription {
name: "apply_plan".to_string(),
description: Some("Execute a previously-generated merge/supersede plan by id. Recorded as a reversible operation. Old memories are invalidated (never deleted). 'possible'/'non_match' plans require confirm=true.".to_string()),
input_schema: tools::merge::apply_plan_schema(),
..Default::default()
},
ToolDescription {
name: "merge_undo".to_string(),
description: Some("Reverse a prior merge/supersede operation (the 'git reflog for your agent's memory'). With no operation_id, lists the reversible operation log so you can pick one.".to_string()),
input_schema: tools::merge::merge_undo_schema(),
..Default::default()
},
ToolDescription {
name: "protect".to_string(),
description: Some("Pin a memory so it can never be auto-merged, superseded, or garbage-collected. Pass protected=false to unpin.".to_string()),
input_schema: tools::merge::protect_schema(),
..Default::default()
},
ToolDescription {
name: "merge_policy".to_string(),
description: Some("Get or set the per-project merge policy: the two Fellegi-Sunter thresholds (match_threshold, possible_threshold) and auto_apply. No args returns the current policy.".to_string()),
input_schema: tools::merge::merge_policy_schema(),
..Default::default()
},
// ================================================================
// COGNITIVE TOOLS (v1.5+)
// ================================================================
ToolDescription {
name: "dream".to_string(),
description: Some("Trigger memory dreaming — replays recent memories to discover hidden connections, synthesize insights, and strengthen important patterns. Returns insights, connections, and dream stats.".to_string()),
input_schema: tools::dream::schema(),
..Default::default()
},
ToolDescription {
name: "explore_connections".to_string(),
description: Some("Graph exploration tool for memory connections. Actions: 'chain' (build reasoning path between memories), 'associations' (find related memories via spreading activation + hippocampal index), 'bridges' (find connecting memories between two nodes).".to_string()),
input_schema: tools::explore::schema(),
..Default::default()
},
ToolDescription {
name: "predict".to_string(),
description: Some("Proactive memory prediction — predicts what memories you'll need next based on context, recent activity, and learned patterns. Returns predictions, suggestions, and speculative retrievals.".to_string()),
input_schema: tools::predict::schema(),
..Default::default()
},
// ================================================================
// RESTORE TOOL (v1.5+)
// ================================================================
ToolDescription {
name: "restore".to_string(),
description: Some("Restore memories from a JSON backup file. Supports MCP wrapper format, RecallResult format, and direct memory array format.".to_string()),
input_schema: tools::restore::schema(),
..Default::default()
},
// ================================================================
// CONTEXT PACKETS (v1.8+)
// ================================================================
ToolDescription {
name: "session_context".to_string(),
description: Some("One-call session initialization. Combines search, intentions, status, predictions, and codebase context into a single token-budgeted response. Replaces 5 separate calls at session start.".to_string()),
input_schema: tools::session_context::schema(),
..Default::default()
},
// ================================================================
// AUTONOMIC TOOLS (v1.9+)
// ================================================================
ToolDescription {
name: "memory_health".to_string(),
description: Some("Retention dashboard. Returns avg retention, retention distribution (buckets: 0-20%, 20-40%, etc.), trend (improving/declining/stable), and recommendation. Lightweight alternative to full system_status focused on memory quality.".to_string()),
input_schema: tools::health::schema(),
..Default::default()
},
ToolDescription {
name: "memory_graph".to_string(),
description: Some("Subgraph export for visualization. Input: center_id or query, depth (1-3), max_nodes. Returns nodes with force-directed layout positions and edges with weights. Powers memory graph visualization.".to_string()),
input_schema: tools::graph::schema(),
..Default::default()
},
ToolDescription {
name: "composed_graph".to_string(),
description: Some("ComposedGraph memory topology. Reads durable composition events, members, and outcome labels; returns recent/already-composed lanes, neighbors, never-composed pairs, bounty-mode lanes, and lets users label outcomes such as helpful, submitted, accepted, rejected, duplicate_risk, needs_poc, or dead_end.".to_string()),
input_schema: tools::composed_graph::schema(),
..Default::default()
},
// ================================================================
// DEEP REFERENCE (v2.0.4+) — replaces cross_reference
// ================================================================
ToolDescription {
name: "deep_reference".to_string(),
description: Some("Deep cognitive reasoning across memories. Combines FSRS-6 trust scoring, spreading activation, temporal supersession, dream insights, and contradiction analysis to build a complete understanding of a topic. Returns trust-scored evidence, fact evolution timeline, and a recommended answer. Use this when accuracy matters.".to_string()),
input_schema: tools::cross_reference::schema(),
..Default::default()
},
ToolDescription {
name: "cross_reference".to_string(),
description: Some("Alias for deep_reference. Connect the dots across memories with cognitive reasoning.".to_string()),
input_schema: tools::cross_reference::schema(),
..Default::default()
},
ToolDescription {
name: "contradictions".to_string(),
description: Some("Inspect memory disagreements directly. Scans a topic or recent memories for trust-weighted contradiction pairs using the same local logic as deep_reference.".to_string()),
input_schema: tools::contradictions::schema(),
..Default::default()
},
// ================================================================
// ACTIVE FORGETTING (v2.0.5) — top-down suppression
// Anderson et al. 2025 Nat Rev Neurosci + Davis Rac1
// ================================================================
ToolDescription {
name: "suppress".to_string(),
description: Some("Actively suppress a memory via top-down inhibitory control (Anderson 2025 SIF + Davis Rac1). Distinct from delete: the memory persists but is inhibited from retrieval and actively decays. Each call compounds. A background Rac1 worker cascades decay to co-activated neighbors. Reversible within 24 hours via reverse=true.".to_string()),
input_schema: tools::suppress::schema(),
..Default::default()
},
];
// Per-tool result-size annotation `_meta["anthropic/maxResultSizeChars"]`.
//
// Claude Code v2.1.91+ honors this annotation to override its 50K default
// `CallToolResult` truncation. Without it, large Vestige payloads
// (`search` with `detail_level="full"` at `limit=20` has been observed
// at ~135K chars; `memory_timeline` at `limit=30` at ~84K chars) are
// silently truncated and spilled to disk, forcing the parent agent to
// chunk-read them.
//
// Per-tool caps below are sized at ~2× observed peak with growth
// headroom; max permitted by Anthropic is 500_000. Only the four
// empirically-measured high-payload tools carry the annotation today;
// the remaining 21 tools deliberately do NOT (cargo-cult prevention —
// annotating a small-payload tool dilutes the signal).
//
// Other tools that COULD plausibly grow into the annotated set with
// future workload (`deep_reference`, `cross_reference`, `memory_graph`,
// `explore_connections`, `session_context`) are left unannotated until
// empirical measurement shows truncation under realistic use.
for tool in tools.iter_mut() {
let max_chars: Option<u64> = match tool.name.as_str() {
"search" => Some(300_000),
"memory_timeline" => Some(200_000),
"memory" => Some(100_000),
"codebase" => Some(100_000),
_ => None,
};
if let Some(n) = max_chars {
let mut meta = serde_json::Map::new();
meta.insert(
"anthropic/maxResultSizeChars".to_string(),
serde_json::Value::from(n),
);
tool.meta = Some(serde_json::Value::Object(meta));
}
}
let result = ListToolsResult { tools };
serde_json::to_value(result).map_err(|e| JsonRpcError::internal_error(&e.to_string()))
}
/// Handle tools/call request
async fn handle_tools_call(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value, JsonRpcError> {
let request: CallToolRequest = match params {
Some(p) => serde_json::from_value(p)
.map_err(|e| JsonRpcError::invalid_params(&e.to_string()))?,
None => return Err(JsonRpcError::invalid_params("Missing tool call parameters")),
};
if let Some(arguments) = &request.arguments
&& !arguments.is_object()
{
return Err(JsonRpcError::invalid_params(
"tools/call arguments must be an object",
));
}
// Record activity on every tool call (non-blocking)
if let Ok(mut cog) = self.cognitive.try_lock() {
cog.activity_tracker.record_activity();
cog.consolidation_scheduler.record_activity();
}
// Save args for event emission (tool dispatch consumes request.arguments)
let saved_args = if self.event_tx.is_some() {
request.arguments.clone()
} else {
None
};
let result = match request.name.as_str() {
// ================================================================
// UNIFIED TOOLS (v1.1+) - Preferred API
// ================================================================
"search" => {
tools::search_unified::execute(
&self.storage,
&self.cognitive,
&self.output_config,
request.arguments,
)
.await
}
"memory" => {
tools::memory_unified::execute(&self.storage, &self.cognitive, request.arguments)
.await
}
"codebase" => {
tools::codebase_unified::execute(
&self.storage,
&self.cognitive,
&self.output_config,
request.arguments,
)
.await
}
"intention" => {
tools::intention_unified::execute(&self.storage, &self.cognitive, request.arguments)
.await
}
// ================================================================
// Core memory (v1.7: smart_ingest absorbs ingest + checkpoint)
// ================================================================
"smart_ingest" => {
tools::smart_ingest::execute(&self.storage, &self.cognitive, request.arguments)
.await
}
// ================================================================
// External-source connectors (#57)
// ================================================================
"source_sync" => tools::source_sync::execute(&self.storage, request.arguments).await,
// ================================================================
// DEPRECATED (v1.7): ingest → smart_ingest
// ================================================================
"ingest" => {
warn!("Tool 'ingest' is deprecated in v1.7. Use 'smart_ingest' instead.");
tools::smart_ingest::execute(&self.storage, &self.cognitive, request.arguments)
.await
}
// ================================================================
// DEPRECATED (v1.7): session_checkpoint → smart_ingest (batch mode)
// ================================================================
"session_checkpoint" => {
warn!(
"Tool 'session_checkpoint' is deprecated in v1.7. Use 'smart_ingest' with 'items' parameter instead."
);
tools::smart_ingest::execute(&self.storage, &self.cognitive, request.arguments)
.await
}
// ================================================================
// DEPRECATED (v1.7): promote_memory → memory(action='promote')
// ================================================================
"promote_memory" => {
warn!(
"Tool 'promote_memory' is deprecated in v1.7. Use 'memory' with action='promote' instead."
);
let unified_args = match request.arguments {
Some(ref args) => {
let mut new_args = args.clone();
if let Some(obj) = new_args.as_object_mut() {
obj.insert("action".to_string(), serde_json::json!("promote"));
}
Some(new_args)
}
None => Some(serde_json::json!({"action": "promote"})),
};
tools::memory_unified::execute(&self.storage, &self.cognitive, unified_args).await
}
"demote_memory" => {
warn!(
"Tool 'demote_memory' is deprecated in v1.7. Use 'memory' with action='demote' instead."
);
let unified_args = match request.arguments {
Some(ref args) => {
let mut new_args = args.clone();
if let Some(obj) = new_args.as_object_mut() {
obj.insert("action".to_string(), serde_json::json!("demote"));
}
Some(new_args)
}
None => Some(serde_json::json!({"action": "demote"})),
};
tools::memory_unified::execute(&self.storage, &self.cognitive, unified_args).await
}
// ================================================================
// DEPRECATED (v1.7): health_check, stats → system_status
// ================================================================
"health_check" => {
warn!("Tool 'health_check' is deprecated in v1.7. Use 'system_status' instead.");
tools::maintenance::execute_system_status(
&self.storage,
&self.cognitive,
request.arguments,
)
.await
}
"stats" => {
warn!("Tool 'stats' is deprecated in v1.7. Use 'system_status' instead.");
tools::maintenance::execute_system_status(
&self.storage,
&self.cognitive,
request.arguments,
)
.await
}
// ================================================================
// SYSTEM STATUS (v1.7: replaces health_check + stats)
// ================================================================
"system_status" => {
tools::maintenance::execute_system_status(
&self.storage,
&self.cognitive,
request.arguments,
)
.await
}
"mark_reviewed" => tools::review::execute(&self.storage, request.arguments).await,
// ================================================================
// DEPRECATED: Search tools - redirect to unified 'search'
// ================================================================
"recall" | "semantic_search" | "hybrid_search" => {
warn!(
"Tool '{}' is deprecated. Use 'search' instead.",
request.name
);
tools::search_unified::execute(
&self.storage,
&self.cognitive,
&self.output_config,
request.arguments,
)
.await
}
// ================================================================
// DEPRECATED: Memory tools - redirect to unified 'memory'
// ================================================================
"get_knowledge" => {
warn!(
"Tool 'get_knowledge' is deprecated. Use 'memory' with action='get' instead."
);
let unified_args = match request.arguments {
Some(ref args) => {
let id = args.get("id").cloned().unwrap_or(serde_json::Value::Null);
Some(serde_json::json!({
"action": "get",
"id": id
}))
}
None => None,
};
tools::memory_unified::execute(&self.storage, &self.cognitive, unified_args).await
}
"delete_knowledge" => {
warn!(
"Tool 'delete_knowledge' is deprecated. Use 'memory' with action='purge', confirm=true instead."
);
let unified_args = match request.arguments {
Some(ref args) => {
let id = args.get("id").cloned().unwrap_or(serde_json::Value::Null);
let confirm = args
.get("confirm")
.cloned()
.unwrap_or(serde_json::Value::Bool(false));
Some(serde_json::json!({
"action": "delete",
"id": id,
"confirm": confirm
}))
}
None => None,
};
tools::memory_unified::execute(&self.storage, &self.cognitive, unified_args).await
}
"get_memory_state" => {
warn!(
"Tool 'get_memory_state' is deprecated. Use 'memory' with action='state' instead."
);
let unified_args = match request.arguments {
Some(ref args) => {
let id = args
.get("memory_id")
.cloned()
.unwrap_or(serde_json::Value::Null);
Some(serde_json::json!({
"action": "state",
"id": id
}))
}
None => None,
};
tools::memory_unified::execute(&self.storage, &self.cognitive, unified_args).await
}
// ================================================================
// DEPRECATED: Codebase tools - redirect to unified 'codebase'
// ================================================================
"remember_pattern" => {
warn!(
"Tool 'remember_pattern' is deprecated. Use 'codebase' with action='remember_pattern' instead."
);
let unified_args = match request.arguments {
Some(ref args) => {
let mut new_args = args.clone();
if let Some(obj) = new_args.as_object_mut() {
obj.insert("action".to_string(), serde_json::json!("remember_pattern"));
}
Some(new_args)
}
None => Some(serde_json::json!({"action": "remember_pattern"})),
};
tools::codebase_unified::execute(
&self.storage,
&self.cognitive,
&self.output_config,
unified_args,
)
.await
}
"remember_decision" => {
warn!(
"Tool 'remember_decision' is deprecated. Use 'codebase' with action='remember_decision' instead."
);
let unified_args = match request.arguments {
Some(ref args) => {
let mut new_args = args.clone();
if let Some(obj) = new_args.as_object_mut() {
obj.insert(
"action".to_string(),
serde_json::json!("remember_decision"),
);
}
Some(new_args)
}
None => Some(serde_json::json!({"action": "remember_decision"})),
};
tools::codebase_unified::execute(
&self.storage,
&self.cognitive,
&self.output_config,
unified_args,
)
.await
}
"get_codebase_context" => {
warn!(
"Tool 'get_codebase_context' is deprecated. Use 'codebase' with action='get_context' instead."
);
let unified_args = match request.arguments {
Some(ref args) => {
let mut new_args = args.clone();
if let Some(obj) = new_args.as_object_mut() {
obj.insert("action".to_string(), serde_json::json!("get_context"));
}
Some(new_args)
}
None => Some(serde_json::json!({"action": "get_context"})),
};
tools::codebase_unified::execute(
&self.storage,
&self.cognitive,
&self.output_config,
unified_args,
)
.await
}
// ================================================================
// DEPRECATED: Intention tools - redirect to unified 'intention'
// ================================================================
"set_intention" => {
warn!(
"Tool 'set_intention' is deprecated. Use 'intention' with action='set' instead."
);
let unified_args = match request.arguments {
Some(ref args) => {
let mut new_args = args.clone();
if let Some(obj) = new_args.as_object_mut() {
obj.insert("action".to_string(), serde_json::json!("set"));
}
Some(new_args)
}
None => Some(serde_json::json!({"action": "set"})),
};
tools::intention_unified::execute(&self.storage, &self.cognitive, unified_args)
.await
}
"check_intentions" => {
warn!(
"Tool 'check_intentions' is deprecated. Use 'intention' with action='check' instead."
);
let unified_args = match request.arguments {
Some(ref args) => {
let mut new_args = args.clone();
if let Some(obj) = new_args.as_object_mut() {
obj.insert("action".to_string(), serde_json::json!("check"));
}
Some(new_args)
}
None => Some(serde_json::json!({"action": "check"})),
};
tools::intention_unified::execute(&self.storage, &self.cognitive, unified_args)
.await
}
"complete_intention" => {
warn!(
"Tool 'complete_intention' is deprecated. Use 'intention' with action='update', status='complete' instead."
);
let unified_args = match request.arguments {
Some(ref args) => {
let id = args
.get("intentionId")
.cloned()
.unwrap_or(serde_json::Value::Null);
Some(serde_json::json!({
"action": "update",
"id": id,
"status": "complete"
}))
}
None => None,
};
tools::intention_unified::execute(&self.storage, &self.cognitive, unified_args)
.await
}
"snooze_intention" => {
warn!(
"Tool 'snooze_intention' is deprecated. Use 'intention' with action='update', status='snooze' instead."
);
let unified_args = match request.arguments {
Some(ref args) => {
let id = args
.get("intentionId")
.cloned()
.unwrap_or(serde_json::Value::Null);
let minutes = args
.get("minutes")
.cloned()
.unwrap_or(serde_json::json!(30));
Some(serde_json::json!({
"action": "update",
"id": id,
"status": "snooze",
"snooze_minutes": minutes
}))
}
None => None,
};
tools::intention_unified::execute(&self.storage, &self.cognitive, unified_args)
.await
}
"list_intentions" => {
warn!(
"Tool 'list_intentions' is deprecated. Use 'intention' with action='list' instead."
);
let unified_args = match request.arguments {
Some(ref args) => {
let mut new_args = args.clone();
if let Some(obj) = new_args.as_object_mut() {
obj.insert("action".to_string(), serde_json::json!("list"));
if let Some(status) = obj.remove("status") {
obj.insert("filter_status".to_string(), status);
}
}
Some(new_args)
}
None => Some(serde_json::json!({"action": "list"})),
};
tools::intention_unified::execute(&self.storage, &self.cognitive, unified_args)
.await
}
// ================================================================
// Neuroscience tools (internal, not in tools/list)
// ================================================================
"list_by_state" => {
tools::memory_states::execute_list(&self.storage, request.arguments).await
}
"state_stats" => tools::memory_states::execute_stats(&self.storage).await,
"trigger_importance" => {
tools::tagging::execute_trigger(&self.storage, request.arguments).await
}
"find_tagged" => tools::tagging::execute_find(&self.storage, request.arguments).await,
"tagging_stats" => tools::tagging::execute_stats(&self.storage).await,
"match_context" => tools::context::execute(&self.storage, request.arguments).await,
// ================================================================
// Feedback (internal, still used by request_feedback)
// ================================================================
"request_feedback" => {
tools::feedback::execute_request_feedback(&self.storage, request.arguments).await
}
// ================================================================
// TEMPORAL TOOLS (v1.2+)
// ================================================================
"memory_timeline" => {
tools::timeline::execute(&self.storage, &self.output_config, request.arguments)
.await
}
"memory_changelog" => tools::changelog::execute(&self.storage, request.arguments).await,
// ================================================================
// MAINTENANCE TOOLS (v1.2+, non-deprecated)
// ================================================================
"consolidate" => {
self.emit(VestigeEvent::ConsolidationStarted {
timestamp: chrono::Utc::now(),
});
tools::maintenance::execute_consolidate(&self.storage, request.arguments).await
}
"backup" => tools::maintenance::execute_backup(&self.storage, request.arguments).await,
"export" => tools::maintenance::execute_export(&self.storage, request.arguments).await,
"gc" => tools::maintenance::execute_gc(&self.storage, request.arguments).await,
// ================================================================
// AUTO-SAVE & DEDUP TOOLS (v1.3+)
// ================================================================
"importance_score" => {
tools::importance::execute(&self.storage, &self.cognitive, request.arguments).await
}
"find_duplicates" => tools::dedup::execute(&self.storage, request.arguments).await,
// ================================================================
// MERGE / SUPERSEDE CONTROLS (v2.1.25 — Phase 3)
// ================================================================
"merge_candidates" | "plan_merge" | "plan_supersede" | "apply_plan" | "merge_undo"
| "protect" | "merge_policy" => {
tools::merge::execute(&self.storage, request.name.as_str(), request.arguments).await
}
// ================================================================
// COGNITIVE TOOLS (v1.5+)
// ================================================================
"dream" => {
self.emit(VestigeEvent::DreamStarted {
memory_count: self
.storage
.get_stats()
.map(|s| s.total_nodes as usize)
.unwrap_or(0),
timestamp: chrono::Utc::now(),
});
tools::dream::execute(&self.storage, &self.cognitive, request.arguments).await
}
"explore_connections" => {
tools::explore::execute(&self.storage, &self.cognitive, request.arguments).await
}
"predict" => {
tools::predict::execute(&self.storage, &self.cognitive, request.arguments).await
}
"restore" => tools::restore::execute(&self.storage, request.arguments).await,
// ================================================================
// CONTEXT PACKETS (v1.8+)
// ================================================================
"session_context" => {
tools::session_context::execute(
&self.storage,
&self.cognitive,
&self.output_config,
request.arguments,
)
.await
}
// ================================================================
// AUTONOMIC TOOLS (v1.9+)
// ================================================================
"memory_health" => tools::health::execute(&self.storage, request.arguments).await,
"memory_graph" => tools::graph::execute(&self.storage, request.arguments).await,
"composed_graph" => {
tools::composed_graph::execute(&self.storage, request.arguments).await
}
"deep_reference" | "cross_reference" => {
tools::cross_reference::execute(&self.storage, &self.cognitive, request.arguments)
.await
}
"contradictions" => {
tools::contradictions::execute(&self.storage, request.arguments).await
}
// ================================================================
// ACTIVE FORGETTING (v2.0.5) — top-down suppression
// ================================================================
"suppress" => tools::suppress::execute(&self.storage, request.arguments).await,
name => {
return Err(JsonRpcError::invalid_params(&format!(
"Unknown tool: {}",
name
)));
}
};
// ================================================================
// DASHBOARD EVENT EMISSION (v2.0)
// Emit real-time events to WebSocket clients after successful tool calls.
// ================================================================
if let Ok(ref content) = result {
self.emit_tool_event(&request.name, &saved_args, content);
}
let response = match result {
Ok(content) => {
let call_result = CallToolResult {
content: vec![crate::protocol::messages::ToolResultContent {
content_type: "text".to_string(),
text: serde_json::to_string_pretty(&content)
.unwrap_or_else(|_| content.to_string()),
}],
structured_content: Some(content),
is_error: Some(false),
};
serde_json::to_value(call_result)
.map_err(|e| JsonRpcError::internal_error(&e.to_string()))
}
Err(e) => {
let error_content = serde_json::json!({ "error": e });
let call_result = CallToolResult {
content: vec![crate::protocol::messages::ToolResultContent {
content_type: "text".to_string(),
text: error_content.to_string(),
}],
structured_content: Some(error_content),
is_error: Some(true),
};
serde_json::to_value(call_result)
.map_err(|e| JsonRpcError::internal_error(&e.to_string()))
}
};
// Inline consolidation trigger: uses ConsolidationScheduler instead of fixed count
let count = self.tool_call_count.fetch_add(1, Ordering::Relaxed) + 1;
let should_consolidate = self
.cognitive
.try_lock()
.ok()
.map(|cog| cog.consolidation_scheduler.should_consolidate())
.unwrap_or(count.is_multiple_of(100)); // Fallback to count-based if lock unavailable
if should_consolidate {
let storage_clone = Arc::clone(&self.storage);
let cognitive_clone = Arc::clone(&self.cognitive);
tokio::spawn(async move {
// Expire labile reconsolidation windows
if let Ok(mut cog) = cognitive_clone.try_lock() {
let _expired = cog.reconsolidation.reconsolidate_expired();
}
match storage_clone.run_consolidation() {
Ok(result) => {
tracing::info!(
tool_calls = count,
decay_applied = result.decay_applied,
duplicates_merged = result.duplicates_merged,
activations_computed = result.activations_computed,
duration_ms = result.duration_ms,
"Inline consolidation triggered (scheduler)"
);
}
Err(e) => {
tracing::warn!("Inline consolidation failed: {}", e);
}
}
});
}
response
}
/// Handle resources/list request
async fn handle_resources_list(&self) -> Result<serde_json::Value, JsonRpcError> {
let resources = vec![
// Memory resources
ResourceDescription {
uri: "memory://stats".to_string(),
name: "Memory Statistics".to_string(),
description: Some("Current memory system statistics and health status".to_string()),
mime_type: Some("application/json".to_string()),
},
ResourceDescription {
uri: "memory://recent".to_string(),
name: "Recent Memories".to_string(),
description: Some("Recently added memories (last 10)".to_string()),
mime_type: Some("application/json".to_string()),
},
ResourceDescription {
uri: "memory://decaying".to_string(),
name: "Decaying Memories".to_string(),
description: Some("Memories with low retention that need review".to_string()),
mime_type: Some("application/json".to_string()),
},
ResourceDescription {
uri: "memory://due".to_string(),
name: "Due for Review".to_string(),
description: Some("Memories scheduled for review today".to_string()),
mime_type: Some("application/json".to_string()),
},
// Codebase resources
ResourceDescription {
uri: "codebase://structure".to_string(),
name: "Codebase Structure".to_string(),
description: Some("Remembered project structure and organization".to_string()),
mime_type: Some("application/json".to_string()),
},
ResourceDescription {
uri: "codebase://patterns".to_string(),
name: "Code Patterns".to_string(),
description: Some("Remembered code patterns and conventions".to_string()),
mime_type: Some("application/json".to_string()),
},
ResourceDescription {
uri: "codebase://decisions".to_string(),
name: "Architectural Decisions".to_string(),
description: Some("Remembered architectural and design decisions".to_string()),
mime_type: Some("application/json".to_string()),
},
// Consolidation resources
ResourceDescription {
uri: "memory://insights".to_string(),
name: "Consolidation Insights".to_string(),
description: Some("Insights generated during memory consolidation".to_string()),
mime_type: Some("application/json".to_string()),
},
ResourceDescription {
uri: "memory://consolidation-log".to_string(),
name: "Consolidation Log".to_string(),
description: Some("History of memory consolidation runs".to_string()),
mime_type: Some("application/json".to_string()),
},
// Prospective memory resources
ResourceDescription {
uri: "memory://intentions".to_string(),
name: "Active Intentions".to_string(),
description: Some(
"Future intentions (prospective memory) waiting to be triggered".to_string(),
),
mime_type: Some("application/json".to_string()),
},
ResourceDescription {
uri: "memory://intentions/due".to_string(),
name: "Triggered Intentions".to_string(),
description: Some("Intentions that have been triggered or are overdue".to_string()),
mime_type: Some("application/json".to_string()),
},
];
let result = ListResourcesResult { resources };
serde_json::to_value(result).map_err(|e| JsonRpcError::internal_error(&e.to_string()))
}
/// Handle resources/read request
async fn handle_resources_read(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value, JsonRpcError> {
let request: ReadResourceRequest = match params {
Some(p) => serde_json::from_value(p)
.map_err(|e| JsonRpcError::invalid_params(&e.to_string()))?,
None => return Err(JsonRpcError::invalid_params("Missing resource URI")),
};
let uri = &request.uri;
// Normalize URI: strip provider prefix (e.g., "vestige/") for scheme matching
// OpenCode and other MCP clients may send "vestige/memory://recent"
// but we register resources as "memory://recent"
let normalized_uri = uri.strip_prefix("vestige/").unwrap_or(uri);
let content = if normalized_uri.starts_with("memory://") {
resources::memory::read(&self.storage, normalized_uri).await
} else if normalized_uri.starts_with("codebase://") {
resources::codebase::read(&self.storage, normalized_uri).await
} else {
Err(format!("Unknown resource scheme: {}", uri))
};
match content {
Ok(text) => {
let result = ReadResourceResult {
contents: vec![crate::protocol::messages::ResourceContent {
uri: uri.clone(),
mime_type: Some("application/json".to_string()),
text: Some(text),
blob: None,
}],
};
serde_json::to_value(result)
.map_err(|e| JsonRpcError::internal_error(&e.to_string()))
}
Err(e) => {
if e.to_ascii_lowercase().contains("unknown")
|| e.to_ascii_lowercase().contains("not found")
{
Err(JsonRpcError::resource_not_found(uri))
} else {
Err(JsonRpcError::internal_error(&e))
}
}
}
}
/// Extract event data from tool results and emit to dashboard.
fn emit_tool_event(
&self,
tool_name: &str,
args: &Option<serde_json::Value>,
result: &serde_json::Value,
) {
if self.event_tx.is_none() {
return;
}
let now = Utc::now();
match tool_name {
// -- smart_ingest: memory created/updated --
"smart_ingest" | "ingest" | "session_checkpoint" => {
// Single mode: result has "decision" (create/update/supersede/reinforce/merge/replace/add_context)
if let Some(decision) = result.get("decision").and_then(|a| a.as_str()) {
let id = result
.get("nodeId")
.or(result.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let preview = result
.get("contentPreview")
.or(result.get("content"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
match decision {
"create" => {
let node_type = result
.get("nodeType")
.and_then(|v| v.as_str())
.unwrap_or("fact")
.to_string();
let tags = result
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|t| t.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
self.emit(VestigeEvent::MemoryCreated {
id,
content_preview: preview,
node_type,
tags,
timestamp: now,
});
}
"update" | "supersede" | "reinforce" | "merge" | "replace"
| "add_context" => {
self.emit(VestigeEvent::MemoryUpdated {
id,
content_preview: preview,
field: decision.to_string(),
timestamp: now,
});
}
_ => {}
}
}
// Batch mode: result has "results" array
if let Some(results) = result.get("results").and_then(|r| r.as_array()) {
for item in results {
let decision = item.get("decision").and_then(|a| a.as_str()).unwrap_or("");
let id = item
.get("nodeId")
.or(item.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let preview = item
.get("contentPreview")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if decision == "create" {
self.emit(VestigeEvent::MemoryCreated {
id,
content_preview: preview,
node_type: "fact".to_string(),
tags: vec![],
timestamp: now,
});
} else if !decision.is_empty() {
self.emit(VestigeEvent::MemoryUpdated {
id,
content_preview: preview,
field: decision.to_string(),
timestamp: now,
});
}
}
}
}
// -- memory: get/delete/promote/demote --
"memory" | "promote_memory" | "demote_memory" | "delete_knowledge"
| "get_memory_state" => {
let action = args
.as_ref()
.and_then(|a| a.get("action"))
.and_then(|a| a.as_str())
.unwrap_or(if tool_name == "promote_memory" {
"promote"
} else if tool_name == "demote_memory" {
"demote"
} else if tool_name == "delete_knowledge" {
"delete"
} else {
""
});
let id = args
.as_ref()
.and_then(|a| a.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
match action {
"delete" | "purge"
if result
.get("success")
.and_then(|value| value.as_bool())
.unwrap_or(false) =>
{
let node_id = result
.get("nodeId")
.and_then(|value| value.as_str())
.unwrap_or(&id)
.to_string();
self.emit(VestigeEvent::MemoryDeleted {
id: node_id,
timestamp: now,
});
}
"promote" => {
let retention = result
.get("newRetention")
.or(result.get("retrievalStrength"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
self.emit(VestigeEvent::MemoryPromoted {
id,
new_retention: retention,
timestamp: now,
});
}
"demote" => {
let retention = result
.get("newRetention")
.or(result.get("retrievalStrength"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
self.emit(VestigeEvent::MemoryDemoted {
id,
new_retention: retention,
timestamp: now,
});
}
_ => {}
}
}
// -- search --
"search" | "recall" | "semantic_search" | "hybrid_search" => {
let query = args
.as_ref()
.and_then(|a| a.get("query"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let results = result.get("results").and_then(|r| r.as_array());
let result_count = results.map(|r| r.len()).unwrap_or(0);
let result_ids: Vec<String> = results
.map(|r| {
r.iter()
.filter_map(|item| {
item.get("id").and_then(|v| v.as_str()).map(String::from)
})
.collect()
})
.unwrap_or_default();
let duration_ms = result
.get("durationMs")
.or(result.get("duration_ms"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
self.emit(VestigeEvent::SearchPerformed {
query,
result_count,
result_ids,
duration_ms,
timestamp: now,
});
}
// -- dream --
"dream" => {
let replayed = result
.get("memoriesReplayed")
.or(result.get("memories_replayed"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let connections = result
.get("connectionsFound")
.or(result.get("connections_found"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let insights = result
.get("insightsGenerated")
.or(result.get("insights"))
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
let duration_ms = result
.get("durationMs")
.or(result.get("duration_ms"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
self.emit(VestigeEvent::DreamCompleted {
memories_replayed: replayed,
connections_found: connections,
insights_generated: insights,
duration_ms,
timestamp: now,
});
}
// -- consolidate --
"consolidate" => {
let processed = result
.get("nodesProcessed")
.or(result.get("nodes_processed"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let decay = result
.get("decayApplied")
.or(result.get("decay_applied"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let embeddings = result
.get("embeddingsGenerated")
.or(result.get("embeddings_generated"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let duration_ms = result
.get("durationMs")
.or(result.get("duration_ms"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
self.emit(VestigeEvent::ConsolidationCompleted {
nodes_processed: processed,
decay_applied: decay,
embeddings_generated: embeddings,
duration_ms,
timestamp: now,
});
}
// -- importance_score --
"importance_score" => {
let preview = args
.as_ref()
.and_then(|a| a.get("content"))
.and_then(|v| v.as_str())
.map(|s| {
if s.len() > 100 {
format!("{}...", &s[..s.floor_char_boundary(100)])
} else {
s.to_string()
}
})
.unwrap_or_default();
let composite = result
.get("compositeScore")
.or(result.get("composite_score"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let channels = result.get("channels").or(result.get("breakdown"));
let novelty = channels
.and_then(|c| c.get("novelty"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let arousal = channels
.and_then(|c| c.get("arousal"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let reward = channels
.and_then(|c| c.get("reward"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let attention = channels
.and_then(|c| c.get("attention"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
self.emit(VestigeEvent::ImportanceScored {
memory_id: None, // importance_score tool runs on arbitrary content
content_preview: preview,
composite_score: composite,
novelty,
arousal,
reward,
attention,
timestamp: now,
});
}
// Other tools don't emit events
_ => {}
}
}
}
// ============================================================================
// TESTS
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
/// Create a test storage instance with a temporary database
async fn test_storage() -> (Arc<Storage>, TempDir) {
let dir = TempDir::new().unwrap();
let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap();
(Arc::new(storage), dir)
}
/// Create a test server with temporary storage
async fn test_server() -> (McpServer, TempDir) {
let (storage, dir) = test_storage().await;
let cognitive = Arc::new(Mutex::new(CognitiveEngine::new()));
let server = McpServer::new(storage, cognitive);
(server, dir)
}
/// Create a JSON-RPC request
fn make_request(method: &str, params: Option<serde_json::Value>) -> JsonRpcRequest {
JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: Some(serde_json::json!(1)),
method: method.to_string(),
params,
}
}
fn make_notification(method: &str, params: Option<serde_json::Value>) -> JsonRpcRequest {
JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: None,
method: method.to_string(),
params,
}
}
fn init_params() -> serde_json::Value {
serde_json::json!({
"protocolVersion": MCP_VERSION,
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
})
}
// ========================================================================
// INITIALIZATION TESTS
// ========================================================================
#[tokio::test]
async fn test_initialize_sets_initialized_flag() {
let (mut server, _dir) = test_server().await;
assert!(!server.initialized);
let request = make_request(
"initialize",
Some(serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
})),
);
let response = server.handle_request(request).await;
assert!(response.is_some());
let response = response.unwrap();
assert!(response.result.is_some());
assert!(response.error.is_none());
assert!(server.initialized);
}
#[tokio::test]
async fn test_initialize_returns_server_info() {
let (mut server, _dir) = test_server().await;
// Send with current protocol version to get it back
let params = serde_json::json!({
"protocolVersion": MCP_VERSION,
"capabilities": {},
"clientInfo": { "name": "test", "version": "1.0" }
});
let request = make_request("initialize", Some(params));
let response = server.handle_request(request).await.unwrap();
let result = response.result.unwrap();
assert_eq!(result["protocolVersion"], MCP_VERSION);
assert_eq!(result["serverInfo"]["name"], "vestige");
assert!(result["capabilities"]["tools"].is_object());
assert!(result["capabilities"]["resources"].is_object());
assert!(result["instructions"].is_string());
}
#[tokio::test]
async fn test_initialize_unsupported_protocol_falls_back_to_latest() {
let (mut server, _dir) = test_server().await;
let params = serde_json::json!({
"protocolVersion": "1.0.0",
"capabilities": {},
"clientInfo": { "name": "test", "version": "1.0" }
});
let request = make_request("initialize", Some(params));
let response = server.handle_request(request).await.unwrap();
let result = response.result.unwrap();
assert_eq!(result["protocolVersion"], MCP_VERSION);
}
#[tokio::test]
async fn test_initialize_missing_params_returns_error() {
let (mut server, _dir) = test_server().await;
let request = make_request("initialize", None);
let response = server.handle_request(request).await.unwrap();
assert!(response.result.is_none());
assert!(response.error.is_some());
assert_eq!(response.error.unwrap().code, -32602);
assert!(!server.initialized);
}
// ========================================================================
// UNINITIALIZED SERVER TESTS
// ========================================================================
#[tokio::test]
async fn test_request_before_initialize_returns_error() {
let (mut server, _dir) = test_server().await;
let request = make_request("tools/list", None);
let response = server.handle_request(request).await.unwrap();
assert!(response.result.is_none());
assert!(response.error.is_some());
let error = response.error.unwrap();
assert_eq!(error.code, -32003); // ServerNotInitialized
}
#[tokio::test]
async fn test_ping_before_initialize_returns_error() {
let (mut server, _dir) = test_server().await;
let request = make_request("ping", None);
let response = server.handle_request(request).await.unwrap();
assert!(response.error.is_some());
assert_eq!(response.error.unwrap().code, -32003);
}
// ========================================================================
// NOTIFICATION TESTS
// ========================================================================
#[tokio::test]
async fn test_initialized_notification_returns_none() {
let (mut server, _dir) = test_server().await;
// First initialize
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
// Send initialized notification
let notification = make_notification("notifications/initialized", None);
let response = server.handle_request(notification).await;
// Notifications should return None
assert!(response.is_none());
}
#[tokio::test]
async fn test_initialized_notification_with_id_returns_invalid_request() {
let (mut server, _dir) = test_server().await;
let request = make_request("notifications/initialized", None);
let response = server.handle_request(request).await.unwrap();
assert!(response.error.is_some());
assert_eq!(response.error.unwrap().code, -32600);
}
#[tokio::test]
async fn test_notification_does_not_emit_response_or_side_effect() {
let (mut server, _dir) = test_server().await;
let notification = make_notification("initialize", None);
let response = server.handle_request(notification).await;
assert!(response.is_none());
assert!(!server.initialized);
}
// ========================================================================
// TOOLS/LIST TESTS
// ========================================================================
#[tokio::test]
async fn test_tools_list_returns_all_tools() {
let (mut server, _dir) = test_server().await;
// Initialize first
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request("tools/list", None);
let response = server.handle_request(request).await.unwrap();
let result = response.result.unwrap();
let tools = result["tools"].as_array().unwrap();
// 34 tools: 25 from v2.1.21 + 7 Phase 3 merge/supersede tools
// (merge_candidates, plan_merge, plan_supersede, apply_plan, merge_undo,
// protect, merge_policy, composed_graph) + 1 connector tool (source_sync, #57).
assert_eq!(tools.len(), 34, "Expected exactly 34 tools");
let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
// Unified tools
assert!(tool_names.contains(&"search"));
assert!(tool_names.contains(&"memory"));
assert!(tool_names.contains(&"codebase"));
assert!(tool_names.contains(&"intention"));
// Core memory (smart_ingest absorbs ingest + checkpoint in v1.7)
assert!(tool_names.contains(&"smart_ingest"));
// External-source connectors (#57)
assert!(tool_names.contains(&"source_sync"));
assert!(
!tool_names.contains(&"ingest"),
"ingest should be removed in v1.7"
);
assert!(
!tool_names.contains(&"session_checkpoint"),
"session_checkpoint should be removed in v1.7"
);
// Feedback merged into memory tool (v1.7)
assert!(
!tool_names.contains(&"promote_memory"),
"promote_memory should be removed in v1.7"
);
assert!(
!tool_names.contains(&"demote_memory"),
"demote_memory should be removed in v1.7"
);
// Temporal tools (v1.2)
assert!(tool_names.contains(&"memory_timeline"));
assert!(tool_names.contains(&"memory_changelog"));
// Maintenance tools (v1.7: system_status replaces health_check + stats)
assert!(tool_names.contains(&"system_status"));
assert!(
!tool_names.contains(&"health_check"),
"health_check should be removed in v1.7"
);
assert!(
!tool_names.contains(&"stats"),
"stats should be removed in v1.7"
);
assert!(tool_names.contains(&"consolidate"));
assert!(tool_names.contains(&"backup"));
assert!(tool_names.contains(&"export"));
assert!(tool_names.contains(&"gc"));
// Auto-save & dedup tools (v1.3)
assert!(tool_names.contains(&"importance_score"));
assert!(tool_names.contains(&"find_duplicates"));
// Merge / Supersede controls (v2.1.25 — Phase 3)
assert!(tool_names.contains(&"merge_candidates"));
assert!(tool_names.contains(&"plan_merge"));
assert!(tool_names.contains(&"plan_supersede"));
assert!(tool_names.contains(&"apply_plan"));
assert!(tool_names.contains(&"merge_undo"));
assert!(tool_names.contains(&"protect"));
assert!(tool_names.contains(&"merge_policy"));
// Cognitive tools (v1.5)
assert!(tool_names.contains(&"dream"));
assert!(tool_names.contains(&"explore_connections"));
assert!(tool_names.contains(&"predict"));
assert!(tool_names.contains(&"restore"));
// Context packets (v1.8)
assert!(tool_names.contains(&"session_context"));
// Autonomic tools (v1.9)
assert!(tool_names.contains(&"memory_health"));
assert!(tool_names.contains(&"memory_graph"));
assert!(tool_names.contains(&"composed_graph"));
// Deep reference + cross_reference alias (v2.0.4)
assert!(tool_names.contains(&"deep_reference"));
assert!(tool_names.contains(&"cross_reference"));
assert!(tool_names.contains(&"contradictions"));
// Active forgetting (v2.0.5) — Anderson 2025 + Davis Rac1
assert!(tool_names.contains(&"suppress"));
}
#[tokio::test]
async fn test_tools_have_descriptions_and_schemas() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request("tools/list", None);
let response = server.handle_request(request).await.unwrap();
let result = response.result.unwrap();
let tools = result["tools"].as_array().unwrap();
for tool in tools {
assert!(tool["name"].is_string(), "Tool should have a name");
assert!(
tool["description"].is_string(),
"Tool should have a description"
);
assert!(
tool["inputSchema"].is_object(),
"Tool should have an input schema"
);
}
}
// ========================================================================
// RESOURCES/LIST TESTS
// ========================================================================
#[tokio::test]
async fn test_resources_list_returns_all_resources() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request("resources/list", None);
let response = server.handle_request(request).await.unwrap();
let result = response.result.unwrap();
let resources = result["resources"].as_array().unwrap();
// Verify expected resources are present
let resource_uris: Vec<&str> = resources
.iter()
.map(|r| r["uri"].as_str().unwrap())
.collect();
assert!(resource_uris.contains(&"memory://stats"));
assert!(resource_uris.contains(&"memory://recent"));
assert!(resource_uris.contains(&"memory://decaying"));
assert!(resource_uris.contains(&"memory://due"));
assert!(resource_uris.contains(&"memory://intentions"));
assert!(resource_uris.contains(&"codebase://structure"));
assert!(resource_uris.contains(&"codebase://patterns"));
assert!(resource_uris.contains(&"codebase://decisions"));
}
#[tokio::test]
async fn test_resources_have_descriptions() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request("resources/list", None);
let response = server.handle_request(request).await.unwrap();
let result = response.result.unwrap();
let resources = result["resources"].as_array().unwrap();
for resource in resources {
assert!(resource["uri"].is_string(), "Resource should have a URI");
assert!(resource["name"].is_string(), "Resource should have a name");
assert!(
resource["description"].is_string(),
"Resource should have a description"
);
}
}
// ========================================================================
// UNKNOWN METHOD TESTS
// ========================================================================
#[tokio::test]
async fn test_unknown_method_returns_error() {
let (mut server, _dir) = test_server().await;
// Initialize first
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request("unknown/method", None);
let response = server.handle_request(request).await.unwrap();
assert!(response.result.is_none());
assert!(response.error.is_some());
let error = response.error.unwrap();
assert_eq!(error.code, -32601); // MethodNotFound
}
#[tokio::test]
async fn test_unknown_tool_returns_error() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request(
"tools/call",
Some(serde_json::json!({
"name": "nonexistent_tool",
"arguments": {}
})),
);
let response = server.handle_request(request).await.unwrap();
assert!(response.error.is_some());
assert_eq!(response.error.unwrap().code, -32602);
}
// ========================================================================
// PING TESTS
// ========================================================================
#[tokio::test]
async fn test_ping_returns_empty_object() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request("ping", None);
let response = server.handle_request(request).await.unwrap();
assert!(response.result.is_some());
assert!(response.error.is_none());
assert_eq!(response.result.unwrap(), serde_json::json!({}));
}
// ========================================================================
// TOOLS/CALL TESTS
// ========================================================================
#[tokio::test]
async fn test_tools_call_missing_params_returns_error() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request("tools/call", None);
let response = server.handle_request(request).await.unwrap();
assert!(response.error.is_some());
assert_eq!(response.error.unwrap().code, -32602); // InvalidParams
}
#[tokio::test]
async fn test_tools_call_invalid_params_returns_error() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request(
"tools/call",
Some(serde_json::json!({
"invalid": "params"
})),
);
let response = server.handle_request(request).await.unwrap();
assert!(response.error.is_some());
assert_eq!(response.error.unwrap().code, -32602);
}
#[tokio::test]
async fn test_tools_call_rejects_non_object_arguments() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request(
"tools/call",
Some(serde_json::json!({
"name": "search",
"arguments": "not-an-object"
})),
);
let response = server.handle_request(request).await.unwrap();
assert!(response.error.is_some());
assert_eq!(response.error.unwrap().code, -32602);
}
// ========================================================================
// Per-tool result-size annotation tests
// (`_meta["anthropic/maxResultSizeChars"]`, CC v2.1.91+)
//
// The annotation lives on the Tool definition in `tools/list`, so CC reads
// it once when the MCP session opens and applies the override to every
// invocation of that tool. These tests pin the wire-form so a future
// refactor of `ToolDescription` cannot silently drop the annotation.
// ========================================================================
/// Expected per-tool caps. Returns `Some(cap)` for tools the discipline
/// annotates, `None` for tools that MUST NOT carry the annotation
/// (cargo-cult prevention).
fn expected_max_result_size(name: &str) -> Option<u64> {
match name {
"search" => Some(300_000),
"memory_timeline" => Some(200_000),
"memory" => Some(100_000),
"codebase" => Some(100_000),
_ => None,
}
}
#[tokio::test]
async fn test_high_payload_tools_have_max_result_size_annotation() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request("tools/list", None);
let response = server.handle_request(request).await.unwrap();
let result = response.result.unwrap();
let tools = result["tools"].as_array().unwrap();
for name in ["search", "memory_timeline", "memory", "codebase"] {
let tool = tools
.iter()
.find(|t| t["name"].as_str() == Some(name))
.unwrap_or_else(|| panic!("Tool '{}' missing from tools/list", name));
let expected = expected_max_result_size(name).unwrap();
let meta = tool.get("_meta").unwrap_or_else(|| {
panic!("Tool '{}' is missing the `_meta` field on the wire", name)
});
let actual = meta
.get("anthropic/maxResultSizeChars")
.and_then(|v| v.as_u64())
.unwrap_or_else(|| {
panic!(
"Tool '{}' _meta lacks integer 'anthropic/maxResultSizeChars'",
name
)
});
assert_eq!(
actual, expected,
"Tool '{}' cap drift: expected {} got {}",
name, expected, actual
);
assert!(
actual <= 500_000,
"Tool '{}' cap {} exceeds Anthropic 500K ceiling",
name,
actual
);
}
}
#[tokio::test]
async fn test_other_tools_do_not_carry_max_result_size_annotation() {
// Cargo-cult prevention. Dynamically derived from tools/list so this
// test is robust to new tools being added: any tool that is NOT in
// the discipline-prescribed set MUST NOT carry the annotation.
// Adding the annotation to a small-payload tool dilutes the signal
// and trains future maintainers that the value is arbitrary.
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request("tools/list", None);
let response = server.handle_request(request).await.unwrap();
let result = response.result.unwrap();
let tools = result["tools"].as_array().unwrap();
for tool in tools {
let name = tool["name"].as_str().unwrap();
if expected_max_result_size(name).is_some() {
continue; // covered by the annotated-tools test
}
// Either the `_meta` key is absent OR it is an object without the
// anthropic key — both are acceptable. The forbidden case is the
// anthropic key present on this tool.
let has_max_size = tool
.get("_meta")
.and_then(|m| m.get("anthropic/maxResultSizeChars"))
.is_some();
assert!(
!has_max_size,
"Tool '{}' should NOT carry maxResultSizeChars annotation \
(not in the discipline-prescribed set: search, memory_timeline, \
memory, codebase). If this tool's realistic max-payload now \
routinely exceeds 50K, update expected_max_result_size() + the \
annotation loop in handle_tools_list together.",
name
);
}
}
#[tokio::test]
async fn test_meta_wire_shape_uses_underscore_meta_field() {
// Anthropic's MCP spec is explicit: the field on the wire is `_meta`,
// NOT `meta`. The Rust struct uses `meta: Option<Value>` with
// `#[serde(rename = "_meta")]` — assert the rename actually fired.
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request("tools/list", None);
let response = server.handle_request(request).await.unwrap();
let result = response.result.unwrap();
let tools = result["tools"].as_array().unwrap();
let search_tool = tools
.iter()
.find(|t| t["name"].as_str() == Some("search"))
.expect("'search' tool present");
// Wire-form: `_meta` must exist; `meta` (un-renamed) must NOT exist.
assert!(
search_tool.get("_meta").is_some(),
"search tool missing `_meta` key (serde rename to _meta did not apply)"
);
assert!(
search_tool.get("meta").is_none(),
"search tool has un-renamed `meta` key (regression — serde rename broke)"
);
}
}