From d71b1e6e35d867c301744f95e926a9d22392dc16 Mon Sep 17 00:00:00 2001 From: Riz Ashraf Date: Sun, 13 Sep 2026 18:03:27 +0100 Subject: [PATCH] feat: Replace python tests with native E2E test suite and fix capabilities schema - Added native E2E test suite in stub to validate server<->stub<->nvim IPC - Added win-nvim integration tests - Fixed empty capabilities schema in initialize response in server and win-nvim - Refactored server to accept dynamic MCP_PORT for test isolation - Made server exit gracefully on port 3000 collision --- Cargo.lock | 1 + README.md | 19 ++-- deploy.ps1 | 4 + gemini-ui.lua | 13 ++- server/src/handlers.rs | 8 +- server/src/main.rs | 60 ++----------- stub/Cargo.toml | 3 + stub/tests/e2e.rs | 134 +++++++++++++++++++++++++++++ win-nvim/src/main.rs | 38 +++++--- win-nvim/src/mcp.rs | 15 +++- win-nvim/tests/integration_test.rs | 111 ++++++++++++++++++++++++ 11 files changed, 327 insertions(+), 79 deletions(-) create mode 100644 deploy.ps1 create mode 100644 stub/tests/e2e.rs create mode 100644 win-nvim/tests/integration_test.rs diff --git a/Cargo.lock b/Cargo.lock index dc36836..1de0350 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1318,6 +1318,7 @@ dependencies = [ "dirs 7.0.0", "futures-util", "reqwest", + "serde_json", "tokio", "tokio-tungstenite 0.21.0", "tokio-util", diff --git a/README.md b/README.md index 7226295..6703ab9 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,12 @@ Copy-Item target\release\mcp-memory-stub.exe C:\Users\reazul.ashraf\.local\bin\m if (-not (Get-Process mcp-memory-server -ErrorAction SilentlyContinue)) { Start-Process -FilePath "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" -ArgumentList "--daemon" -WindowStyle Hidden -ErrorAction SilentlyContinue } ``` -**Shutting Down:** If you need to stop the background daemon (e.g., to replace the executable after a recompile), use the built-in `--exit` flag instead of forcefully killing the process. +**Shutting Down:** If you need to stop or restart the background daemon (e.g., to replace the executable after a recompile), you MUST NEVER use brute-force OS kill commands (e.g., Stop-Process, pkill, or kill). Instead, you MUST ALWAYS use the server's built-in graceful shutdown mechanisms: +1. The CLI flag: `--exit` (or `--restart`) ```powershell mcp-memory-server.exe --exit ``` +2. The HTTP endpoint: `POST http://127.0.0.1:3000/shutdown` ` **Step 2:** Update your Windows ~/.gemini/config/mcp_config.json to point the CLI to the ultra-lightweight stub (since the server is already running in the background): `json @@ -99,13 +101,8 @@ Whenever you alt-tab into a Neovim window (FocusGained) or switch files (BufEnte ### Neovim MCP Tools The LLM agent interacts with your active Neovim session using a dedicated set of MCP tools. *(Note: /nvim/telemetry is strictly a one-way webhook for Neovim; the LLM uses the tools below to interact).* -* ** -vim_goto_line**: Open files and jump cursors directly from the LLM. -* ** -vim_set_diagnostics**: Push inline code review warnings as virtual text. -* ** -vim_get_active_buffer**: Read live, unsaved buffer contents. -* ** -vim_get_cursor**: Fetch precise line/column coordinates. -* ** -vim_get_visual_selection**: Read highlighted code blocks. +* **vim_goto_line**: Open files and jump cursors directly from the LLM. +* **vim_set_diagnostics**: Push inline code review warnings as virtual text. +* **vim_get_active_buffer**: Read live, unsaved buffer contents. +* **vim_get_cursor**: Fetch precise line/column coordinates. +* **vim_get_visual_selection**: Read highlighted code blocks. diff --git a/deploy.ps1 b/deploy.ps1 new file mode 100644 index 0000000..af98c4d --- /dev/null +++ b/deploy.ps1 @@ -0,0 +1,4 @@ +Get-Process mcp-memory-* -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep -Seconds 2 +foreach ($b in @('mcp-memory-server.exe', 'mcp-memory-stub.exe', 'mcp-memory-win-nvim.exe')) { $s = 'target\release\' + $b; $d = 'C:\Users\reazul.ashraf\.local\bin\' + $b; if (Test-Path $d) { Remove-Item ($d + '.bak') -Force -ErrorAction SilentlyContinue; Rename-Item $d ($b + '.bak') -Force -ErrorAction SilentlyContinue }; Copy-Item $s $d -Force } +& 'C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe' --daemon diff --git a/gemini-ui.lua b/gemini-ui.lua index 7b08d06..05208ab 100644 --- a/gemini-ui.lua +++ b/gemini-ui.lua @@ -60,6 +60,10 @@ local function execute_agent(prompt) M.show_progress("Starting Antigravity...") + -- termopen requires an empty and unmodified buffer + vim.api.nvim_buf_set_lines(progress_buf, 0, -1, false, {}) + vim.bo[progress_buf].modified = false + -- termopen will attach to the current buffer (which show_progress just focused) job_id = vim.fn.termopen(cmd, { on_exit = function(_, code) @@ -70,9 +74,16 @@ local function execute_agent(prompt) end function M.ask_agent() + local file = vim.api.nvim_buf_get_name(0) vim.ui.input({ prompt = 'Ask Agent: ' }, function(input) if not input or input == '' then return end - execute_agent(input) + + local final_input = input + if file and file ~= "" then + final_input = input .. string.format(" [Context file: %s]", file) + end + + execute_agent(final_input) end) end diff --git a/server/src/handlers.rs b/server/src/handlers.rs index 83eb893..97f347b 100644 --- a/server/src/handlers.rs +++ b/server/src/handlers.rs @@ -26,7 +26,9 @@ impl MemoryHandler { id, serde_json::json!({ "protocolVersion": "2024-11-05", - "capabilities": {}, + "capabilities": { + "tools": {} + }, "serverInfo": { "name": "gemini-mcp-memory", "version": "3.0.0" @@ -1607,3 +1609,7 @@ crate::mcp::tool_def::("create_entities", "Create new entiti } } } + + + + diff --git a/server/src/main.rs b/server/src/main.rs index d4ca0c2..435c4fa 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -449,56 +449,11 @@ fn run_server(state: Arc) -> Result<(), Box> ) .with_state(app_state); - let mut retries = 0; - let listener = loop { - match tokio::net::TcpListener::bind("127.0.0.1:3000").await { - Ok(l) => break l, - Err(e) => { - // Check if it's already running and healthy - if let Ok(mut stream) = std::net::TcpStream::connect("127.0.0.1:3000") { - use std::io::{Read, Write}; - let _ = stream.write_all( - b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", - ); - let mut response = String::new(); - let _ = stream.read_to_string(&mut response); - if response.contains("200 OK") { - // Already healthy! Just exit cleanly instead of panicking/retrying loop. - std::process::exit(0); - } - } - - retries += 1; - if retries > 15 { - let log_path = dirs::home_dir() - .unwrap_or_default() - .join(".gemini/mcp_memory/daemon_fatal.log"); - let _ = std::fs::write( - &log_path, - format!( - "FATAL: Could not bind to 127.0.0.1:3000 after 15 seconds: {}\n", - e - ), - ); - std::process::exit(1); - } - let log_path = dirs::home_dir() - .unwrap_or_default() - .join(".gemini/mcp_memory/daemon_error.log"); - if let Ok(mut file) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - { - use std::io::Write; - let _ = writeln!( - file, - "Failed to bind to 127.0.0.1:3000 (attempt {}): {}. Retrying in 1s...", - retries, e - ); - } - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - } + let listener = match tokio::net::TcpListener::bind(std::env::var("MCP_PORT").map(|p| format!("127.0.0.1:{}", p)).unwrap_or_else(|_| std::env::var("MCP_PORT").map(|p| format!("127.0.0.1:{}", p)).unwrap_or_else(|_| "127.0.0.1:3000".to_string()).to_string())).await { + Ok(l) => l, + Err(e) => { + tracing::info!("Port 3000 is already in use ({}). Assuming server is already running and exiting gracefully.", e); + std::process::exit(0); } }; @@ -705,7 +660,7 @@ fn main() -> Result<(), Box> { let cli = Cli::parse(); if cli.exit { - if let Ok(mut stream) = std::net::TcpStream::connect("127.0.0.1:3000") { + if let Ok(mut stream) = std::net::TcpStream::connect(std::env::var("MCP_PORT").map(|p| format!("127.0.0.1:{}", p)).unwrap_or_else(|_| "127.0.0.1:3000".to_string())) { use std::io::Write; let _ = stream.write_all( b"POST /shutdown HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", @@ -716,7 +671,7 @@ fn main() -> Result<(), Box> { } if cli.restart { - if let Ok(mut stream) = std::net::TcpStream::connect("127.0.0.1:3000") { + if let Ok(mut stream) = std::net::TcpStream::connect(std::env::var("MCP_PORT").map(|p| format!("127.0.0.1:{}", p)).unwrap_or_else(|_| "127.0.0.1:3000".to_string())) { use std::io::Write; let _ = stream.write_all( b"POST /shutdown HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", @@ -842,3 +797,4 @@ fn main() -> Result<(), Box> { + diff --git a/stub/Cargo.toml b/stub/Cargo.toml index f80cd56..0956de4 100644 --- a/stub/Cargo.toml +++ b/stub/Cargo.toml @@ -14,3 +14,6 @@ tracing-appender = "0.2.5" tracing = "0.1.44" tracing-subscriber = "0.3.23" dirs = "7.0.0" + +[dev-dependencies] +serde_json = "1.0.151" diff --git a/stub/tests/e2e.rs b/stub/tests/e2e.rs new file mode 100644 index 0000000..26d7cd2 --- /dev/null +++ b/stub/tests/e2e.rs @@ -0,0 +1,134 @@ +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Command, Stdio}; +use std::time::Duration; +use std::sync::Arc; +use tokio::sync::Mutex; + +fn send_message(stdin: &mut std::process::ChildStdin, msg: Value) { + let s = serde_json::to_string(&msg).unwrap(); + let payload = format!("Content-Length: {}\r\n\r\n{}", s.len(), s); + stdin.write_all(payload.as_bytes()).unwrap(); + stdin.flush().unwrap(); +} + +fn read_message(stdout: &mut std::process::ChildStdout) -> Option { + let mut reader = BufReader::new(stdout); + let mut length = 0; + + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + return None; + } + let line = line.trim_end(); + if line.is_empty() { + break; + } + if let Some(len_str) = line.strip_prefix("Content-Length: ") { + length = len_str.parse().unwrap_or(0); + } + } + + if length == 0 { + return None; + } + + let mut buf = vec![0u8; length]; + reader.read_exact(&mut buf).unwrap(); + let body_str = String::from_utf8_lossy(&buf); + + Some(serde_json::from_str(&body_str).unwrap()) +} + +#[tokio::test] +async fn test_full_system_e2e_performance() { + let temp_dir = std::env::temp_dir().join(format!("mcp_e2e_{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs())); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let test_port = "3031"; // Use a distinct port + + let mut server_exe = std::env::current_exe().unwrap(); + server_exe.pop(); + server_exe.pop(); + server_exe.push("mcp-memory-server.exe"); + + let mut nvim_exe = server_exe.clone(); + nvim_exe.pop(); + nvim_exe.push("mcp-memory-win-nvim.exe"); + + // 1. Start Server + let mut server = Command::new(&server_exe) + .env("MCP_PORT", test_port) + .env("MCP_MEMORY_STORE_DIR", temp_dir.to_str().unwrap()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("Failed to start server"); + + tokio::time::sleep(Duration::from_secs(2)).await; + + // 2. Start Stub + let stub_exe = env!("CARGO_BIN_EXE_mcp-memory-stub"); + let mut stub = Command::new(stub_exe) + .arg("--target") + .arg(format!("http://127.0.0.1:{}", test_port)) + .env("MCP_MEMORY_STORE_DIR", temp_dir.to_str().unwrap()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("Failed to start stub"); + + let mut stub_stdin = stub.stdin.take().unwrap(); + let mut stub_stdout = stub.stdout.take().unwrap(); + + // 3. Start Win-Nvim + let mut win_nvim = Command::new(&nvim_exe) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("Failed to start win-nvim"); + + let mut nvim_stdin = win_nvim.stdin.take().unwrap(); + let mut nvim_stdout = win_nvim.stdout.take().unwrap(); + + // Send 100 concurrent-like sequential rapid requests to Stub + let start_time = std::time::Instant::now(); + for i in 1..=100 { + let tools_req = json!({ + "jsonrpc": "2.0", + "method": "tools/list", + "params": {}, + "id": i + }); + send_message(&mut stub_stdin, tools_req); + let resp = read_message(&mut stub_stdout).expect("Failed to read rapid response from stub"); + assert_eq!(resp["id"], i); + } + let stub_duration = start_time.elapsed(); + + // Send 100 concurrent-like sequential rapid requests to Win-Nvim + let start_time_nvim = std::time::Instant::now(); + for i in 1..=100 { + let tools_req = json!({ + "jsonrpc": "2.0", + "method": "tools/list", + "params": {}, + "id": i + }); + send_message(&mut nvim_stdin, tools_req); + let resp = read_message(&mut nvim_stdout).expect("Failed to read rapid response from win-nvim"); + assert_eq!(resp["id"], i); + } + let nvim_duration = start_time_nvim.elapsed(); + + println!("Stub 100 requests: {:?}", stub_duration); + println!("Win-Nvim 100 requests: {:?}", nvim_duration); + + // Cleanup + let _ = stub.kill(); + let _ = win_nvim.kill(); + let _ = server.kill(); +} diff --git a/win-nvim/src/main.rs b/win-nvim/src/main.rs index 84ceb58..01ef0a8 100644 --- a/win-nvim/src/main.rs +++ b/win-nvim/src/main.rs @@ -56,7 +56,10 @@ async fn main() { jsonrpc: "2.0".to_string(), id, result: Some(json!({ - "capabilities": {}, + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, "serverInfo": { "name": "mcp-memory-win-nvim", "version": "0.1.0" @@ -329,7 +332,27 @@ async fn main() { } async fn get_socket_path() -> Result { - // 1. Try auto-discovery in \\.\pipe\ + // 1. Primary: Use the active_nvim.txt which is updated by Neovim's BufEnter telemetry + let profile = std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\reazul.ashraf".into()); + let path = format!("{}\\.gemini\\active_nvim.txt", profile); + + if let Ok(content) = std::fs::read_to_string(&path) { + let p = content.trim().to_string(); + if !p.is_empty() { + // It might be a full pipe path or just the name. If it's just the name, prepend \\.\pipe\ + if p.starts_with(r"\\.\pipe\") { + return Ok(p); + } else if p.starts_with("nvim.") { + return Ok(format!(r"\\.\pipe\{}", p)); + } else { + // Some other servername format? Try it as is. + return Ok(p); + } + } + } + + // 2. Fallback to auto-discovery in \\.\pipe\ (only if single instance is running) + tracing::warn!("active_nvim.txt missing or invalid, falling back to pipe discovery"); if let Ok(dir) = std::fs::read_dir(r"\\.\pipe\") { for entry in dir.flatten() { let name = entry.file_name(); @@ -339,17 +362,6 @@ async fn get_socket_path() -> Result { } } } - - // 2. Fallback to active_nvim.txt - let profile = std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\reazul.ashraf".into()); - let path = format!("{}\\.gemini\\active_nvim.txt", profile); - - if let Ok(content) = std::fs::read_to_string(&path) { - let p = content.trim().to_string(); - if !p.is_empty() { - return Ok(p); - } - } Err("Could not find active Windows Neovim named pipe".to_string()) } diff --git a/win-nvim/src/mcp.rs b/win-nvim/src/mcp.rs index 4c33a65..f64d69d 100644 --- a/win-nvim/src/mcp.rs +++ b/win-nvim/src/mcp.rs @@ -40,7 +40,20 @@ pub async fn read_message(stdin: &mut BufReader) -> Option(&buffer) { + Ok(req) => Some(req), + Err(e) => { + let s = String::from_utf8_lossy(&buffer); + tracing::error!("Failed to parse JSON-RPC request: {}. Payload: {}", e, s); + Some(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: None, + method: "unknown_parse_error".to_string(), + params: None, + }) + } + } } pub async fn send_response(response: JsonRpcResponse) { diff --git a/win-nvim/tests/integration_test.rs b/win-nvim/tests/integration_test.rs new file mode 100644 index 0000000..8585387 --- /dev/null +++ b/win-nvim/tests/integration_test.rs @@ -0,0 +1,111 @@ +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Command, Stdio}; +use std::time::Duration; + +fn send_message(stdin: &mut std::process::ChildStdin, msg: Value) { + let s = serde_json::to_string(&msg).unwrap(); + let payload = format!("Content-Length: {}\r\n\r\n{}", s.len(), s); + stdin.write_all(payload.as_bytes()).unwrap(); + stdin.flush().unwrap(); +} + +fn read_message(stdout: &mut std::process::ChildStdout) -> Option { + let mut reader = BufReader::new(stdout); + let mut length = 0; + + // Read headers + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + return None; // EOF + } + let line = line.trim_end(); + if line.is_empty() { + break; + } + if let Some(len_str) = line.strip_prefix("Content-Length: ") { + length = len_str.parse().unwrap_or(0); + } + } + + if length == 0 { + return None; + } + + // Read body + let mut buf = vec![0u8; length]; + reader.read_exact(&mut buf).unwrap(); + let body_str = String::from_utf8_lossy(&buf); + + Some(serde_json::from_str(&body_str).unwrap()) +} + +#[test] +fn test_mcp_initialization_and_tools_list() { + // Determine the path to the built binary. + // cargo test automatically sets CARGO_BIN_EXE_ for binary targets! + let exe_path = env!("CARGO_BIN_EXE_mcp-memory-win-nvim"); + + let mut child = Command::new(exe_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Failed to start mcp-memory-win-nvim"); + + let mut stdin = child.stdin.take().expect("Failed to open stdin"); + let mut stdout = child.stdout.take().expect("Failed to open stdout"); + + // 1. Test Initialize + let init_req = json!({ + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0" + } + }, + "id": 1 + }); + + send_message(&mut stdin, init_req); + + let init_resp = read_message(&mut stdout).expect("Failed to read initialize response"); + + assert_eq!(init_resp["jsonrpc"], "2.0"); + assert_eq!(init_resp["id"], 1); + + // VERIFY CRITICAL FIX: Ensure capabilities.tools is present! + let capabilities = &init_resp["result"]["capabilities"]; + assert!(capabilities.get("tools").is_some(), "capabilities.tools MUST be present in initialize response to register with Antigravity proxy!"); + + // 2. Test tools/list + let tools_req = json!({ + "jsonrpc": "2.0", + "method": "tools/list", + "params": {}, + "id": 2 + }); + + send_message(&mut stdin, tools_req); + + let tools_resp = read_message(&mut stdout).expect("Failed to read tools/list response"); + + assert_eq!(tools_resp["jsonrpc"], "2.0"); + assert_eq!(tools_resp["id"], 2); + + let tools = tools_resp["result"]["tools"].as_array().expect("result.tools must be an array"); + assert!(!tools.is_empty(), "Server must expose at least one tool"); + + // Verify a specific tool exists + let has_get_active_buffer = tools.iter().any(|t| t["name"] == "nvim_get_active_buffer"); + assert!(has_get_active_buffer, "Missing nvim_get_active_buffer tool"); + + // Kill the child process cleanly + child.kill().expect("Failed to kill child"); + child.wait().expect("Failed to wait on child"); +}