refactor(mcp): strip sse fallback architecture in favor of pure websockets and fix nvim NDJSON bug

This commit is contained in:
Riz Ashraf committed 2026-09-17 15:26:22 +01:00
1 parent 0e29b12ac8
commit 3716c3e698
33 files changed
+2082 -1756

No files matched your search

+92 -77
View File
@@ -5,107 +5,104 @@ 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);
let payload = format!("{}\n", 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 {
fn read_message(reader: &mut impl BufRead) -> Option<Value> {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 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())
serde_json::from_str(line.trim()).ok()
}
#[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 test_port = "3042"; // Use a distinct port
let test_auth_token = "test-token-12345";
let mut exe_dir = std::env::current_exe().unwrap();
exe_dir.pop(); // pop test executable name
exe_dir.pop(); // pop deps/
let mut server_exe = exe_dir.join("mcp-memory-server.exe");
if !server_exe.exists() {
let mut target_dir = std::env::current_dir().unwrap();
if target_dir.ends_with("stub") {
target_dir.pop();
}
server_exe = target_dir.join("target").join("debug").join("mcp-memory-server.exe");
}
let mut nvim_exe = exe_dir.join("mcp-memory-win-nvim.exe");
if !nvim_exe.exists() {
let mut target_dir = std::env::current_dir().unwrap();
if target_dir.ends_with("stub") {
target_dir.pop();
}
nvim_exe = target_dir.join("target").join("debug").join("mcp-memory-win-nvim.exe");
}
// Since tests run from inside `target/debug/deps`, and `cargo test` does not guarantee
// `env!("CARGO_BIN_EXE_name")` works correctly for binaries compiled in other crates without build dependencies,
// we use `CARGO_MANIFEST_DIR` (which points to `stub`) to reliably locate the workspace `target/debug`.
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let debug_dir = manifest_dir.parent().unwrap().join("target").join("debug");
let server_exe = debug_dir.join(format!("mcp-memory-server{}", std::env::consts::EXE_SUFFIX));
let nvim_name = if cfg!(windows) {
"mcp-memory-win-nvim"
} else {
"mcp-memory-linux-nvim"
};
let nvim_exe = debug_dir.join(format!("{}{}", nvim_name, std::env::consts::EXE_SUFFIX));
let stub_exe = debug_dir.join(format!("mcp-memory-stub{}", std::env::consts::EXE_SUFFIX));
assert!(server_exe.exists(), "Server not found at {:?}", server_exe);
assert!(nvim_exe.exists(), "Nvim not found at {:?}", nvim_exe);
assert!(stub_exe.exists(), "Stub not found at {:?}", stub_exe);
// 1. Start Server
let mut server = Command::new(&server_exe)
.env("MCP_PORT", test_port)
let mut server = Command::new(&server_exe).arg("--daemon")
.env("MCP_PORT", test_port).env("RUST_LOG", "debug")
.env("MCP_MEMORY_STORE_DIR", temp_dir.to_str().unwrap())
.stdout(Stdio::null())
.stderr(Stdio::null())
.env("MCP_AUTH_TOKEN", test_auth_token).env("RUST_LOG", "debug")
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.expect("Failed to start server");
tokio::time::sleep(Duration::from_secs(2)).await;
// Give server time to generate TLS cert and start
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.build()
.unwrap();
let mut started = false;
for _ in 0..30 {
if let Ok(resp) = client.get(format!("http://127.0.0.1:{}/health", test_port)).send().await {
if resp.status().is_success() {
started = true;
break;
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
assert!(started, "Server failed to start in time");
// 2. Start Stub
let stub_exe = env!("CARGO_BIN_EXE_mcp-memory-stub");
let mut stub = Command::new(stub_exe)
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())
.env("MCP_AUTH_TOKEN", test_auth_token).env("RUST_LOG", "debug")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.stderr(Stdio::inherit())
.spawn()
.expect("Failed to start stub");
let mut stub_stdin = stub.stdin.take().unwrap();
let mut stub_stdout = stub.stdout.take().unwrap();
let mut stub_stdout = BufReader::new(stub.stdout.take().unwrap());
// 3. Start Win-Nvim
let mut win_nvim = Command::new(&nvim_exe)
// 3. Start Nvim Bridge
let mut nvim = Command::new(&nvim_exe)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.stderr(Stdio::inherit())
.spawn()
.expect("Failed to start win-nvim");
.expect("Failed to start nvim bridge");
let mut nvim_stdin = win_nvim.stdin.take().unwrap();
let mut nvim_stdout = win_nvim.stdout.take().unwrap();
let mut nvim_stdin = nvim.stdin.take().unwrap();
let mut nvim_stdout = BufReader::new(nvim.stdout.take().unwrap());
println!("Server, stub, and nvim spawned successfully");
// Send 100 concurrent-like sequential rapid requests to Stub
println!("Starting 100 requests to stub...");
let start_time = std::time::Instant::now();
for i in 1..=100 {
let tools_req = json!({
@@ -114,13 +111,27 @@ async fn test_full_system_e2e_performance() {
"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");
// Alternate between LSP header format and JSONL format
if i % 2 == 0 {
send_message(&mut stub_stdin, tools_req);
} else {
let s = serde_json::to_string(&tools_req).unwrap();
stub_stdin.write_all(format!("{}\n", s).as_bytes()).unwrap();
stub_stdin.flush().unwrap();
}
let mut resp = read_message(&mut stub_stdout).expect("Failed to read rapid response from stub");
while resp.get("id").is_none() || resp["id"].is_null() {
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();
println!("Stub 100 requests: {:?}", stub_duration);
// Send 100 concurrent-like sequential rapid requests to Win-Nvim
println!("Starting 100 requests to nvim...");
let start_time_nvim = std::time::Instant::now();
for i in 1..=100 {
let tools_req = json!({
@@ -129,8 +140,19 @@ async fn test_full_system_e2e_performance() {
"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");
if i % 2 == 0 {
send_message(&mut nvim_stdin, tools_req);
} else {
let s = serde_json::to_string(&tools_req).unwrap();
nvim_stdin.write_all(format!("{}\n", s).as_bytes()).unwrap();
nvim_stdin.flush().unwrap();
}
let mut resp = read_message(&mut nvim_stdout).expect("Failed to read rapid response from win-nvim");
while resp.get("id").is_none() || resp["id"].is_null() {
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();
@@ -139,15 +161,8 @@ async fn test_full_system_e2e_performance() {
println!("Win-Nvim 100 requests: {:?}", nvim_duration);
// Cleanup
let _ = stub.kill();
let _ = win_nvim.kill();
let _ = server.kill();
let _ = stub.kill();
let _ = nvim.kill();
let _ = std::fs::remove_dir_all(temp_dir);
}
+88
View File
@@ -0,0 +1,88 @@
use std::process::Stdio;
use std::time::{Duration, Instant};
use tokio::process::Command;
fn get_stub_exe() -> std::path::PathBuf {
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let debug_dir = manifest_dir.parent().unwrap().join("target").join("debug");
debug_dir.join(format!("mcp-memory-stub{}", std::env::consts::EXE_SUFFIX))
}
#[tokio::test]
async fn test_stub_connection_refused() {
let _ = std::process::Command::new("cargo").arg("build").arg("--bin").arg("mcp-memory-stub").status();
let target = "http://127.0.0.1:49999";
let start = Instant::now();
let mut child = Command::new(get_stub_exe())
.arg("--target")
.arg(target)
.stdin(Stdio::null()) // close stdin immediately to simulate EOF
.spawn()
.expect("Failed to execute stub");
let res = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
let elapsed = start.elapsed();
assert!(res.is_ok(), "Stub hung on connection refused! Took {:?}", elapsed);
}
#[tokio::test]
async fn test_stub_handles_eof_cleanly() {
let _ = std::process::Command::new("cargo").arg("build").arg("--bin").arg("mcp-memory-stub").status();
let target = "http://127.0.0.1:49998";
let mut child = Command::new(get_stub_exe())
.arg("--target")
.arg(target)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("Failed to execute stub");
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
let msg = "Content-Length: 51\r\n\r\n{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\",\"params\":{},\"id\":1}";
stdin.write_all(msg.as_bytes()).await.unwrap();
} // stdin dropped here
let start = Instant::now();
let res = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
let elapsed = start.elapsed();
assert!(res.is_ok(), "Stub hung after EOF! Took {:?}", elapsed);
}
#[tokio::test]
async fn test_stub_sse_fallback_failure() {
let _ = std::process::Command::new("cargo").arg("build").arg("--bin").arg("mcp-memory-stub").status();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let local_addr = listener.local_addr().unwrap();
let target = format!("http://127.0.0.1:{}", local_addr.port());
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
use tokio::io::AsyncReadExt;
let mut buf = [0; 1024];
let _ = socket.read(&mut buf).await;
drop(socket);
}
});
let start = Instant::now();
let mut child = Command::new(get_stub_exe())
.arg("--target")
.arg(target)
.stdin(Stdio::null())
.spawn()
.expect("Failed to execute stub");
let res = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
let elapsed = start.elapsed();
assert!(res.is_ok(), "Stub hung on fallback failure! Took {:?}", elapsed);
}