mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-02 22:01:01 +02:00
First AI memory system to model forgetting as a neuroscience-grounded PROCESS rather than passive decay. Adds the `suppress` MCP tool (#24), Rac1 cascade worker, migration V10, and dashboard forgetting indicators. Based on: - Anderson, Hanslmayr & Quaegebeur (2025), Nat Rev Neurosci — right lateral PFC as the domain-general inhibitory controller; SIF compounds with each stopping attempt. - Cervantes-Sandoval et al. (2020), Front Cell Neurosci PMC7477079 — Rac1 GTPase as the active synaptic destabilization mechanism. What's new: * `suppress` MCP tool — each call compounds `suppression_count` and subtracts a `0.15 × count` penalty (saturating at 80%) from retrieval scores during hybrid search. Distinct from delete (removes) and demote (one-shot). * Rac1 cascade worker — background sweep piggybacks the 6h consolidation loop, walks `memory_connections` edges from recently-suppressed seeds, applies attenuated FSRS decay to co-activated neighbors. You don't just forget Jake — you fade the café, the roommate, the birthday. * 24h labile window — reversible via `suppress({id, reverse: true})` within 24 hours. Matches Nader reconsolidation semantics. * Migration V10 — additive-only (`suppression_count`, `suppressed_at` + partial indices). All v2.0.x DBs upgrade seamlessly on first launch. * Dashboard: `ForgettingIndicator.svelte` pulses when suppressions are active. 3D graph nodes dim to 20% opacity when suppressed. New WebSocket events: `MemorySuppressed`, `MemoryUnsuppressed`, `Rac1CascadeSwept`. Heartbeat carries `suppressed_count`. * Search pipeline: SIF penalty inserted into the accessibility stage so it stacks on top of passive FSRS decay. * Tool count bumped 23 → 24. Cognitive modules 29 → 30. Memories persist — they are INHIBITED, not erased. `memory.get(id)` returns full content through any number of suppressions. The 24h labile window is a grace period for regret. Also fixes issue #31 (dashboard graph view buggy) as a companion UI bug discovered during the v2.0.5 audit cycle: * Root cause: node glow `SpriteMaterial` had no `map`, so `THREE.Sprite` rendered as a solid-coloured 1×1 plane. Additive blending + `UnrealBloomPass(0.8, 0.4, 0.85)` amplified the square edges into hard-edged glowing cubes. * Fix: shared 128×128 radial-gradient `CanvasTexture` singleton used as the sprite map. Retuned bloom to `(0.55, 0.6, 0.2)`. Halved fog density (0.008 → 0.0035). Edges bumped from dark navy `0x4a4a7a` to brand violet `0x8b5cf6` with higher opacity. Added explicit `scene.background` and a 2000-point starfield for depth. * 21 regression tests added in `ui-fixes.test.ts` locking every invariant in (shared texture singleton, depthWrite:false, scale ×6, bloom magic numbers via source regex, starfield presence). Tests: 1,284 Rust (+47) + 171 Vitest (+21) = 1,455 total, 0 failed Clippy: clean across all targets, zero warnings Release binary: 22.6MB, `cargo build --release -p vestige-mcp` green Versions: workspace aligned at 2.0.5 across all 6 crates/packages Closes #31
492 lines
14 KiB
Rust
492 lines
14 KiB
Rust
//! # MCP Protocol Compliance Tests
|
|
//!
|
|
//! Tests validating JSON-RPC 2.0 and MCP protocol compliance.
|
|
//! Based on the Model Context Protocol specification.
|
|
|
|
use serde_json::json;
|
|
|
|
// ============================================================================
|
|
// JSON-RPC 2.0 MESSAGE FORMAT TESTS
|
|
// ============================================================================
|
|
|
|
/// Test that JSON-RPC requests have required fields.
|
|
///
|
|
/// Per JSON-RPC 2.0 spec, requests MUST contain:
|
|
/// - jsonrpc: "2.0"
|
|
/// - method: string
|
|
/// - id: optional (if present, makes it a request vs notification)
|
|
#[test]
|
|
fn test_jsonrpc_request_required_fields() {
|
|
// Valid request with all required fields
|
|
let valid_request = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {}
|
|
});
|
|
|
|
assert_eq!(
|
|
valid_request["jsonrpc"], "2.0",
|
|
"jsonrpc version must be 2.0"
|
|
);
|
|
assert!(
|
|
valid_request["method"].is_string(),
|
|
"method must be a string"
|
|
);
|
|
assert!(
|
|
valid_request["id"].is_number(),
|
|
"id should be present for requests"
|
|
);
|
|
}
|
|
|
|
/// Test that JSON-RPC notifications have no id field.
|
|
///
|
|
/// Notifications are requests without an id - the server MUST NOT reply.
|
|
#[test]
|
|
fn test_jsonrpc_notification_has_no_id() {
|
|
let notification = json!({
|
|
"jsonrpc": "2.0",
|
|
"method": "notifications/initialized"
|
|
});
|
|
|
|
assert!(
|
|
notification.get("id").is_none(),
|
|
"Notifications must not have an id field"
|
|
);
|
|
assert_eq!(notification["method"], "notifications/initialized");
|
|
}
|
|
|
|
/// Test JSON-RPC response format for success.
|
|
///
|
|
/// Successful responses MUST contain:
|
|
/// - jsonrpc: "2.0"
|
|
/// - id: matching the request id
|
|
/// - result: the result value (any JSON)
|
|
/// - MUST NOT contain error
|
|
#[test]
|
|
fn test_jsonrpc_success_response_format() {
|
|
let success_response = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"result": {
|
|
"protocolVersion": "2024-11-05",
|
|
"serverInfo": {
|
|
"name": "vestige",
|
|
"version": "0.1.0"
|
|
}
|
|
}
|
|
});
|
|
|
|
assert_eq!(success_response["jsonrpc"], "2.0");
|
|
assert!(
|
|
success_response["result"].is_object(),
|
|
"Success response must have result"
|
|
);
|
|
assert!(
|
|
success_response.get("error").is_none(),
|
|
"Success response must not have error"
|
|
);
|
|
}
|
|
|
|
/// Test JSON-RPC response format for errors.
|
|
///
|
|
/// Error responses MUST contain:
|
|
/// - jsonrpc: "2.0"
|
|
/// - id: matching the request id (or null if parsing failed)
|
|
/// - error: object with code, message, and optional data
|
|
/// - MUST NOT contain result
|
|
#[test]
|
|
fn test_jsonrpc_error_response_format() {
|
|
let error_response = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"error": {
|
|
"code": -32601,
|
|
"message": "Method not found"
|
|
}
|
|
});
|
|
|
|
assert_eq!(error_response["jsonrpc"], "2.0");
|
|
assert!(
|
|
error_response["error"].is_object(),
|
|
"Error response must have error object"
|
|
);
|
|
assert!(
|
|
error_response["error"]["code"].is_number(),
|
|
"Error must have code"
|
|
);
|
|
assert!(
|
|
error_response["error"]["message"].is_string(),
|
|
"Error must have message"
|
|
);
|
|
assert!(
|
|
error_response.get("result").is_none(),
|
|
"Error response must not have result"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// STANDARD JSON-RPC ERROR CODE TESTS
|
|
// ============================================================================
|
|
|
|
/// Test standard JSON-RPC error codes.
|
|
///
|
|
/// Standard error codes are defined in JSON-RPC 2.0:
|
|
/// - -32700: Parse error
|
|
/// - -32600: Invalid Request
|
|
/// - -32601: Method not found
|
|
/// - -32602: Invalid params
|
|
/// - -32603: Internal error
|
|
#[test]
|
|
fn test_standard_jsonrpc_error_codes() {
|
|
let error_codes = [
|
|
(-32700, "Parse error"),
|
|
(-32600, "Invalid Request"),
|
|
(-32601, "Method not found"),
|
|
(-32602, "Invalid params"),
|
|
(-32603, "Internal error"),
|
|
];
|
|
|
|
for (code, message) in error_codes {
|
|
// All standard codes are in the reserved range
|
|
assert!(
|
|
(-32700..=-32600).contains(&code),
|
|
"Standard error code {} ({}) must be in reserved range",
|
|
code,
|
|
message
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Test MCP-specific error codes.
|
|
///
|
|
/// MCP defines additional error codes in the -32000 to -32099 range:
|
|
/// - -32000: Connection closed
|
|
/// - -32001: Request timeout
|
|
/// - -32002: Resource not found
|
|
/// - -32003: Server not initialized
|
|
#[test]
|
|
fn test_mcp_specific_error_codes() {
|
|
let mcp_error_codes = [
|
|
(-32000, "ConnectionClosed"),
|
|
(-32001, "RequestTimeout"),
|
|
(-32002, "ResourceNotFound"),
|
|
(-32003, "ServerNotInitialized"),
|
|
];
|
|
|
|
for (code, name) in mcp_error_codes {
|
|
// MCP-specific codes are in the server error range
|
|
assert!(
|
|
(-32099..=-32000).contains(&code),
|
|
"MCP error code {} ({}) must be in server error range",
|
|
code,
|
|
name
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// MCP INITIALIZATION TESTS
|
|
// ============================================================================
|
|
|
|
/// Test MCP initialize request format.
|
|
///
|
|
/// The initialize request MUST contain:
|
|
/// - protocolVersion: string (e.g., "2024-11-05")
|
|
/// - capabilities: object describing client capabilities
|
|
/// - clientInfo: object with name and version
|
|
#[test]
|
|
fn test_mcp_initialize_request_format() {
|
|
let init_request = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {
|
|
"roots": {},
|
|
"sampling": {}
|
|
},
|
|
"clientInfo": {
|
|
"name": "test-client",
|
|
"version": "1.0.0"
|
|
}
|
|
}
|
|
});
|
|
|
|
let params = &init_request["params"];
|
|
assert!(
|
|
params["protocolVersion"].is_string(),
|
|
"protocolVersion required"
|
|
);
|
|
assert!(params["capabilities"].is_object(), "capabilities required");
|
|
assert!(params["clientInfo"].is_object(), "clientInfo required");
|
|
assert!(
|
|
params["clientInfo"]["name"].is_string(),
|
|
"clientInfo.name required"
|
|
);
|
|
assert!(
|
|
params["clientInfo"]["version"].is_string(),
|
|
"clientInfo.version required"
|
|
);
|
|
}
|
|
|
|
/// Test MCP initialize response format.
|
|
///
|
|
/// The initialize response MUST contain:
|
|
/// - protocolVersion: string (server's version)
|
|
/// - serverInfo: object with name and version
|
|
/// - capabilities: object describing server capabilities
|
|
/// - instructions: optional string with usage guidance
|
|
#[test]
|
|
fn test_mcp_initialize_response_format() {
|
|
let init_response = json!({
|
|
"protocolVersion": "2024-11-05",
|
|
"serverInfo": {
|
|
"name": "vestige",
|
|
"version": "0.1.0"
|
|
},
|
|
"capabilities": {
|
|
"tools": { "listChanged": false },
|
|
"resources": { "listChanged": false }
|
|
},
|
|
"instructions": "Vestige is your long-term memory system."
|
|
});
|
|
|
|
assert!(
|
|
init_response["protocolVersion"].is_string(),
|
|
"protocolVersion required"
|
|
);
|
|
assert!(
|
|
init_response["serverInfo"].is_object(),
|
|
"serverInfo required"
|
|
);
|
|
assert!(
|
|
init_response["serverInfo"]["name"].is_string(),
|
|
"serverInfo.name required"
|
|
);
|
|
assert!(
|
|
init_response["serverInfo"]["version"].is_string(),
|
|
"serverInfo.version required"
|
|
);
|
|
assert!(
|
|
init_response["capabilities"].is_object(),
|
|
"capabilities required"
|
|
);
|
|
}
|
|
|
|
/// Test that requests before initialization are rejected.
|
|
///
|
|
/// Per MCP spec, the server MUST reject all requests except 'initialize'
|
|
/// until initialization is complete.
|
|
#[test]
|
|
fn test_server_rejects_requests_before_initialize() {
|
|
// Simulate the expected error for pre-init requests
|
|
let pre_init_error = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"error": {
|
|
"code": -32003,
|
|
"message": "Server not initialized"
|
|
}
|
|
});
|
|
|
|
assert_eq!(
|
|
pre_init_error["error"]["code"], -32003,
|
|
"Pre-initialization requests should return ServerNotInitialized error"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TOOLS PROTOCOL TESTS
|
|
// ============================================================================
|
|
|
|
/// Test tools/list response format.
|
|
///
|
|
/// The response contains an array of tool descriptions, each with:
|
|
/// - name: string (tool identifier)
|
|
/// - description: optional string
|
|
/// - inputSchema: JSON Schema for tool arguments
|
|
#[test]
|
|
fn test_tools_list_response_format() {
|
|
let tools_list_response = json!({
|
|
"tools": [
|
|
{
|
|
"name": "ingest",
|
|
"description": "Add new knowledge to memory.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"content": { "type": "string" }
|
|
},
|
|
"required": ["content"]
|
|
}
|
|
},
|
|
{
|
|
"name": "recall",
|
|
"description": "Search and retrieve knowledge.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": { "type": "string" }
|
|
},
|
|
"required": ["query"]
|
|
}
|
|
}
|
|
]
|
|
});
|
|
|
|
let tools = tools_list_response["tools"].as_array().unwrap();
|
|
assert!(!tools.is_empty(), "Tools list should not be empty");
|
|
|
|
for tool in tools {
|
|
assert!(tool["name"].is_string(), "Tool must have name");
|
|
assert!(
|
|
tool["inputSchema"].is_object(),
|
|
"Tool must have inputSchema"
|
|
);
|
|
assert_eq!(
|
|
tool["inputSchema"]["type"], "object",
|
|
"inputSchema must be an object type"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Test tools/call request format.
|
|
///
|
|
/// The request MUST contain:
|
|
/// - name: string (tool to invoke)
|
|
/// - arguments: optional object with tool parameters
|
|
#[test]
|
|
fn test_tools_call_request_format() {
|
|
let tools_call_request = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 2,
|
|
"method": "tools/call",
|
|
"params": {
|
|
"name": "ingest",
|
|
"arguments": {
|
|
"content": "Test knowledge to remember",
|
|
"nodeType": "fact",
|
|
"tags": ["test", "memory"]
|
|
}
|
|
}
|
|
});
|
|
|
|
let params = &tools_call_request["params"];
|
|
assert!(params["name"].is_string(), "Tool name required");
|
|
assert!(
|
|
params["arguments"].is_object(),
|
|
"Arguments should be an object"
|
|
);
|
|
}
|
|
|
|
/// Test tools/call response format.
|
|
///
|
|
/// The response contains:
|
|
/// - content: array of content items (text, image, etc.)
|
|
/// - isError: optional boolean indicating tool execution error
|
|
#[test]
|
|
fn test_tools_call_response_format() {
|
|
let tools_call_response = json!({
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": "{\"success\": true, \"nodeId\": \"abc123\"}"
|
|
}
|
|
],
|
|
"isError": false
|
|
});
|
|
|
|
let content = tools_call_response["content"].as_array().unwrap();
|
|
assert!(!content.is_empty(), "Content array should not be empty");
|
|
assert!(
|
|
content[0]["type"].is_string(),
|
|
"Content item must have type"
|
|
);
|
|
assert!(
|
|
content[0]["text"].is_string(),
|
|
"Text content must have text field"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// RESOURCES PROTOCOL TESTS
|
|
// ============================================================================
|
|
|
|
/// Test resources/list response format.
|
|
///
|
|
/// The response contains an array of resource descriptions:
|
|
/// - uri: string (resource identifier)
|
|
/// - name: string (human-readable name)
|
|
/// - description: optional string
|
|
/// - mimeType: optional string
|
|
#[test]
|
|
fn test_resources_list_response_format() {
|
|
let resources_list = json!({
|
|
"resources": [
|
|
{
|
|
"uri": "memory://stats",
|
|
"name": "Memory Statistics",
|
|
"description": "Current memory system statistics",
|
|
"mimeType": "application/json"
|
|
},
|
|
{
|
|
"uri": "memory://recent",
|
|
"name": "Recent Memories",
|
|
"description": "Recently added memories",
|
|
"mimeType": "application/json"
|
|
}
|
|
]
|
|
});
|
|
|
|
let resources = resources_list["resources"].as_array().unwrap();
|
|
for resource in resources {
|
|
assert!(resource["uri"].is_string(), "Resource must have uri");
|
|
assert!(resource["name"].is_string(), "Resource must have name");
|
|
}
|
|
}
|
|
|
|
/// Test resources/read request format.
|
|
///
|
|
/// The request MUST contain:
|
|
/// - uri: string (resource to read)
|
|
#[test]
|
|
fn test_resources_read_request_format() {
|
|
let read_request = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 3,
|
|
"method": "resources/read",
|
|
"params": {
|
|
"uri": "memory://stats"
|
|
}
|
|
});
|
|
|
|
assert!(read_request["params"]["uri"].is_string(), "URI required");
|
|
}
|
|
|
|
/// Test resources/read response format.
|
|
///
|
|
/// The response contains:
|
|
/// - contents: array of content items with uri, mimeType, and text/blob
|
|
#[test]
|
|
fn test_resources_read_response_format() {
|
|
let read_response = json!({
|
|
"contents": [
|
|
{
|
|
"uri": "memory://stats",
|
|
"mimeType": "application/json",
|
|
"text": "{\"totalNodes\": 42, \"averageRetention\": 0.85}"
|
|
}
|
|
]
|
|
});
|
|
|
|
let contents = read_response["contents"].as_array().unwrap();
|
|
assert!(!contents.is_empty(), "Contents should not be empty");
|
|
assert!(contents[0]["uri"].is_string(), "Content must have uri");
|
|
// Must have either text or blob
|
|
assert!(
|
|
contents[0]["text"].is_string() || contents[0]["blob"].is_string(),
|
|
"Content must have text or blob"
|
|
);
|
|
}
|