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

+25 -13
View File
@@ -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<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\") {
for entry in dir.flatten() {
let name = entry.file_name();
@@ -339,17 +362,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())
}
+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];
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) {
+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");
}