//! 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, cognitive: Arc>, 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>, /// Resolved output config from `/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, } /// 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) -> Arc { let config = VestigeConfig::load_from_data_dir(storage.data_dir()); Arc::new(config.output()) } impl McpServer { #[allow(dead_code)] pub fn new(storage: Arc, cognitive: Arc>) -> 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, cognitive: Arc>, event_tx: broadcast::Sender, ) -> 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 { 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, ) -> Result { 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 { // 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='' (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 = 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, ) -> Result { 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 { 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, ) -> Result { 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, 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 = 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, 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) -> 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) -> 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 { 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` 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)" ); } }