vestige/crates/vestige-mcp/src/server.rs
Sam Valladares 29658d24f6 merge(#99): v2.2 12-tool consolidation + flagship backfill (13 advertised)
Tool Consolidation v2.2.0 (34->12 advertised surface: recall/memory/codebase/
intention/smart_ingest/source_sync/memory_status/maintain/dedup/graph/
session_start/suppress) onto the retroactive-salience-backfill base.

Conflict resolution:
- server.rs: took #99's canonical 12-tool surface, then re-added
  as a 13th ADVERTISED tool (ToolDescription + dispatch arm + test). Backfill
  is the flagship 'Memory with hindsight' (Cai 2024 Nature) cognitive primitive
  — a distinct capability, not a maintenance op to fold into `maintain` or hide.
- Cargo.toml: kept usearch features=["fp16lib"] with the fuller MSVC-C1021
  explanation comment (Windows build fix, #71).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 15:03:47 -05:00

2636 lines
118 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.2: 12 advertised tools after Layer-1 Tool Consolidation
// (verified by `tools.len() == 12` in test_tools_list_returns_all_tools).
// 22 deprecated/folded names still work as hidden redirects in
// handle_tools_call. See docs/launch/tool-consolidation-v2.2.0.md.
let mut tools = vec![
// ================================================================
// RECALL — unified retrieval tool (v2.2). HOT PATH.
// Folds search + deep_reference + cross_reference + contradictions.
// mode='lookup' (default) is a zero-overhead pass-through to search.
// ================================================================
ToolDescription {
name: "recall".to_string(),
description: Some("Retrieve from memory. Modes: 'lookup' (default — fast hybrid search: keyword + semantic + convex fusion, auto-strengthens on access; use for plain recall), 'reason' (deep cognitive reasoning across memories with FSRS-6 trust scoring, spreading activation, supersession, and contradiction analysis; use when accuracy matters, needs 'query'), 'contradictions' (surface trust-weighted disagreement pairs for a 'topic'). Default mode is fast — only 'reason' pays the deep-analysis cost.".to_string()),
input_schema: tools::recall::schema(),
..Default::default()
},
// ================================================================
// UNIFIED TOOLS (v1.1+)
// ================================================================
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()
},
// ================================================================
// STATUS / TEMPORAL — unified `memory_status` tool (v2.2)
// Folds system_status + memory_health + memory_timeline +
// memory_changelog into one view-dispatched surface.
// ================================================================
ToolDescription {
name: "memory_status".to_string(),
description: Some("Memory status & history. Views: 'health' (default — full system health + stats + FSRS preview + cognitive-module health + warnings + recommendations), 'retention' (lightweight retention dashboard: avg, distribution, trend), 'timeline' (browse memories chronologically, grouped by day), 'changelog' (audit trail of memory state changes — per-memory transitions or system-wide).".to_string()),
input_schema: tools::memory_status::schema(),
..Default::default()
},
// ================================================================
// MAINTAIN — unified maintenance/lifecycle tool (v2.2)
// Folds consolidate + dream + gc + importance_score + backup +
// export + restore into one action-dispatched surface.
// ================================================================
ToolDescription {
name: "maintain".to_string(),
description: Some("Memory maintenance & lifecycle. Actions: 'consolidate' (run FSRS-6 decay/embedding cycle), 'dream' (replay memories → insights/connections + strengthen patterns), 'gc' (garbage-collect stale memories; dry_run=true by default for safety), 'importance_score' (4-channel neuroscience score for 'content'), 'backup' (SQLite DB backup), 'export' (memories as JSON/JSONL with tag/date filters), 'restore' (restore from a JSON backup at 'path').".to_string()),
input_schema: tools::maintain::schema(),
..Default::default()
},
// ================================================================
// DEDUP / MERGE / SUPERSEDE — unified `dedup` tool (v2.2)
// Folds find_duplicates + the 7 Phase-3 merge tools into one
// action-dispatched surface. Diff-previewed, confidence-gated,
// reversible, never silent; bitemporal-never-delete preserved.
// ================================================================
ToolDescription {
name: "dedup".to_string(),
description: Some("Deduplication & merge/supersede. Actions: 'scan' (default — surface duplicate clusters via cosine + merge candidates via Fellegi-Sunter, read-only), 'plan_merge' (preview a reversible merge plan for 2+ member_ids → plan_id), 'plan_supersede' (preview superseding old_id with new_id → plan_id), 'apply' (execute a plan_id; 'possible'/'non_match' need confirm=true), 'undo' (reverse an operation_id, or omit to list the reflog), 'protect' (pin a memory against auto-merge/supersede/forget), 'policy' (get/set Fellegi-Sunter thresholds). Old memories are invalidated, never deleted.".to_string()),
input_schema: tools::dedup::unified_schema(),
..Default::default()
},
// ================================================================
// COGNITIVE TOOLS (v1.5+)
// (dream folded into `maintain` action='dream' in v2.2)
// ================================================================
// ================================================================
// GRAPH — unified graph/association/prediction tool (v2.2)
// Folds explore_connections + predict + memory_graph + composed_graph.
// ================================================================
ToolDescription {
name: "graph".to_string(),
description: Some("Memory graph & associations. Actions: 'chain' (reasoning path from→to), 'associations' (related memories via spreading activation, needs 'from'), 'bridges' (connectors between from/to), 'predict' (what memories you'll need next, from 'context'), 'memory_graph' (force-directed subgraph for viz, from center_id or query), 'recent'/'get'/'memory'/'neighbors'/'never_composed'/'bounty_mode' (composition topology), 'label' (record a composition outcome — the only write).".to_string()),
input_schema: tools::graph_unified::schema(),
..Default::default()
},
// ================================================================
// RESTORE TOOL (v1.5+)
// (folded into `maintain` action='restore' in v2.2)
// ================================================================
// ================================================================
// CONTEXT PACKETS (v1.8+)
// ================================================================
ToolDescription {
name: "session_start".to_string(),
description: Some("One-call session initialization. Combines search, intentions, status, predictions, and codebase context into a single token-budgeted response. Call this once at the start of a session instead of 5 separate calls. (Renamed from 'session_context' in v2.2.)".to_string()),
input_schema: tools::session_context::schema(),
..Default::default()
},
// ================================================================
// AUTONOMIC TOOLS (v1.9+)
// (memory_health → `memory_status` view='retention';
// memory_graph + composed_graph → `graph`, all in v2.2)
// ================================================================
// ================================================================
// DEEP REFERENCE (v2.0.4+) — folded into `recall` (mode='reason' /
// 'contradictions') in v2.2. deep_reference/cross_reference/
// contradictions remain hidden dispatch aliases.
// ================================================================
// ================================================================
// 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()
},
// ================================================================
// RETROACTIVE SALIENCE BACKFILL — Cai 2024 Nature
// "Memory with hindsight": failure -> backward causal reach.
// A flagship v2.2 capability, kept as its own advertised tool — it
// is a distinct cognitive primitive (backward causal promotion),
// not a maintenance op that folds into `maintain`.
// ================================================================
ToolDescription {
name: "backfill".to_string(),
description: Some("Memory with hindsight. When a FAILURE (bug/crash/regression) is recorded, reach BACKWARD in time and promote the quiet earlier memory that caused it — the root cause a vector search structurally cannot surface because it isn't similar to the failure, only causally upstream (shares an entity: same file/env-var/service). Faithful port of Cai 2024 Nature; backward-only by construction. Pass failure_id (or it auto-finds the latest failure), manual=true to force, promote=false for a dry run.".to_string()),
input_schema: tools::backfill::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
// high-payload tools carry the annotation (recall, memory_status,
// memory, codebase, dedup, graph); the remaining advertised 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() {
// v2.2: search folded into recall (mode='lookup'); annotation moved.
"recall" => Some(300_000),
"memory_status" => Some(200_000),
"memory" => Some(100_000),
"codebase" => Some(100_000),
// v2.2: dedup action='scan' returns duplicate clusters +
// merge candidates + policy in one payload.
"dedup" => Some(150_000),
// v2.2: graph action='memory_graph' (force-directed layout) and
// 'bounty_mode' pagination can both produce large payloads.
"graph" => Some(250_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
// ================================================================
// RECALL — unified retrieval tool (v2.2). HOT PATH.
// mode = lookup (default, zero-overhead) | reason | contradictions
"recall" => {
tools::recall::execute(
&self.storage,
&self.cognitive,
&self.output_config,
request.arguments,
)
.await
}
// DEPRECATED (v2.2): folded into `recall` (mode='lookup'). Hidden alias.
"search" => {
warn!("Tool 'search' is deprecated in v2.2. Use 'recall' (mode='lookup', the default).");
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,
// ================================================================
// Retroactive Salience Backfill (Cai 2024 Nature) — flagship v2.2
// ================================================================
"backfill" => tools::backfill::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
}
// ================================================================
// MEMORY STATUS — unified status/temporal tool (v2.2)
// view = health (default) | retention | timeline | changelog
// ================================================================
"memory_status" => {
tools::memory_status::execute(
&self.storage,
&self.cognitive,
&self.output_config,
request.arguments,
)
.await
}
// DEPRECATED (v2.2): folded into `memory_status`. Hidden aliases —
// each calls the same underlying handler verbatim.
"system_status" => {
warn!("Tool 'system_status' is deprecated in v2.2. Use 'memory_status' (view='health').");
tools::maintenance::execute_system_status(
&self.storage,
&self.cognitive,
request.arguments,
)
.await
}
"mark_reviewed" => tools::review::execute(&self.storage, request.arguments).await,
// ================================================================
// DEPRECATED: legacy search aliases — redirect to `recall` lookup.
// ('recall' itself is now the unified retrieval tool, handled above.)
// ================================================================
"semantic_search" | "hybrid_search" => {
warn!(
"Tool '{}' is deprecated. Use 'recall' (mode='lookup') 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+) — DEPRECATED (v2.2): folded into
// `memory_status` (view='timeline' / view='changelog'). Hidden aliases.
// ================================================================
"memory_timeline" => {
warn!("Tool 'memory_timeline' is deprecated in v2.2. Use 'memory_status' (view='timeline').");
tools::timeline::execute(&self.storage, &self.output_config, request.arguments)
.await
}
"memory_changelog" => {
warn!("Tool 'memory_changelog' is deprecated in v2.2. Use 'memory_status' (view='changelog').");
tools::changelog::execute(&self.storage, request.arguments).await
}
// ================================================================
// MAINTAIN — unified maintenance/lifecycle tool (v2.2)
// action = consolidate | dream | gc | importance_score | backup
// | export | restore
// ================================================================
"maintain" => {
// Mirror the pre-dispatch *Started* events that the standalone
// consolidate/dream arms emit, keyed off the action.
match request
.arguments
.as_ref()
.and_then(|a| a.get("action"))
.and_then(|v| v.as_str())
{
Some("consolidate") => self.emit(VestigeEvent::ConsolidationStarted {
timestamp: chrono::Utc::now(),
}),
Some("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::maintain::execute(&self.storage, &self.cognitive, request.arguments).await
}
// ================================================================
// MAINTENANCE TOOLS (v1.2+) — DEPRECATED (v2.2): folded into
// `maintain`. Hidden aliases; pre-emit Started events preserved.
// ================================================================
"consolidate" => {
warn!("Tool 'consolidate' is deprecated in v2.2. Use 'maintain' (action='consolidate').");
self.emit(VestigeEvent::ConsolidationStarted {
timestamp: chrono::Utc::now(),
});
tools::maintenance::execute_consolidate(&self.storage, request.arguments).await
}
"backup" => {
warn!("Tool 'backup' is deprecated in v2.2. Use 'maintain' (action='backup').");
tools::maintenance::execute_backup(&self.storage, request.arguments).await
}
"export" => {
warn!("Tool 'export' is deprecated in v2.2. Use 'maintain' (action='export').");
tools::maintenance::execute_export(&self.storage, request.arguments).await
}
"gc" => {
warn!("Tool 'gc' is deprecated in v2.2. Use 'maintain' (action='gc').");
tools::maintenance::execute_gc(&self.storage, request.arguments).await
}
// ================================================================
// AUTO-SAVE & DEDUP TOOLS (v1.3+)
// ================================================================
// DEPRECATED (v2.2): folded into `maintain` (action='importance_score').
"importance_score" => {
warn!("Tool 'importance_score' is deprecated in v2.2. Use 'maintain' (action='importance_score').");
tools::importance::execute(&self.storage, &self.cognitive, request.arguments).await
}
// ================================================================
// DEDUP / MERGE / SUPERSEDE — unified `dedup` tool (v2.2)
// ================================================================
"dedup" => tools::dedup::execute_unified(&self.storage, request.arguments).await,
// DEPRECATED (v2.2): folded into `dedup`. Kept as hidden back-compat
// aliases (≥1 minor release) — they call the same underlying handlers
// verbatim, so envelopes/plan_id/confirm-gating/bitemporal are intact.
"find_duplicates" => {
warn!("Tool 'find_duplicates' is deprecated in v2.2. Use 'dedup' with action='scan'.");
tools::dedup::execute(&self.storage, request.arguments).await
}
"merge_candidates" | "plan_merge" | "plan_supersede" | "apply_plan" | "merge_undo"
| "protect" | "merge_policy" => {
warn!(
"Tool '{}' is deprecated in v2.2. Use 'dedup' (action={}).",
request.name,
match request.name.as_str() {
"merge_candidates" => "scan",
"apply_plan" => "apply",
"merge_undo" => "undo",
"merge_policy" => "policy",
other => other,
}
);
tools::merge::execute(&self.storage, request.name.as_str(), request.arguments).await
}
// ================================================================
// COGNITIVE TOOLS (v1.5+) — DEPRECATED (v2.2): dream folded into
// `maintain` (action='dream'). Hidden alias; DreamStarted preserved.
// ================================================================
"dream" => {
warn!("Tool 'dream' is deprecated in v2.2. Use 'maintain' (action='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
}
// ================================================================
// GRAPH — unified graph/association/prediction tool (v2.2)
// ================================================================
"graph" => {
tools::graph_unified::execute(&self.storage, &self.cognitive, request.arguments)
.await
}
// DEPRECATED (v2.2): folded into `graph`. Hidden aliases.
"explore_connections" => {
warn!("Tool 'explore_connections' is deprecated in v2.2. Use 'graph' (action='chain'|'associations'|'bridges').");
tools::explore::execute(&self.storage, &self.cognitive, request.arguments).await
}
"predict" => {
warn!("Tool 'predict' is deprecated in v2.2. Use 'graph' (action='predict').");
tools::predict::execute(&self.storage, &self.cognitive, request.arguments).await
}
// DEPRECATED (v2.2): folded into `maintain` (action='restore').
"restore" => {
warn!("Tool 'restore' is deprecated in v2.2. Use 'maintain' (action='restore').");
tools::restore::execute(&self.storage, request.arguments).await
}
// ================================================================
// CONTEXT PACKETS (v1.8+) — `session_start` (renamed v2.2)
// ================================================================
"session_start" => {
tools::session_context::execute(
&self.storage,
&self.cognitive,
&self.output_config,
request.arguments,
)
.await
}
// DEPRECATED (v2.2): renamed to `session_start`. Hidden alias.
"session_context" => {
warn!("Tool 'session_context' is deprecated in v2.2. Use 'session_start'.");
tools::session_context::execute(
&self.storage,
&self.cognitive,
&self.output_config,
request.arguments,
)
.await
}
// ================================================================
// AUTONOMIC TOOLS (v1.9+)
// ================================================================
// DEPRECATED (v2.2): folded into `memory_status` (view='retention').
"memory_health" => {
warn!("Tool 'memory_health' is deprecated in v2.2. Use 'memory_status' (view='retention').");
tools::health::execute(&self.storage, request.arguments).await
}
// DEPRECATED (v2.2): folded into `graph`. Hidden aliases.
"memory_graph" => {
warn!("Tool 'memory_graph' is deprecated in v2.2. Use 'graph' (action='memory_graph').");
tools::graph::execute(&self.storage, request.arguments).await
}
"composed_graph" => {
warn!("Tool 'composed_graph' is deprecated in v2.2. Use 'graph' (action='recent'|'get'|'memory'|'neighbors'|'never_composed'|'bounty_mode'|'label').");
tools::composed_graph::execute(&self.storage, request.arguments).await
}
// DEPRECATED (v2.2): folded into `recall`. Hidden aliases.
"deep_reference" | "cross_reference" => {
warn!("Tool '{}' is deprecated in v2.2. Use 'recall' (mode='reason').", request.name);
tools::cross_reference::execute(&self.storage, &self.cognitive, request.arguments)
.await
}
"contradictions" => {
warn!("Tool 'contradictions' is deprecated in v2.2. Use 'recall' (mode='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();
// v2.2: the unified `maintain` tool folds consolidate/dream/importance_score
// (the three maintenance actions that emit). Normalize its name to the
// effective action so the existing emit arms below fire unchanged. Old
// standalone names still arrive verbatim and match directly.
let tool_name = if tool_name == "maintain" {
args.as_ref()
.and_then(|a| a.get("action"))
.and_then(|v| v.as_str())
.unwrap_or("maintain")
} else if tool_name == "recall" {
// The unified `recall` tool fires SearchPerformed only for the lookup
// path (the former `search`). reason/contradictions do not emit, so
// map them to a non-emitting name.
match args
.as_ref()
.and_then(|a| a.get("mode"))
.and_then(|v| v.as_str())
{
Some("reason") | Some("contradictions") => "recall_noemit",
_ => "search", // lookup (default) → SearchPerformed
}
} else {
tool_name
};
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();
// v2.2 Tool Consolidation (Layer 1): 34 → 27 after `dedup` folds
// find_duplicates + the 7 Phase-3 merge tools (8 → 1). Old names remain
// dispatchable as hidden back-compat aliases but drop off the advertised list.
assert_eq!(
tools.len(),
13,
"Expected exactly 13 tools after v2.2 Layer-1 consolidation \
(12 consolidated: dedup + memory_status + graph + maintain + recall; \
session_context renamed) plus the flagship `backfill` (Retroactive \
Salience, Cai 2024 Nature), a distinct cognitive primitive"
);
let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
// Unified tools
// (search folded into `recall` mode='lookup' in v2.2)
assert!(tool_names.contains(&"recall"));
assert!(tool_names.contains(&"memory"));
assert!(tool_names.contains(&"codebase"));
assert!(tool_names.contains(&"intention"));
// Flagship retroactive-salience backfill stays advertised (not folded).
assert!(tool_names.contains(&"backfill"));
// 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"
);
// Status / temporal — unified `memory_status` tool (v2.2).
// system_status + memory_health + memory_timeline + memory_changelog
// folded in; old names dispatch as hidden aliases but are off the list.
assert!(tool_names.contains(&"memory_status"));
for old in [
"system_status",
"memory_health",
"memory_timeline",
"memory_changelog",
] {
assert!(
!tool_names.contains(&old),
"{old} should be folded into 'memory_status' in v2.2"
);
}
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"
);
// Maintenance / lifecycle — unified `maintain` tool (v2.2).
// consolidate + dream + gc + importance_score + backup + export + restore
// folded in; old names dispatch as hidden aliases but are off the list.
assert!(tool_names.contains(&"maintain"));
for old in [
"consolidate",
"dream",
"gc",
"importance_score",
"backup",
"export",
"restore",
] {
assert!(
!tool_names.contains(&old),
"{old} should be folded into 'maintain' in v2.2"
);
}
// Dedup / merge / supersede — unified `dedup` tool (v2.2).
// find_duplicates + the 7 Phase-3 merge tools folded in; still
// dispatchable as hidden back-compat aliases, but off the advertised list.
assert!(tool_names.contains(&"dedup"));
for old in [
"find_duplicates",
"merge_candidates",
"plan_merge",
"plan_supersede",
"apply_plan",
"merge_undo",
"protect",
"merge_policy",
] {
assert!(
!tool_names.contains(&old),
"{old} should be folded into 'dedup' in v2.2"
);
}
// Cognitive tools (v1.5): explore_connections + predict → `graph`;
// dream + restore → `maintain` (all v2.2). Nothing left advertised here.
// Context packets (v1.8) — renamed session_context → session_start (v2.2)
assert!(tool_names.contains(&"session_start"));
assert!(
!tool_names.contains(&"session_context"),
"session_context renamed to 'session_start' in v2.2"
);
// Graph — unified `graph` tool (v2.2). explore_connections + predict +
// memory_graph + composed_graph folded in; old names dispatch as hidden
// aliases but are off the advertised list. (memory_health → memory_status.)
assert!(tool_names.contains(&"graph"));
for old in [
"explore_connections",
"predict",
"memory_graph",
"composed_graph",
] {
assert!(
!tool_names.contains(&old),
"{old} should be folded into 'graph' in v2.2"
);
}
// Retrieval — unified `recall` tool (v2.2). search + deep_reference +
// cross_reference + contradictions folded in; old names dispatch as
// hidden aliases but are off the advertised list.
for old in [
"search",
"deep_reference",
"cross_reference",
"contradictions",
] {
assert!(
!tool_names.contains(&old),
"{old} should be folded into 'recall' in v2.2"
);
}
// Active forgetting (v2.0.5) — Anderson 2025 + Davis Rac1
assert!(tool_names.contains(&"suppress"));
}
/// v2.2: the 8 tools folded into `dedup` must still dispatch (hidden
/// back-compat aliases), i.e. they must NOT return the "Unknown tool"
/// InvalidParams (-32602) error. Read-only/list-style actions are used so
/// the call resolves without mutating or requiring extra setup.
#[tokio::test]
async fn test_deprecated_dedup_aliases_redirect() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
// (tool name, args) — all read-only / list-style so they resolve cleanly.
let calls: Vec<(&str, serde_json::Value)> = vec![
("find_duplicates", serde_json::json!({})),
("merge_candidates", serde_json::json!({})),
("merge_undo", serde_json::json!({})), // no operation_id => lists the reflog
("merge_policy", serde_json::json!({})), // no args => returns current policy
("dedup", serde_json::json!({"action": "policy"})),
("dedup", serde_json::json!({})), // default action = scan
];
for (name, args) in calls {
let request = make_request(
"tools/call",
Some(serde_json::json!({ "name": name, "arguments": args })),
);
let response = server.handle_request(request).await.unwrap();
// The call may succeed (result) or fail for a domain reason, but it
// must NOT be the unknown-tool InvalidParams error.
if let Some(err) = response.error {
assert_ne!(
err.code, -32602,
"'{name}' should still dispatch (hidden alias), got unknown-tool error: {}",
err.message
);
}
}
}
/// v2.2: the 4 tools folded into `memory_status` must still dispatch, and
/// each `view` of the new tool must resolve.
#[tokio::test]
async fn test_memory_status_views_and_aliases() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let calls: Vec<(&str, serde_json::Value)> = vec![
// Deprecated aliases must still dispatch.
("system_status", serde_json::json!({})),
("memory_health", serde_json::json!({})),
("memory_timeline", serde_json::json!({})),
("memory_changelog", serde_json::json!({})),
// New unified views.
("memory_status", serde_json::json!({})), // default view = health
("memory_status", serde_json::json!({"view": "retention"})),
("memory_status", serde_json::json!({"view": "timeline"})),
("memory_status", serde_json::json!({"view": "changelog"})),
];
for (name, args) in calls {
let request = make_request(
"tools/call",
Some(serde_json::json!({ "name": name, "arguments": args })),
);
let response = server.handle_request(request).await.unwrap();
assert!(
response.error.is_none(),
"'{name}' {args} should resolve, got error: {:?}",
response.error
);
}
}
/// v2.2: the 4 tools folded into `graph` must still dispatch, and the
/// read-only `graph` actions must resolve. (memory_graph is sync — this also
/// guards the no-`.await` facade branch.)
#[tokio::test]
async fn test_graph_actions_and_aliases() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let calls: Vec<(&str, serde_json::Value)> = vec![
// Deprecated aliases must still dispatch (not unknown-tool).
("predict", serde_json::json!({})),
("memory_graph", serde_json::json!({})),
("composed_graph", serde_json::json!({"action": "recent"})),
// New unified actions (read-only).
("graph", serde_json::json!({"action": "predict"})),
("graph", serde_json::json!({"action": "memory_graph"})),
("graph", serde_json::json!({"action": "recent"})),
("graph", serde_json::json!({"action": "never_composed"})),
];
for (name, args) in calls {
let request = make_request(
"tools/call",
Some(serde_json::json!({ "name": name, "arguments": args })),
);
let response = server.handle_request(request).await.unwrap();
if let Some(err) = response.error {
assert_ne!(
err.code, -32602,
"'{name}' {args} should dispatch (not unknown-tool): {}",
err.message
);
}
}
}
/// v2.2: the 7 tools folded into `maintain` must still dispatch, the new
/// actions must resolve, gc must default to dry_run, and restore must keep
/// path validation (a nonexistent path errors rather than silently no-op).
#[tokio::test]
async fn test_maintain_actions_and_safety() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
// Aliases + safe new actions must dispatch (not unknown-tool).
let dispatch_ok: Vec<(&str, serde_json::Value)> = vec![
("consolidate", serde_json::json!({})),
("backup", serde_json::json!({})),
("dream", serde_json::json!({})),
("maintain", serde_json::json!({"action": "consolidate"})),
("maintain", serde_json::json!({"action": "gc"})),
("maintain", serde_json::json!({"action": "backup"})),
];
for (name, args) in dispatch_ok {
let request = make_request(
"tools/call",
Some(serde_json::json!({ "name": name, "arguments": args })),
);
let response = server.handle_request(request).await.unwrap();
if let Some(err) = response.error {
assert_ne!(err.code, -32602, "'{name}' {args} should dispatch: {}", err.message);
}
}
// gc via maintain defaults to dry_run=true (no deletion).
let gc_req = make_request(
"tools/call",
Some(serde_json::json!({ "name": "maintain", "arguments": {"action": "gc"} })),
);
let gc_resp = server.handle_request(gc_req).await.unwrap();
let text = gc_resp.result.unwrap()["content"][0]["text"]
.as_str()
.unwrap()
.to_string();
assert!(
text.contains("\"dryRun\": true") || text.contains("\"dryRun\":true"),
"maintain action=gc must default to dry_run=true; got: {text}"
);
// restore keeps path validation: a missing file must error, not no-op.
let restore_req = make_request(
"tools/call",
Some(serde_json::json!({
"name": "maintain",
"arguments": {"action": "restore", "path": "/nonexistent/vestige-backup-xyz.json"}
})),
);
let restore_resp = server.handle_request(restore_req).await.unwrap();
// Either a JSON-RPC error or an error envelope is acceptable; a silent
// success is NOT (that would mean confinement/validation was bypassed).
let validated = restore_resp.error.is_some()
|| restore_resp
.result
.map(|r| {
r["content"][0]["text"]
.as_str()
.map(|t| t.to_lowercase().contains("not found") || t.to_lowercase().contains("error"))
.unwrap_or(false)
})
.unwrap_or(false);
assert!(validated, "maintain action=restore must validate a missing path");
}
/// v2.2 HOT PATH: `recall` defaults to mode='lookup' (search), the folded
/// names still dispatch, and the reason/contradictions modes resolve.
#[tokio::test]
async fn test_recall_modes_and_aliases() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let calls: Vec<(&str, serde_json::Value)> = vec![
// Deprecated aliases must still dispatch.
("search", serde_json::json!({"query": "x"})),
("deep_reference", serde_json::json!({"query": "x"})),
("cross_reference", serde_json::json!({"query": "x"})),
("contradictions", serde_json::json!({})),
("semantic_search", serde_json::json!({"query": "x"})),
// New unified modes.
("recall", serde_json::json!({"query": "x"})), // default mode = lookup
("recall", serde_json::json!({"mode": "lookup", "query": "x"})),
("recall", serde_json::json!({"mode": "reason", "query": "x"})),
("recall", serde_json::json!({"mode": "contradictions"})),
];
for (name, args) in calls {
let request = make_request(
"tools/call",
Some(serde_json::json!({ "name": name, "arguments": args })),
);
let response = server.handle_request(request).await.unwrap();
assert!(
response.error.is_none(),
"'{name}' {args} should resolve, got error: {:?}",
response.error
);
}
}
/// v2.2: `recall` mode='lookup' (the default) must produce the same result
/// shape as the former standalone `search` — i.e. the no-mode default is a
/// faithful pass-through, not a reasoning call.
#[tokio::test]
async fn test_recall_lookup_matches_search_shape() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let args = serde_json::json!({ "query": "anything" });
let via_recall = make_request(
"tools/call",
Some(serde_json::json!({ "name": "recall", "arguments": args })),
);
let via_search = make_request(
"tools/call",
Some(serde_json::json!({ "name": "search", "arguments": args })),
);
let r1 = server.handle_request(via_recall).await.unwrap();
let r2 = server.handle_request(via_search).await.unwrap();
assert!(r1.error.is_none() && r2.error.is_none());
// The unified-tool wrapper text (the search payload) must match.
assert_eq!(
r1.result.unwrap()["content"][0]["text"],
r2.result.unwrap()["content"][0]["text"],
"recall(mode=lookup) must equal search byte-for-byte"
);
}
#[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 {
// v2.2: search folded into recall (mode='lookup'); annotation moved.
"recall" => Some(300_000),
// v2.2: memory_timeline folded into memory_status (view='timeline');
// the high-payload annotation moved with it.
"memory_status" => Some(200_000),
"memory" => Some(100_000),
"codebase" => Some(100_000),
// v2.2: dedup action='scan' returns clusters + candidates + policy.
"dedup" => Some(150_000),
// v2.2: graph memory_graph layout + bounty_mode pagination.
"graph" => Some(250_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 ["recall", "memory_status", "memory", "codebase", "dedup", "graph"] {
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();
// v2.2: `recall` is the annotated retrieval tool (search folded in).
let recall_tool = tools
.iter()
.find(|t| t["name"].as_str() == Some("recall"))
.expect("'recall' tool present");
// Wire-form: `_meta` must exist; `meta` (un-renamed) must NOT exist.
assert!(
recall_tool.get("_meta").is_some(),
"recall tool missing `_meta` key (serde rename to _meta did not apply)"
);
assert!(
recall_tool.get("meta").is_none(),
"recall tool has un-renamed `meta` key (regression — serde rename broke)"
);
}
}