use crate::state::MemoryState; use async_trait::async_trait; use serde_json::Value; use std::sync::Arc; #[async_trait] pub trait McpTool: Send + Sync { /// The unique name of the tool fn name(&self) -> &'static str; /// The JSON schema for the tool fn schema(&self) -> Value; /// Execute the tool with the given arguments async fn execute(&self, args: Value, state: Arc) -> crate::error::Result; } #[async_trait] pub trait McpResource: Send + Sync { fn uri(&self) -> &'static str; fn name(&self) -> &'static str; fn description(&self) -> Option<&'static str> { None } fn mime_type(&self) -> Option<&'static str> { Some("application/json") } async fn read(&self, state: Arc) -> crate::error::Result; } #[async_trait] pub trait McpPrompt: Send + Sync { fn name(&self) -> &'static str; fn description(&self) -> Option<&'static str> { None } fn arguments(&self) -> serde_json::Value { serde_json::json!([]) } async fn get( &self, args: Value, state: Arc, ) -> crate::error::Result; } struct GraphEntitiesResource; #[async_trait] impl McpResource for GraphEntitiesResource { fn uri(&self) -> &'static str { "memory://graph/entities" } fn name(&self) -> &'static str { "Graph Entities" } fn description(&self) -> Option<&'static str> { Some("All nodes and entities currently stored in the knowledge graph") } async fn read(&self, state: Arc) -> crate::error::Result { let graph = state.graph.cache.read().unwrap_or_else(|e| e.into_inner()); let data: Vec<_> = graph.entities.values().collect(); if data.len() > 100 { let truncated: Vec<_> = data.iter().take(100).cloned().collect(); let res = serde_json::json!({ "_meta": format!("Showing 100 of {} total entities. Use omni_search or get_subgraph for targeted entity discovery.", data.len()), "entities": truncated }); Ok(serde_json::to_string_pretty(&res)?) } else { Ok(serde_json::to_string_pretty(&data)?) } } } struct GraphRelationsResource; #[async_trait] impl McpResource for GraphRelationsResource { fn uri(&self) -> &'static str { "memory://graph/relations" } fn name(&self) -> &'static str { "Graph Relations" } fn description(&self) -> Option<&'static str> { Some("All relationships between entities currently stored in the knowledge graph") } async fn read(&self, state: Arc) -> crate::error::Result { let graph = state.graph.cache.read().unwrap_or_else(|e| e.into_inner()); let data = &graph.relations; if data.len() > 200 { let truncated: Vec<_> = data.iter().take(200).cloned().collect(); let res = serde_json::json!({ "_meta": format!("Showing 200 of {} total relations. Use query_graph_path or get_subgraph for targeted relation discovery.", data.len()), "relations": truncated }); Ok(serde_json::to_string_pretty(&res)?) } else { Ok(serde_json::to_string_pretty(&data)?) } } } struct TasksActiveResource; #[async_trait] impl McpResource for TasksActiveResource { fn uri(&self) -> &'static str { "memory://tasks/active" } fn name(&self) -> &'static str { "Active Tasks" } fn description(&self) -> Option<&'static str> { Some("List of currently active tasks") } async fn read(&self, state: Arc) -> crate::error::Result { let tasks = state .project .tasks .cache .read() .unwrap_or_else(|e| e.into_inner()); let data: Vec<_> = tasks.iter().filter(|t| t.is_active()).collect(); Ok(serde_json::to_string_pretty(&data)?) } } struct DecisionsActiveResource; #[async_trait] impl McpResource for DecisionsActiveResource { fn uri(&self) -> &'static str { "memory://decisions/active" } fn name(&self) -> &'static str { "Active ADR Decisions" } fn description(&self) -> Option<&'static str> { Some("All accepted Architectural Decision Records (ADRs)") } async fn read(&self, state: Arc) -> crate::error::Result { let adrs = state .code .adrs .cache .read() .unwrap_or_else(|e| e.into_inner()); let data: Vec<_> = adrs.iter().filter(|a| a.status == "accepted").collect(); Ok(serde_json::to_string_pretty(&data)?) } } struct TechDebtActiveResource; #[async_trait] impl McpResource for TechDebtActiveResource { fn uri(&self) -> &'static str { "memory://tech_debt/unresolved" } fn name(&self) -> &'static str { "Unresolved Tech Debt" } fn description(&self) -> Option<&'static str> { Some("All currently unresolved technical debt items") } async fn read(&self, state: Arc) -> crate::error::Result { let debts = state .code .tech_debts .cache .read() .unwrap_or_else(|e| e.into_inner()); let data: Vec<_> = debts.iter().filter(|d| !d.is_resolved).collect(); Ok(serde_json::to_string_pretty(&data)?) } } struct AnalyzeTechDebtPrompt; #[async_trait] impl McpPrompt for AnalyzeTechDebtPrompt { fn name(&self) -> &'static str { "analyze_tech_debt" } fn description(&self) -> Option<&'static str> { Some("Analyze the project's current technical debt") } async fn get( &self, _args: Value, _state: Arc, ) -> crate::error::Result { Ok(serde_json::json!({ "messages": [ { "role": "user", "content": { "type": "text", "text": "Please read the current technical debt from the knowledge graph (or use the list_tech_debt tool) and provide a prioritization plan for addressing it." } } ] })) } } struct SummarizeArchitecturePrompt; #[async_trait] impl McpPrompt for SummarizeArchitecturePrompt { fn name(&self) -> &'static str { "summarize_architecture" } fn description(&self) -> Option<&'static str> { Some("Summarize project architecture using active ADRs and knowledge graph entities") } async fn get( &self, _args: Value, _state: Arc, ) -> crate::error::Result { Ok(serde_json::json!({ "messages": [ { "role": "user", "content": { "type": "text", "text": "Please read active decisions from memory://decisions/active and graph entities from memory://graph/entities, and synthesize a clean architectural overview report." } } ] })) } } struct SessionDeltaResource; #[async_trait] impl McpResource for SessionDeltaResource { fn uri(&self) -> &'static str { "memory://session/delta" } fn name(&self) -> &'static str { "Session Delta" } fn description(&self) -> Option<&'static str> { Some("Recent changes, active tasks, code edits, and notes created in the current session") } async fn read(&self, state: Arc) -> crate::error::Result { let now = crate::handlers::utils::now_secs(); let lookback = 7200; // last 2 hours let cutoff = now.saturating_sub(lookback); let changes = state .code .ledger .cache .read() .unwrap_or_else(|e| e.into_inner()); let recent_changes: Vec<_> = changes.iter().filter(|c| c.timestamp >= cutoff).collect(); let tasks = state .project .tasks .cache .read() .unwrap_or_else(|e| e.into_inner()); let active_tasks: Vec<_> = tasks.iter().filter(|t| t.is_active()).collect(); let delta = serde_json::json!({ "recent_changes": recent_changes, "active_tasks": active_tasks, "timestamp": now, }); Ok(serde_json::to_string_pretty(&delta)?) } } pub struct ContextWarmupPrompt; #[async_trait] impl McpPrompt for ContextWarmupPrompt { fn name(&self) -> &'static str { "context_warmup" } fn description(&self) -> Option<&'static str> { Some( "Consolidated context warmup prompt providing active tasks, active decisions, and unresolved tech debt", ) } async fn get( &self, _args: Value, _state: Arc, ) -> crate::error::Result { Ok(serde_json::json!({ "messages": [ { "role": "user", "content": { "type": "text", "text": "Perform a complete context warmup. Please read memory://session/delta, memory://tasks/active, memory://decisions/active, and memory://tech_debt/unresolved to synthesize current project focus and active workspace constraints." } } ] })) } } pub struct MemoryHandler { pub state: Arc, pub tools: std::collections::HashMap>, pub resources: std::collections::HashMap>, pub prompts: std::collections::HashMap>, } impl MemoryHandler { pub fn new(state: Arc) -> Self { let mut tools: std::collections::HashMap> = std::collections::HashMap::new(); let mut resources: std::collections::HashMap> = std::collections::HashMap::new(); let mut prompts: std::collections::HashMap> = std::collections::HashMap::new(); macro_rules! register_resource { ($handler:ident) => { let h = $handler; resources.insert(h.uri().to_string(), Box::new(h)); }; } macro_rules! register_prompt { ($handler:ident) => { let h = $handler; prompts.insert(h.name().to_string(), Box::new(h)); }; } register_resource!(GraphEntitiesResource); register_resource!(GraphRelationsResource); register_resource!(TasksActiveResource); register_resource!(DecisionsActiveResource); register_resource!(TechDebtActiveResource); register_resource!(SessionDeltaResource); register_prompt!(AnalyzeTechDebtPrompt); register_prompt!(SummarizeArchitecturePrompt); register_prompt!(ContextWarmupPrompt); struct TerminalHistoryResource; #[async_trait] impl McpResource for TerminalHistoryResource { fn uri(&self) -> &'static str { "memory://terminal/recent" } fn name(&self) -> &'static str { "Terminal History" } fn description(&self) -> Option<&'static str> { Some("Recent terminal execution history and exit codes") } async fn read(&self, state: Arc) -> crate::error::Result { let items = state .telemetry .terminal_history .cache .read() .unwrap_or_else(|e| e.into_inner()); Ok(serde_json::to_string_pretty(&*items)?) } } struct RecentActivitiesResource; #[async_trait] impl McpResource for RecentActivitiesResource { fn uri(&self) -> &'static str { "memory://activity/recent" } fn name(&self) -> &'static str { "Recent Activity" } fn description(&self) -> Option<&'static str> { Some("Recent IDE and developer activity logs") } async fn read(&self, state: Arc) -> crate::error::Result { let items = state .telemetry .recent_activities .cache .read() .unwrap_or_else(|e| e.into_inner()); Ok(serde_json::to_string_pretty(&*items)?) } } struct MilestonesResource; #[async_trait] impl McpResource for MilestonesResource { fn uri(&self) -> &'static str { "memory://milestones" } fn name(&self) -> &'static str { "Milestones" } fn description(&self) -> Option<&'static str> { Some("Project milestones and their status") } async fn read(&self, state: Arc) -> crate::error::Result { let items = state .project .milestones .cache .read() .unwrap_or_else(|e| e.into_inner()); Ok(serde_json::to_string_pretty(&*items)?) } } struct HandoffRoutinePrompt; #[async_trait] impl McpPrompt for HandoffRoutinePrompt { fn name(&self) -> &'static str { "handoff_routine" } fn description(&self) -> Option<&'static str> { Some("Initiate the end-of-session handoff and standup report generation") } async fn get( &self, _args: Value, _state: Arc, ) -> crate::error::Result { Ok(serde_json::json!({ "messages": [ { "role": "user", "content": { "type": "text", "text": "I am logging off. Please invoke the DevOpsSRE subagent to generate a standup report and leave a handoff memo for the next session. Make sure to check active tasks and recent code changes." } } ] })) } } register_resource!(TerminalHistoryResource); register_resource!(RecentActivitiesResource); register_resource!(MilestonesResource); struct ArchiveRoutinePrompt; #[async_trait] impl McpPrompt for ArchiveRoutinePrompt { fn name(&self) -> &'static str { "archive_routine" } fn description(&self) -> Option<&'static str> { Some("Compress old session summaries into a milestone retrospective") } async fn get( &self, _args: Value, _state: Arc, ) -> crate::error::Result { Ok(serde_json::json!({" messages": [ { "role": "user", "content": { "type": "text", "text": "Please read the old session summaries, synthesize them into a dense Milestone Retrospective entity, and then use the appropriate tools to delete the old session summaries." } } ] })) } } register_prompt!(HandoffRoutinePrompt); register_prompt!(ArchiveRoutinePrompt); macro_rules! register { ($module:ident::$handler:ident) => { let h = crate::handlers::$module::$handler; tools.insert(h.name().to_string(), Box::new(h)); }; } register!(graph::QueryGraphPathHandler); register!(graph::CreateEntitiesHandler); register!(graph::CreateRelationsHandler); register!(graph::AddObservationsHandler); register!(graph::DeleteEntitiesHandler); register!(graph::DeleteObservationsHandler); register!(graph::DeleteRelationsHandler); register!(graph::ReadGraphHandler); register!(graph::SearchNodesHandler); register!(graph::OpenNodesHandler); register!(graph::VisualizeGraphHandler); register!(graph::CondenseEntityHandler); register!(graph::MergeEntitiesHandler); register!(graph::FindOrphansHandler); register!(tasks::TasksHandler); register!(tasks::MilestonesHandler); register!(notes::HandoffMemosHandler); register!(notes::AddSessionSummaryHandler); register!(notes::GenerateStandupReportHandler); register!(notes::PromoteToEntityHandler); register!(meta::DecisionsHandler); register!(meta::TechDebtHandler); register!(meta::LogErrorFixHandler); register!(meta::SearchErrorFixesHandler); register!(meta::LogCodeChangeHandler); register!(meta::QueryRecentChangesHandler); register!(meta::OmniSearchHandler); register!(meta::GetProjectHealthHandler); register!(env::EnvironmentHandler); register!(workspaces::SnippetsHandler); register!(vision::ClipboardHandler); register!(git::GetActiveWorktreeContextHandler); register!(git::QueryGitDiffsHandler); register!(logs::ProcessLogsHandler); register!(ast::ReadFileSkeletonHandler); register!(ast::ReplaceAstNodeHandler); register!(ast::FindSymbolReferencesHandler); register!(ast::GetCallersHandler); register!(ast::AnalyzeImpactHandler); register!(workspaces::ReadDirectoryArchitectureHandler); register!(workspaces::SemanticCodeSearchHandler); register!(workspaces::ManageSubagentNamespaceHandler); register!(graph::GetSubgraphHandler); register!(meta::ManageCheckpointHandler); register!(graph::SweepGraphHealthHandler); register!(meta::QueryLineageHandler); register!(meta::GetNextActionableTasksHandler); register!(meta::HypothesesHandler); register!(meta::GetPreflightContextHandler); register!(graph::ResolveStaleSymbolsHandler); register!(graph::SummarizeSubgraphHandler); register!(meta::AgentSignalsHandler); register!(meta::AutoSessionCheckpointHandler); Self { state, tools, resources, prompts, } } pub async fn handle_request(&self, req: serde_json::Value) -> Option { let id = req.get("id").cloned().unwrap_or(serde_json::Value::Null); let id_clone = id.clone(); let method = req.get("method").and_then(|m| m.as_str()).unwrap_or(""); match method { "server/discover" => { let payload = serde_json::json!({ "resultType": "complete", "ttlMs": 0, "cacheScope": "public", "supportedVersions": ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"], "capabilities": { "tools": serde_json::json!({}), "resources": serde_json::json!({}), "prompts": serde_json::json!({}) }, "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "gemini-mcp-memory", "version": "3.0.0" } } }); Some(crate::mcp::success(id, payload)) } "initialize" => { let init = rmcp::model::InitializeResult::new( rmcp::model::ServerCapabilities::builder() .enable_tools() .enable_resources() .enable_prompts() .build(), ) .with_server_info(rmcp::model::Implementation::new( "gemini-mcp-memory", "3.0.0", )) .with_instructions(include_str!("instructions.md")); Some(crate::mcp::success( id, serde_json::to_value(&init).unwrap_or_default(), )) } "notifications/initialized" => None, "tools/list" => { let mut tools: Vec = self.tools.values().map(|t| t.schema()).collect(); tools.sort_by_key(|t| { t.get("name") .and_then(|n| n.as_str()) .unwrap_or("") .to_string() }); Some(crate::mcp::success( id, serde_json::json!({ "tools": tools }), )) } "resources/list" => { let resources: Vec<_> = self .resources .values() .map(|r| { let mut obj = serde_json::json!({ "uri": r.uri(), "name": r.name(), }); if let Some(desc) = r.description() { obj["description"] = serde_json::json!(desc); } if let Some(mime) = r.mime_type() { obj["mimeType"] = serde_json::json!(mime); } obj }) .collect(); let payload = serde_json::json!({ "resources": resources }); Some(crate::mcp::success(id, payload)) } "resources/templates/list" => { let payload = serde_json::json!({ "resourceTemplates": [] }); Some(crate::mcp::success(id, payload)) } "resources/read" => { let params = req.get("params").unwrap_or(&serde_json::Value::Null); let uri = params.get("uri").and_then(|u| u.as_str()).unwrap_or(""); if let Some(resource) = self.resources.get(uri) { match resource.read(Arc::clone(&self.state)).await { Ok(text) => { let payload = serde_json::json!({ "contents": [{ "uri": uri, "mimeType": resource.mime_type().unwrap_or("application/json"), "text": text }] }); Some(crate::mcp::success(id, payload)) } Err(e) => Some(crate::mcp::error(id, -32603, &e.to_string())), } } else { Some(crate::mcp::error(id, -32602, "Resource not found")) } } "prompts/list" => { let prompts: Vec<_> = self .prompts .values() .map(|p| { let mut obj = serde_json::json!({ "name": p.name(), "arguments": p.arguments(), }); if let Some(desc) = p.description() { obj["description"] = serde_json::json!(desc); } obj }) .collect(); let payload = serde_json::json!({ "prompts": prompts }); Some(crate::mcp::success(id, payload)) } "prompts/get" => { let params = req.get("params").unwrap_or(&serde_json::Value::Null); let name = params.get("name").and_then(|n| n.as_str()).unwrap_or(""); let args = params .get("arguments") .cloned() .unwrap_or_else(|| serde_json::json!({})); if let Some(prompt) = self.prompts.get(name) { match prompt.get(args, Arc::clone(&self.state)).await { Ok(messages) => Some(crate::mcp::success(id, messages)), Err(e) => Some(crate::mcp::error(id, -32603, &e.to_string())), } } else { Some(crate::mcp::error(id, -32602, "Prompt not found")) } } "tools/call" => { let params = req.get("params").unwrap_or(&serde_json::Value::Null); let name = params.get("name").and_then(|n| n.as_str()).unwrap_or(""); let args = params .get("arguments") .cloned() .unwrap_or(serde_json::Value::Object(Default::default())); let category = match name { "clipboard" => "CLIPBOARD", "create_entities" | "create_relations" | "add_observations" | "delete_entities" | "delete_observations" | "delete_relations" | "read_graph" | "get_subgraph" | "search_graph" | "get_schema" | "search_nodes" | "open_nodes" | "visualize_graph" | "condense_entity" | "merge_entities" | "find_orphans" | "sweep_graph_health" | "summarize_subgraph" | "query_graph_path" | "resolve_stale_symbols" => "GRAPH", "decisions" => "DECISION", "log_code_change" => "CODE", "log_error_fix" => "ERROR_FIX", "tech_debt" => "TECH_DEBT", "tasks" | "milestones" => "TASK", "handoff_memos" => "HANDOFF_MEMO", "manage_checkpoint" => "CHECKPOINT", "manage_subagent_namespace" => "SUBAGENT", "snippets" => "SNIPPET", "search_web" => "WEB_SEARCH", "omni_search" => "OMNI_SEARCH", "environment" => "ENVIRONMENT", "hypotheses" => "HYPOTHESIS", "agent_signals" => "AGENT_SIGNAL", "process_logs" => "PROCESS_LOG", _ => "TOOL", }; let activity_msg = format_tool_activity_description(name, &args); self.state.broadcast_activity(category, &activity_msg); let result: crate::error::Result = if let Some(tool) = self.tools.get(name) { execute_tool_span(name, tool.as_ref(), args, self.state.clone()).await } else { Err(crate::error::AppError::Internal(format!( "Unknown tool: {}", name ))) }; match result { Ok(text) => { let payload = serde_json::json!({ "content": [{"type": "text", "text": text}], "isError": false }); Some(crate::mcp::success(id_clone, payload)) } Err(e) => { tracing::error!("Tool {} failed: {}", name, e); let payload = serde_json::json!({ "content": [{"type": "text", "text": e.to_string()}], "isError": true }); Some(crate::mcp::success(id_clone, payload)) } } } m if m.starts_with("notifications/") => None, "ping" => Some(crate::mcp::success(id, serde_json::json!({}))), _ => { if id.is_null() { None } else { Some(crate::mcp::error( id, -32601, &format!("Method {} not found", method), )) } } } } } pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) -> String { let (action, detail) = match name { "tasks" => { let act = args .get("action") .and_then(|v| v.as_str()) .unwrap_or("manage"); let title = args .get("title") .or_else(|| args.get("id")) .and_then(|v| v.as_str()) .unwrap_or(""); ( "Tasks", format!("{}: {}", act, title) .trim_end_matches(": ") .to_string(), ) } "decisions" => { let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("log"); let title = args .get("title") .or_else(|| args.get("query")) .and_then(|v| v.as_str()) .unwrap_or(""); ( "Decisions", format!("{}: {}", act, title) .trim_end_matches(": ") .to_string(), ) } "tech_debt" => { let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("log"); let desc = args .get("description") .or_else(|| args.get("id")) .and_then(|v| v.as_str()) .unwrap_or(""); ( "Tech Debt", format!("{}: {}", act, desc) .trim_end_matches(": ") .to_string(), ) } "create_entities" => { let names = args .get("entities") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|e| e.get("name").and_then(|n| n.as_str())) .collect::>() .join(", ") }) .unwrap_or_default(); ("Create Entities", names) } "hypotheses" => { let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("manage"); let hyp = args .get("hypothesis") .or_else(|| args.get("query")) .and_then(|v| v.as_str()) .unwrap_or(""); ( "Hypotheses", format!("{}: {}", act, hyp).trim_end_matches(": ").to_string(), ) } "agent_signals" => { let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("signal"); let sender = args.get("sender").and_then(|v| v.as_str()).unwrap_or(""); let st = args.get("signal_type").and_then(|v| v.as_str()).unwrap_or(""); ( "Agent Signals", format!("{}: {} [{}]", act, sender, st).trim_end_matches(": ").to_string(), ) } "process_logs" => { let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("logs"); let file = args.get("file_path").and_then(|v| v.as_str()).unwrap_or(""); ( "Process Logs", format!("{}: {}", act, file).trim_end_matches(": ").to_string(), ) } _ => return name.to_string(), }; if detail.is_empty() { action.to_string() } else { format!("{}: {}", action, detail) } } #[tracing::instrument(name = "mcp_tool_execution", skip(tool, args, state), fields(tool_name = %name))] async fn execute_tool_span( name: &str, tool: &dyn McpTool, args: Value, state: Arc, ) -> crate::error::Result { tool.execute(args, state).await } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; use serde_json::json; #[test] fn test_format_tool_activity_description() { let msg = format_tool_activity_description( "tasks", &serde_json::json!({ "action": "add", "title": "Refactor router" }), ); assert_eq!(msg, "Tasks: add: Refactor router"); let msg2 = format_tool_activity_description( "create_entities", &serde_json::json!({ "entities": [{ "name": "EntityA" }, { "name": "EntityB" }] }), ); assert_eq!(msg2, "Create Entities: EntityA, EntityB"); let msg3 = format_tool_activity_description("read_graph", &json!({})); assert_eq!(msg3, "read_graph"); } #[tokio::test] async fn test_memory_handler_tools_registration() { let dir = tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); let handler = MemoryHandler::new(state); // Assert some known tools are registered assert!(handler.tools.contains_key("create_entities")); assert!(handler.tools.contains_key("tasks")); // Ensure we can fetch list of tools let list_tools_req = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }); let res_list = handler.handle_request(list_tools_req).await.unwrap(); assert_eq!(res_list["jsonrpc"], "2.0"); assert_eq!(res_list["id"], 1); assert!(res_list["result"]["tools"].as_array().unwrap().len() > 10); } #[tokio::test] async fn test_resources_and_prompts_endpoints() { let dir = tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); let handler = MemoryHandler::new(state); // Test resources/list let req_list_res = json!({ "jsonrpc": "2.0", "id": 10, "method": "resources/list", "params": {} }); let res_list = handler.handle_request(req_list_res).await.unwrap(); let resources_arr = res_list["result"]["resources"].as_array().unwrap(); assert!( resources_arr .iter() .any(|r| r["uri"] == "memory://tasks/active") ); assert!( resources_arr .iter() .any(|r| r["uri"] == "memory://decisions/active") ); assert!( resources_arr .iter() .any(|r| r["uri"] == "memory://activity/recent") ); // Test resources/read let req_read_res = json!({ "jsonrpc": "2.0", "id": 11, "method": "resources/read", "params": { "uri": "memory://tasks/active" } }); let res_read = handler.handle_request(req_read_res).await.unwrap(); assert_eq!( res_read["result"]["contents"][0]["uri"], "memory://tasks/active" ); assert!( res_read["result"]["contents"][0]["text"] .as_str() .unwrap() .contains("[]") ); // Empty tasks // Test prompts/list let req_list_prompts = json!({ "jsonrpc": "2.0", "id": 12, "method": "prompts/list", "params": {} }); let res_prompts = handler.handle_request(req_list_prompts).await.unwrap(); let prompts_arr = res_prompts["result"]["prompts"].as_array().unwrap(); assert!(prompts_arr.iter().any(|p| p["name"] == "handoff_routine")); // Test prompts/get let req_get_prompt = json!({ "jsonrpc": "2.0", "id": 13, "method": "prompts/get", "params": { "name": "handoff_routine", "arguments": {} } }); let res_get = handler.handle_request(req_get_prompt).await.unwrap(); let messages = res_get["result"]["messages"].as_array().unwrap(); assert_eq!(messages[0]["role"], "user"); assert!( messages[0]["content"]["text"] .as_str() .unwrap() .contains("standup report") ); } #[tokio::test] async fn test_tool_call_success_and_error_responses() { let dir = tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); let handler = MemoryHandler::new(state); // 1. Test successful tool call (e.g. read_graph) let req_success = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "read_graph", "arguments": {} } }); let res_success = handler.handle_request(req_success).await.unwrap(); assert_eq!(res_success["jsonrpc"], "2.0"); assert_eq!(res_success["id"], 2); // A successful tool call should return a result with isError: false assert_eq!(res_success["result"]["isError"], false); assert!(res_success["result"]["content"].as_array().is_some()); // 2. Test tool call semantic failure (e.g. updating a task that does not exist) let req_fail = json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "tasks", "arguments": { "action": "update", "id": "nonexistent_task_123", "status": "completed" } } }); let res_fail = handler.handle_request(req_fail).await.unwrap(); assert_eq!(res_fail["jsonrpc"], "2.0"); assert_eq!(res_fail["id"], 3); // Semantic failures must explicitly return isError: true inside the result to halt the LLM assert_eq!(res_fail["result"]["isError"], true); assert!( res_fail["result"]["content"][0]["text"] .as_str() .unwrap() .contains("not found") ); // 3. Test unknown JSON-RPC method returns JSON-RPC protocol error let req_unknown = json!({ "jsonrpc": "2.0", "id": 4, "method": "unknown_method_xyz" }); let res_unknown = handler.handle_request(req_unknown).await.unwrap(); assert!(res_unknown.get("error").is_some()); assert_eq!(res_unknown["error"]["code"], -32601); } #[tokio::test] async fn test_handle_request() { let dir = tempfile::tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); let handler = MemoryHandler::new(state); let req1 = serde_json::json!({ "jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": { "name": "create_entities", "arguments": { "entities": [{ "name": "EntityA" }, { "name": "EntityB" }] } } }); let res1 = handler.handle_request(req1).await.unwrap(); assert_eq!(res1["jsonrpc"], "2.0"); assert_eq!(res1["id"], 5); let req2 = serde_json::json!({ "jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": { "name": "read_graph", "arguments": {} } }); let res2 = handler.handle_request(req2).await.unwrap(); assert_eq!(res2["jsonrpc"], "2.0"); assert_eq!(res2["id"], 6); } #[tokio::test] async fn test_tools_list_schema_and_descriptions() { let dir = tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); let handler = MemoryHandler::new(state); let list_req = json!({ "jsonrpc": "2.0", "id": 100, "method": "tools/list", "params": {} }); let res = handler.handle_request(list_req).await.unwrap(); assert_eq!(res["jsonrpc"], "2.0"); assert_eq!(res["id"], 100); let tools = res["result"]["tools"] .as_array() .expect("tools array expected"); assert!( tools.len() >= 30, "Expected at least 30 registered MCP tools, found {}", tools.len() ); for tool in tools { let name = tool["name"].as_str().expect("tool name must be string"); assert!(!name.is_empty(), "Tool name cannot be empty"); let desc = tool["description"] .as_str() .expect("tool description must be string"); assert!(!desc.is_empty(), "Tool '{}' missing description", name); let schema = tool["inputSchema"] .as_object() .expect("inputSchema must be object"); assert!( schema.contains_key("properties") || schema.contains_key("type"), "Tool '{}' schema must contain type/properties metadata", name ); } } #[tokio::test] async fn test_all_tools_llm_request_ok_and_invalid() { let dir = tempdir().unwrap(); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); let handler = MemoryHandler::new(state); // 1. Verify invalid LLM requests fail gracefully with isError: true or structured response for (tool_name, _tool) in &handler.tools { let invalid_req = json!({ "jsonrpc": "2.0", "id": 200, "method": "tools/call", "params": { "name": tool_name, "arguments": { "invalid_field_unlikely_to_match": "bogus_value", "action": "non_existent_action_xyz_999" } } }); let res = handler .handle_request(invalid_req) .await .expect("handler must respond"); assert_eq!(res["jsonrpc"], "2.0"); assert!(res["result"]["content"].is_array()); } // 2. Verify valid LLM requests return isError: false let sample_valid_calls = vec![ ("read_graph", json!({})), ("search_nodes", json!({"query": "test"})), ("tasks", json!({"action": "list"})), ("decisions", json!({"action": "query"})), ("tech_debt", json!({"action": "list"})), ("snippets", json!({"action": "search", "query": "test"})), ("clipboard", json!({"action": "read"})), ("environment", json!({"action": "read_fingerprint"})), ("omni_search", json!({"query": "test"})), ("get_project_health", json!({})), ("find_orphans", json!({})), ("sweep_graph_health", json!({})), ("get_next_actionable_tasks", json!({})), ("get_preflight_context", json!({})), ( "auto_session_checkpoint", json!({"session_summary": "Test summary"}), ), ]; for (name, args) in sample_valid_calls { let valid_req = json!({ "jsonrpc": "2.0", "id": 201, "method": "tools/call", "params": { "name": name, "arguments": args } }); let res = handler .handle_request(valid_req) .await .expect("handler must respond"); assert_eq!(res["jsonrpc"], "2.0"); assert_eq!( res["result"]["isError"], false, "Tool '{}' failed valid execution. Result: {:?}", name, res ); } } }