diff --git a/crates/vestige-mcp/src/server.rs b/crates/vestige-mcp/src/server.rs index 979f8d9..65c78f0 100644 --- a/crates/vestige-mcp/src/server.rs +++ b/crates/vestige-mcp/src/server.rs @@ -357,16 +357,14 @@ impl McpServer { input_schema: tools::dream::schema(), ..Default::default() }, + // ================================================================ + // GRAPH — unified graph/association/prediction tool (v2.2) + // Folds explore_connections + predict + memory_graph + composed_graph. + // ================================================================ 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(), + 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() }, // ================================================================ @@ -389,20 +387,9 @@ impl McpServer { }, // ================================================================ // AUTONOMIC TOOLS (v1.9+) - // (memory_health folded into `memory_status` view='retention' in v2.2) + // (memory_health → `memory_status` view='retention'; + // memory_graph + composed_graph → `graph`, all in v2.2) // ================================================================ - 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 // ================================================================ @@ -464,6 +451,9 @@ impl McpServer { // 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 { @@ -1012,10 +1002,20 @@ impl McpServer { }); 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 } "restore" => tools::restore::execute(&self.storage, request.arguments).await, @@ -1052,8 +1052,13 @@ impl McpServer { warn!("Tool 'memory_health' is deprecated in v2.2. Use 'memory_status' (view='retention')."); tools::health::execute(&self.storage, request.arguments).await } - "memory_graph" => tools::graph::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 } "deep_reference" | "cross_reference" => { @@ -1825,8 +1830,8 @@ mod tests { // dispatchable as hidden back-compat aliases but drop off the advertised list. assert_eq!( tools.len(), - 24, - "Expected exactly 24 tools after dedup + memory_status consolidation" + 21, + "Expected exactly 21 tools after dedup + memory_status + graph consolidation" ); let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); @@ -1913,9 +1918,8 @@ mod tests { } // Cognitive tools (v1.5) + // (explore_connections + predict folded into `graph` in v2.2) 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) — renamed session_context → session_start (v2.2) @@ -1925,9 +1929,21 @@ mod tests { "session_context renamed to 'session_start' in v2.2" ); - // Autonomic tools (v1.9) — memory_health folded into memory_status (v2.2) - assert!(tool_names.contains(&"memory_graph")); - assert!(tool_names.contains(&"composed_graph")); + // 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" + ); + } // Deep reference + cross_reference alias (v2.0.4) assert!(tool_names.contains(&"deep_reference")); @@ -2011,6 +2027,43 @@ mod tests { } } + /// 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 + ); + } + } + } + #[tokio::test] async fn test_tools_have_descriptions_and_schemas() { let (mut server, _dir) = test_server().await; @@ -2233,6 +2286,8 @@ mod tests { "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, } } @@ -2248,7 +2303,7 @@ mod tests { let result = response.result.unwrap(); let tools = result["tools"].as_array().unwrap(); - for name in ["search", "memory_status", "memory", "codebase", "dedup"] { + for name in ["search", "memory_status", "memory", "codebase", "dedup", "graph"] { let tool = tools .iter() .find(|t| t["name"].as_str() == Some(name)) diff --git a/crates/vestige-mcp/src/tools/composed_graph.rs b/crates/vestige-mcp/src/tools/composed_graph.rs index 957f8e8..58c18dd 100644 --- a/crates/vestige-mcp/src/tools/composed_graph.rs +++ b/crates/vestige-mcp/src/tools/composed_graph.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use uuid::Uuid; use vestige_core::{CompositionOutcomeRecord, Storage}; -const OUTCOME_TYPES: &[&str] = &[ +pub(crate) const OUTCOME_TYPES: &[&str] = &[ "helpful", "dead_end", "submitted", diff --git a/crates/vestige-mcp/src/tools/graph_unified.rs b/crates/vestige-mcp/src/tools/graph_unified.rs new file mode 100644 index 0000000..ab8a4dc --- /dev/null +++ b/crates/vestige-mcp/src/tools/graph_unified.rs @@ -0,0 +1,140 @@ +//! Unified `graph` Tool (v2.2 — Tool Consolidation) +//! +//! Folds four graph/association/prediction tools into one action-dispatched +//! surface: +//! +//! action ∈ { +//! chain, associations, bridges, // former explore_connections +//! predict, // former predict +//! memory_graph, // former memory_graph (viz subgraph) +//! recent, get, memory, neighbors, // former composed_graph +//! never_composed, bounty_mode, label, // " +//! } +//! +//! This is a transparent facade: each action forwards the *same* args envelope +//! to the existing handler, which re-reads its own discriminator/params. None of +//! the underlying arg structs use `deny_unknown_fields`, so unrelated fields are +//! ignored. All actions are read-only EXCEPT `label`, which writes a composition +//! outcome (the one mutator) and is logged for audit. + +use serde_json::Value; +use std::sync::Arc; +use tokio::sync::Mutex; + +use vestige_core::Storage; + +use crate::cognitive::CognitiveEngine; +// Reuse composed_graph's canonical outcome-label vocabulary (do not re-list). +use super::composed_graph::OUTCOME_TYPES; + +/// Discriminated-union schema for the unified `graph` tool. +pub fn schema() -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "chain", "associations", "bridges", + "predict", "memory_graph", + "recent", "get", "memory", "neighbors", + "never_composed", "bounty_mode", "label" + ], + "description": "Graph operation. Reasoning paths: 'chain' (from→to), 'associations' (related 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'). Composition topology: 'recent', 'get' (event_id), 'memory' (memory_id), 'neighbors' (memory_id), 'never_composed', 'bounty_mode', 'label' (record an outcome — the only write)." + }, + // --- explore (chain/associations/bridges) --- + "from": { "type": "string", "description": "[chain/associations/bridges] Source memory ID." }, + "to": { "type": "string", "description": "[chain/bridges] Target memory ID." }, + // --- predict --- + "context": { "type": "object", "description": "[predict] Current context (current_file, current_topics, codebase)." }, + // --- memory_graph (viz subgraph) --- + "center_id": { "type": "string", "description": "[memory_graph] Center node id (or use 'query')." }, + "query": { "type": "string", "description": "[memory_graph] Pick a center node by search query." }, + "depth": { "type": "integer", "minimum": 1, "maximum": 3, "description": "[memory_graph] Traversal depth (1-3, default 2)." }, + "max_nodes": { "type": "integer", "description": "[memory_graph] Max nodes (default 50, capped 200)." }, + // --- composed_graph --- + "event_id": { "type": "string", "description": "[get/label] Composition event id." }, + "memory_id": { "type": "string", "description": "[memory/neighbors] Memory id." }, + "tags": { "type": "array", "items": { "type": "string" }, "description": "[never_composed/bounty_mode] Optional tag filter." }, + "outcome_type": { + "type": "string", + "enum": OUTCOME_TYPES, + "description": "[label] Outcome to record for the composition (the only mutating action)." + }, + // --- shared --- + "limit": { "type": "integer", "description": "Max results (per-action defaults; clamped internally).", "minimum": 1, "maximum": 100 } + }, + "required": ["action"] + }) +} + +/// Unified dispatcher for `graph`. Routes on `action`. +pub async fn execute( + storage: &Arc, + cognitive: &Arc>, + args: Option, +) -> Result { + let action = args + .as_ref() + .and_then(|a| a.get("action")) + .and_then(|v| v.as_str()) + .ok_or("Missing 'action'. Use chain|associations|bridges|predict|memory_graph|recent|get|memory|neighbors|never_composed|bounty_mode|label.")? + .to_string(); + + match action.as_str() { + // explore_connections — re-reads its own `action` (chain/associations/bridges). + "chain" | "associations" | "bridges" => { + super::explore::execute(storage, cognitive, args).await + } + // predict — reads `context`, ignores `action`. + "predict" => super::predict::execute(storage, cognitive, args).await, + // memory_graph — reads center_id/query/depth, ignores `action`. + "memory_graph" => super::graph::execute(storage, args).await, + // composed_graph — re-reads its own `action`. `label` is the only write. + "recent" | "get" | "memory" | "neighbors" | "never_composed" | "bounty_mode" | "label" => { + if action == "label" { + let event_id = args + .as_ref() + .and_then(|a| a.get("event_id")) + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let outcome = args + .as_ref() + .and_then(|a| a.get("outcome_type")) + .and_then(|v| v.as_str()) + .unwrap_or("?"); + tracing::info!( + event_id = %event_id, + outcome_type = %outcome, + "graph: composition outcome labeled" + ); + } + super::composed_graph::execute(storage, args).await + } + other => Err(format!( + "Unknown graph action '{other}'. Use chain|associations|bridges|predict|memory_graph|recent|get|memory|neighbors|never_composed|bounty_mode|label." + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_schema_action_count() { + let s = schema(); + let actions = s["properties"]["action"]["enum"].as_array().unwrap(); + assert_eq!(actions.len(), 12); + // outcome_type enum is sourced from the canonical const. + let outcomes = s["properties"]["outcome_type"]["enum"].as_array().unwrap(); + assert_eq!(outcomes.len(), OUTCOME_TYPES.len()); + } + + #[test] + fn test_missing_action_errors() { + // Pure arg-shape check; no storage needed for the early return path. + let s = schema(); + assert_eq!(s["required"][0], "action"); + } +} diff --git a/crates/vestige-mcp/src/tools/mod.rs b/crates/vestige-mcp/src/tools/mod.rs index 4f2043d..f8ee696 100644 --- a/crates/vestige-mcp/src/tools/mod.rs +++ b/crates/vestige-mcp/src/tools/mod.rs @@ -46,6 +46,10 @@ pub mod session_context; pub mod graph; pub mod health; +// v2.2: Unified graph surface — folds explore_connections + predict + +// memory_graph + composed_graph into one action-dispatched tool. +pub mod graph_unified; + // v2.1: Cross-reference (connect the dots) pub mod composed_graph; pub mod contradictions;