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:
1 parent
f2582b58be
commit
d71b1e6e35
11 files changed
+327
-79
No files matched your search
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>> {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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"
|
||||||
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
Reference in new issue
Block a user