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
This commit is contained in:
Riz Ashraf committed 2026-09-13 18:03:27 +01:00
1 parent f2582b58be
commit d71b1e6e35
11 files changed
+327 -79

No files matched your search

Generated
+1
View File
@@ -1318,6 +1318,7 @@ dependencies = [
"dirs 7.0.0", "dirs 7.0.0",
"futures-util", "futures-util",
"reqwest", "reqwest",
"serde_json",
"tokio", "tokio",
"tokio-tungstenite 0.21.0", "tokio-tungstenite 0.21.0",
"tokio-util", "tokio-util",
+8 -11
View File
@@ -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 } 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 ```powershell
mcp-memory-server.exe --exit 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): **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 `json
@@ -99,13 +101,8 @@ Whenever you alt-tab into a Neovim window (FocusGained) or switch files (BufEnte
### Neovim MCP Tools ### 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).* 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_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_set_diagnostics**: Push inline code review warnings as virtual text. * **vim_get_cursor**: Fetch precise line/column coordinates.
* ** * **vim_get_visual_selection**: Read highlighted code blocks.
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.
+4
View File
@@ -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
+12 -1
View File
@@ -60,6 +60,10 @@ local function execute_agent(prompt)
M.show_progress("Starting Antigravity...") 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) -- termopen will attach to the current buffer (which show_progress just focused)
job_id = vim.fn.termopen(cmd, { job_id = vim.fn.termopen(cmd, {
on_exit = function(_, code) on_exit = function(_, code)
@@ -70,9 +74,16 @@ local function execute_agent(prompt)
end end
function M.ask_agent() function M.ask_agent()
local file = vim.api.nvim_buf_get_name(0)
vim.ui.input({ prompt = 'Ask Agent: ' }, function(input) vim.ui.input({ prompt = 'Ask Agent: ' }, function(input)
if not input or input == '' then return end 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)
end end
+7 -1
View File
@@ -26,7 +26,9 @@ impl MemoryHandler {
id, id,
serde_json::json!({ serde_json::json!({
"protocolVersion": "2024-11-05", "protocolVersion": "2024-11-05",
"capabilities": {}, "capabilities": {
"tools": {}
},
"serverInfo": { "serverInfo": {
"name": "gemini-mcp-memory", "name": "gemini-mcp-memory",
"version": "3.0.0" "version": "3.0.0"
@@ -1607,3 +1609,7 @@ crate::mcp::tool_def::<CreateEntitiesTool>("create_entities", "Create new entiti
} }
} }
} }
+8 -52
View File
@@ -449,56 +449,11 @@ fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>>
) )
.with_state(app_state); .with_state(app_state);
let mut retries = 0; 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 {
let listener = loop { Ok(l) => l,
match tokio::net::TcpListener::bind("127.0.0.1:3000").await { Err(e) => {
Ok(l) => break l, tracing::info!("Port 3000 is already in use ({}). Assuming server is already running and exiting gracefully.", e);
Err(e) => { std::process::exit(0);
// 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;
}
} }
}; };
@@ -705,7 +660,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse(); let cli = Cli::parse();
if cli.exit { 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; use std::io::Write;
let _ = stream.write_all( let _ = stream.write_all(
b"POST /shutdown HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", 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<dyn std::error::Error>> {
} }
if cli.restart { 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; use std::io::Write;
let _ = stream.write_all( let _ = stream.write_all(
b"POST /shutdown HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", 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<dyn std::error::Error>> {
+3
View File
@@ -14,3 +14,6 @@ tracing-appender = "0.2.5"
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = "0.3.23" tracing-subscriber = "0.3.23"
dirs = "7.0.0" dirs = "7.0.0"
[dev-dependencies]
serde_json = "1.0.151"
+134
View File
@@ -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<Value> {
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();
}
+25 -13
View File
@@ -56,7 +56,10 @@ async fn main() {
jsonrpc: "2.0".to_string(), jsonrpc: "2.0".to_string(),
id, id,
result: Some(json!({ result: Some(json!({
"capabilities": {}, "protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": { "serverInfo": {
"name": "mcp-memory-win-nvim", "name": "mcp-memory-win-nvim",
"version": "0.1.0" "version": "0.1.0"
@@ -329,7 +332,27 @@ async fn main() {
} }
async fn get_socket_path() -> Result<String, String> { async fn get_socket_path() -> Result<String, String> {
// 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\") { if let Ok(dir) = std::fs::read_dir(r"\\.\pipe\") {
for entry in dir.flatten() { for entry in dir.flatten() {
let name = entry.file_name(); let name = entry.file_name();
@@ -340,17 +363,6 @@ async fn get_socket_path() -> Result<String, String> {
} }
} }
// 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()) Err("Could not find active Windows Neovim named pipe".to_string())
} }
+14 -1
View File
@@ -40,7 +40,20 @@ pub async fn read_message(stdin: &mut BufReader<tokio::io::Stdin>) -> Option<Jso
} }
let mut buffer = vec![0; length]; let mut buffer = vec![0; length];
stdin.read_exact(&mut buffer).await.unwrap_or(0); stdin.read_exact(&mut buffer).await.unwrap_or(0);
serde_json::from_slice(&buffer).ok()
match serde_json::from_slice::<JsonRpcRequest>(&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) { pub async fn send_response(response: JsonRpcResponse) {
+111
View File
@@ -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<Value> {
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_<name> 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");
}