From 1da413568ef8492bb76bcb5913d4ffbcff5d9603 Mon Sep 17 00:00:00 2001 From: Riz Ashraf Date: Mon, 14 Sep 2026 04:07:52 +0100 Subject: [PATCH] docs: update architecture and design docs with proxy reconnect and deployment strategies --- README.md | 3 +- build.ps1 | 100 +++ build_template.rs | 20 + check_versions.ps1 | 56 ++ design.md | 9 + linux-nvim/build.rs | 20 + linux-nvim/src/main.rs | 620 +----------------- linux-nvim/src/unix_app.rs | 612 ++++++++++++++++++ server/build.rs | 26 +- server/src/handlers.rs | 981 ++++++++++++++++++++++++++++- server/src/main.rs | 182 +++--- server/src/state.rs | 2 +- stub/build.rs | 20 + stub/src/main.rs | 12 +- stub/tests/e2e.rs | 37 +- win-nvim/build.rs | 20 + win-nvim/src/main.rs | 11 +- win-nvim/tests/integration_test.rs | 1 - 18 files changed, 2010 insertions(+), 722 deletions(-) create mode 100644 build.ps1 create mode 100644 build_template.rs create mode 100644 check_versions.ps1 create mode 100644 linux-nvim/build.rs create mode 100644 linux-nvim/src/unix_app.rs create mode 100644 stub/build.rs create mode 100644 win-nvim/build.rs diff --git a/README.md b/README.md index 6703ab9..3f6f87f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ mcp-memory acts as the persistent "brain" for the agy CLI agents. It tracks enti ` To eliminate heavy Cross-OS I/O penalties when using WSL and Windows simultaneously, mcp-memory operates using a **Dual-Transport Leader/Stub Architecture**: * **The Server (mcp-memory-server)**: Runs natively on the Windows host. It binds to .0.0.0:3000, serving standard stdio to the primary Windows agy instance while simultaneously hosting an Axum HTTP server for secondary clients. -* **The Stub (mcp-memory-stub)**: An ultra-lightweight proxy binary. WSL agy instances run this native Linux stub, which transparently pipes stdio JSON-RPC traffic over the network to the Windows HTTP server (http://127.0.0.1:3000), completely bypassing WSL NTFS mounts. +* **The Stub (mcp-memory-stub)**: An ultra-lightweight proxy binary. WSL agy instances run this native Linux stub, which transparently pipes stdio JSON-RPC traffic over the network to the Windows HTTP server (http://127.0.0.1:3000), completely bypassing WSL NTFS mounts. It features full MPSC queue buffering and a WebSocket reconnect handshake ( +otifications/tools/list_changed) so that tools automatically refresh seamlessly without disconnecting the CLI if the background server restarts. ` ## Quick Start & Usage ` diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..8939730 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,100 @@ +$ErrorActionPreference = 'Stop' + +Write-Host "Building mcp-memory (server and stub) for Windows..." -ForegroundColor Cyan +& rustup run stable cargo build --release --workspace --exclude mcp-memory-linux-nvim +if ($LASTEXITCODE -ne 0) { + Write-Error "Windows build failed!" + exit $LASTEXITCODE +} + +Write-Host "Building mcp-memory (server and stub) for Linux (WSL)..." -ForegroundColor Cyan +& rustup run stable cargo zigbuild --release --workspace --exclude mcp-memory-win-nvim --target x86_64-unknown-linux-musl +if ($LASTEXITCODE -ne 0) { + Write-Error "Linux build failed!" + exit $LASTEXITCODE +} + +Write-Host "`nBuild successful! Gracefully shutting down running servers..." -ForegroundColor Green +$serverExe = "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" +if (Test-Path $serverExe) { + & $serverExe --exit 2>$null +} +try { + Invoke-RestMethod -Uri "http://127.0.0.1:3000/shutdown" -Method Post -ErrorAction SilentlyContinue | Out-Null +} catch {} +Start-Sleep -Seconds 2 + +function Get-ExeVersion { + param([string]$Path, [string]$EnvName) + if ($EnvName -eq 'WSL') { + $checkCmd = "if [ -x ""$Path"" ]; then $Path --version; else echo 'Not Found'; fi" + $output = wsl.exe -d Ubuntu -e bash -c $checkCmd + } else { + if (Test-Path $Path) { + $output = & $Path --version 2>&1 + } else { + $output = 'Not Found' + } + } + if ([string]::IsNullOrWhiteSpace($output)) { return "No Output / Errored" } + return $output.Trim() +} + +function Deploy-If-Needed { + param([string]$Source, [string]$Dest, [string]$EnvName) + + $sourceVerPath = $Source + if ($EnvName -eq 'WSL') { + $sourceVerPath = wsl.exe -d Ubuntu -e wslpath -u $Source + } + + $sourceVer = Get-ExeVersion -Path $sourceVerPath -EnvName $EnvName + $destVer = Get-ExeVersion -Path $Dest -EnvName $EnvName + + if ($sourceVer -eq $destVer -and $sourceVer -ne "Not Found") { + Write-Host " Skip: $($Dest) is already up-to-date ($sourceVer)" -ForegroundColor DarkGray + return + } + + Write-Host " Update: $($Dest) ($destVer -> $sourceVer)" -ForegroundColor Yellow + + if ($EnvName -eq 'WSL') { + # Translate source path to WSL path + $wslSource = wsl.exe -d Ubuntu -e wslpath -u $Source + wsl.exe -d Ubuntu -e bash -c "mv $Dest ${Dest}.`date +%s`.old 2>/dev/null || true; cp $wslSource $Dest && chmod +x $Dest" + } else { + if (Test-Path $Dest) { + $rand = Get-Random + Move-Item -Path $Dest -Destination "${Dest}.${rand}.old" -Force -ErrorAction SilentlyContinue + } + Copy-Item -Path $Source -Destination $Dest -Force + } + + $newDestVer = Get-ExeVersion -Path $Dest -EnvName $EnvName + if ($newDestVer -eq $sourceVer) { + Write-Host " Success: $($Dest) verified as $newDestVer" -ForegroundColor Green + } else { + Write-Error " Failed: $($Dest) version mismatch after copy! Expected $sourceVer, got $newDestVer" + } +} + +Write-Host "`nDeploying and verifying binaries..." -ForegroundColor Cyan +$winBase = "C:\Users\reazul.ashraf\.local\bin" +$wslBase = "/home/riz/.local/bin" +$winTarget = "target\release" +$wslTarget = "target\x86_64-unknown-linux-musl\release" + +Deploy-If-Needed -Source "$winTarget\mcp-memory-server.exe" -Dest "$winBase\mcp-memory-server.exe" -EnvName "Win" +Deploy-If-Needed -Source "$winTarget\mcp-memory-stub.exe" -Dest "$winBase\mcp-memory-stub.exe" -EnvName "Win" +Deploy-If-Needed -Source "$winTarget\mcp-memory-win-nvim.exe" -Dest "$winBase\mcp-memory-win-nvim.exe" -EnvName "Win" + +# Note: Source is local windows path, but Deploy-If-Needed translates it to wslpath +Deploy-If-Needed -Source "$wslTarget\mcp-memory-server" -Dest "$wslBase/mcp-memory-server" -EnvName "WSL" +Deploy-If-Needed -Source "$wslTarget\mcp-memory-stub" -Dest "$wslBase/mcp-memory-stub" -EnvName "WSL" +Deploy-If-Needed -Source "$wslTarget\mcp-memory-linux-nvim" -Dest "$wslBase/mcp-memory-linux-nvim" -EnvName "WSL" + +Write-Host "`nCopying instructions.md..." -ForegroundColor Cyan +Copy-Item instructions.md "C:\Users\reazul.ashraf\.gemini\antigravity-cli\mcp\memory\instructions.md" -Force +wsl.exe -d Ubuntu -e bash -c "mkdir -p /home/riz/.gemini/antigravity-cli/mcp/memory && cp $(wsl.exe -d Ubuntu -e wslpath -u instructions.md) /home/riz/.gemini/antigravity-cli/mcp/memory/instructions.md" + +Write-Host "`nBuild and deployment complete!" -ForegroundColor Green diff --git a/build_template.rs b/build_template.rs new file mode 100644 index 0000000..0e0609c --- /dev/null +++ b/build_template.rs @@ -0,0 +1,20 @@ +use std::process::Command; + +fn main() { + let git_hash = Command::new("git") + .args(&["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .and_then(|out| String::from_utf8(out.stdout).ok()) + .unwrap_or_else(|| "unknown".to_string()); + + let git_date = Command::new("git") + .args(&["log", "-1", "--format=%cd", "--date=format:%Y.%m.%d"]) + .output() + .ok() + .and_then(|out| String::from_utf8(out.stdout).ok()) + .unwrap_or_else(|| "unknown".to_string()); + + let version = format!("{} ({} {})", env!("CARGO_PKG_VERSION"), git_date.trim(), git_hash.trim()); + println!("cargo:rustc-env=APP_VERSION={}", version); +} diff --git a/check_versions.ps1 b/check_versions.ps1 new file mode 100644 index 0000000..7ade942 --- /dev/null +++ b/check_versions.ps1 @@ -0,0 +1,56 @@ +$ErrorActionPreference = 'Stop' + +function Get-ExeVersion { + param( + [string]$Path, + [string]$EnvName + ) + if ($EnvName -eq 'WSL') { + $checkCmd = "if [ -x ""$Path"" ]; then $Path --version; else echo 'Not Found'; fi" + $output = wsl.exe -d Ubuntu -e bash -c $checkCmd + } else { + if (Test-Path $Path) { + $output = & $Path --version 2>&1 + } else { + $output = 'Not Found' + } + } + + if ([string]::IsNullOrWhiteSpace($output)) { + return "No Output / Errored" + } + return $output.Trim() +} + +Write-Host "=======================================" -ForegroundColor Cyan +Write-Host " MCP Memory Executable Version Checker " -ForegroundColor Cyan +Write-Host "=======================================" -ForegroundColor Cyan +Write-Host "" + +$winBase = "C:\Users\reazul.ashraf\.local\bin" +$wslBase = "/home/riz/.local/bin" + +$components = @( + @{ Name = "Server (Win)"; Path = "$winBase\mcp-memory-server.exe"; Env = "Win" }, + @{ Name = "Stub (Win)"; Path = "$winBase\mcp-memory-stub.exe"; Env = "Win" }, + @{ Name = "Win-Nvim"; Path = "$winBase\mcp-memory-win-nvim.exe"; Env = "Win" }, + @{ Name = "Server (WSL)"; Path = "$wslBase/mcp-memory-server"; Env = "WSL" }, + @{ Name = "Stub (WSL)"; Path = "$wslBase/mcp-memory-stub"; Env = "WSL" }, + @{ Name = "Linux-Nvim"; Path = "$wslBase/mcp-memory-linux-nvim"; Env = "WSL" } +) + +foreach ($comp in $components) { + Write-Host ("Checking {0,-15} -> " -f $comp.Name) -NoNewline + $ver = Get-ExeVersion -Path $comp.Path -EnvName $comp.Env + + if ($ver -eq "Not Found") { + Write-Host $ver -ForegroundColor Red + } elseif ($ver -eq "No Output / Errored") { + Write-Host $ver -ForegroundColor Yellow + } else { + Write-Host $ver -ForegroundColor Green + } +} + +Write-Host "" +Write-Host "Done!" -ForegroundColor Cyan diff --git a/design.md b/design.md index 8019e32..1b711ca 100644 --- a/design.md +++ b/design.md @@ -141,3 +141,12 @@ vim_list_buffers**: Discover unsaved work and context. vim_get_diagnostics**: Read live LSP errors dynamically instead of requiring a compiler. * ** vim_execute_lua ("God Mode")**: The ultimate fallback tool. Evaluates raw Lua scripts inside the active Neovim instance and returns JSON. This prevents the need to continuously recompile the Rust server whenever a new Neovim capability is required. + +## 15. Build & Deployment Strategy +Because the background server operates as an always-on Windows daemon, standard recompilation and file-copying strategies will fail due to active Windows OS file locks. + +### Randomized Lock Bypassing +The \uild.ps1\ deployment pipeline intercepts locked \.exe\ files by appending a unique, timestamped/randomized suffix (e.g., \mcp-memory-server.exe.12345.old\) when forcing a \Move-Item\. This guarantees that rapid sequential deployments (where a previous \.old\ file might still be locked by a zombie process) never silently fail or collide. + +### Dynamic Versioning +To trace binary provenances during rapid deployment cycles, all binaries embed dynamic versioning directly at compile time (via \uild.rs\ and \uild_template.rs\). The injected \APP_VERSION\ environment variable combines the static Cargo \ ersion\ with the live \git\ short hash and UTC timestamp, allowing the CLI \--version\ commands and the HTTP \/api/version\ endpoints to guarantee exactly which iteration of the code is actively executing. \ No newline at end of file diff --git a/linux-nvim/build.rs b/linux-nvim/build.rs new file mode 100644 index 0000000..0e0609c --- /dev/null +++ b/linux-nvim/build.rs @@ -0,0 +1,20 @@ +use std::process::Command; + +fn main() { + let git_hash = Command::new("git") + .args(&["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .and_then(|out| String::from_utf8(out.stdout).ok()) + .unwrap_or_else(|| "unknown".to_string()); + + let git_date = Command::new("git") + .args(&["log", "-1", "--format=%cd", "--date=format:%Y.%m.%d"]) + .output() + .ok() + .and_then(|out| String::from_utf8(out.stdout).ok()) + .unwrap_or_else(|| "unknown".to_string()); + + let version = format!("{} ({} {})", env!("CARGO_PKG_VERSION"), git_date.trim(), git_hash.trim()); + println!("cargo:rustc-env=APP_VERSION={}", version); +} diff --git a/linux-nvim/src/main.rs b/linux-nvim/src/main.rs index c473b93..f3e120a 100644 --- a/linux-nvim/src/main.rs +++ b/linux-nvim/src/main.rs @@ -1,612 +1,16 @@ -mod mcp; +#[cfg(unix)] +mod unix_app; -use mcp::{read_message, send_response, send_error, JsonRpcResponse}; -use serde_json::json; -use tokio::net::UnixStream; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; - -fn init_logging(app_name: &str) -> Option { - let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| { - dirs::home_dir() - .map(|mut h| { - h.push(".gemini/mcp_memory"); - h.to_string_lossy().to_string() - }) - .unwrap_or_else(|| ".gemini/mcp_memory".into()) - }); - let log_dir = std::path::PathBuf::from(base_dir).join("logs"); - std::fs::create_dir_all(&log_dir).unwrap_or_default(); - - let file_appender = tracing_appender::rolling::daily(log_dir, format!("{}.log", app_name)); - let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); - - let _ = tracing_subscriber::fmt() - .with_writer(non_blocking) - .with_ansi(false) - .with_max_level(tracing::Level::INFO) - .try_init(); - - Some(guard) +#[cfg(unix)] +fn main() { + if std::env::args().any(|a| a == "--version" || a == "-V") { + println!("mcp-memory-linux-nvim {}", env!("APP_VERSION")); + return; + } + unix_app::main(); } -#[tokio::main] -async fn main() { - let _guard = init_logging("linux-nvim"); - let mut stdin = tokio::io::BufReader::new(tokio::io::stdin()); - loop { - let msg = match read_message(&mut stdin).await { - Some(m) => m, - None => break, - }; - - tokio::spawn(async move { - let id = msg.id.clone().unwrap_or(json!(null)); - - match msg.method.as_str() { - "initialize" => { - send_response(JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ - "capabilities": {}, - "serverInfo": { - "name": "mcp-memory-linux-nvim", - "version": "0.1.0" - } - })), - error: None, - }).await; - } - "tools/list" => { - send_response(JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ - "tools": [ - { - "name": "nvim_goto_line", - "description": "Open a file and jump to a specific line", - "inputSchema": { - "type": "object", - "properties": { - "file": { "type": "string" }, - "line": { "type": "integer" } - }, - "required": ["file", "line"] - } - }, - { - "name": "nvim_get_active_buffer", - "description": "Get the contents of the currently active Neovim buffer", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "nvim_get_cursor", - "description": "Get the current cursor position (line and column) in the active Neovim buffer", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "nvim_get_visual_selection", - "description": "Get the text that is currently highlighted or was last highlighted in Visual mode", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "nvim_set_diagnostics", - "description": "Push a diagnostic message (like an LSP warning) to a specific line in the active buffer", - "inputSchema": { - "type": "object", - "properties": { - "line": { "type": "integer" }, - "message": { "type": "string" } - }, - "required": ["line", "message"] - } - }, - { - "name": "nvim_execute_lua", - "description": "Execute arbitrary Lua code in Neovim and return the result (JSON serialized).", - "inputSchema": { - "type": "object", - "properties": { - "code": { "type": "string" } - }, - "required": ["code"] - } - }, - { - "name": "nvim_list_buffers", - "description": "Get a list of all loaded Neovim buffers and their IDs.", - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "nvim_get_diagnostics", - "description": "Get all LSP diagnostics (errors, warnings) for the active buffer.", - "inputSchema": { - "type": "object", - "properties": {} - } - } - ] - })), - error: None, - }).await; - } - "tools/call" => { - let params = msg.params.clone().unwrap_or(json!({})); - let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let args = params.get("arguments").cloned().unwrap_or(json!({})); - - match name { - "nvim_goto_line" => { - let file = args.get("file").and_then(|v| v.as_str()).unwrap_or(""); - let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(1); - - let cmd = format!("edit {} | {} | normal! zz", file, line); - match send_nvim_command(&cmd).await { - Ok(_) => { - send_response(JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ - "content": [ - { "type": "text", "text": format!("Successfully jumped to {}:{}", file, line) } - ] - })), - error: None, - }).await; - } - Err(e) => { - send_error(id, -32603, &format!("Failed to execute command: {}", e)).await; - } - } - } - "nvim_get_active_buffer" => { - match get_nvim_active_buffer().await { - Ok(content) => { - send_response(JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ - "content": [ - { "type": "text", "text": content } - ] - })), - error: None, - }).await; - } - Err(e) => { - send_error(id, -32603, &format!("Failed to get active buffer: {}", e)).await; - } - } - } - "nvim_get_cursor" => { - match get_nvim_cursor().await { - Ok(content) => { - send_response(JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ - "content": [ - { "type": "text", "text": content } - ] - })), - error: None, - }).await; - } - Err(e) => { - send_error(id, -32603, &format!("Failed to get cursor: {}", e)).await; - } - } - } - "nvim_get_visual_selection" => { - match get_nvim_visual_selection().await { - Ok(content) => { - send_response(JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ - "content": [ - { "type": "text", "text": content } - ] - })), - error: None, - }).await; - } - Err(e) => { - send_error(id, -32603, &format!("Failed to get visual selection: {}", e)).await; - } - } - } - "nvim_set_diagnostics" => { - let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(1); - let message = args.get("message").and_then(|v| v.as_str()).unwrap_or(""); - - match set_nvim_diagnostics(line, message).await { - Ok(_) => { - send_response(JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ - "content": [ - { "type": "text", "text": format!("Successfully set diagnostic on line {}", line) } - ] - })), - error: None, - }).await; - } - Err(e) => { - send_error(id, -32603, &format!("Failed to set diagnostic: {}", e)).await; - } - } - } - - "nvim_execute_lua" => { - let code = args.get("code").and_then(|v| v.as_str()).unwrap_or(""); - match execute_nvim_lua(code).await { - Ok(content) => { - send_response(JsonRpcResponse { - jsonrpc: "2.0".to_string(), id, result: Some(json!({ "content": [{ "type": "text", "text": content }] })), error: None, - }).await; - } - Err(e) => { send_error(id, -32603, &format!("Failed to execute lua: {}", e)).await; } - } - } - "nvim_list_buffers" => { - let code = r#" - local bufs = vim.api.nvim_list_bufs() - local loaded = {} - for _, b in ipairs(bufs) do - if vim.api.nvim_buf_is_loaded(b) then - local name = vim.api.nvim_buf_get_name(b) - table.insert(loaded, {id = b, name = name == "" and "[No Name]" or name}) - end - end - return loaded - "#; - match execute_nvim_lua(code).await { - Ok(content) => { - send_response(JsonRpcResponse { - jsonrpc: "2.0".to_string(), id, result: Some(json!({ "content": [{ "type": "text", "text": content }] })), error: None, - }).await; - } - Err(e) => { send_error(id, -32603, &format!("Failed to list buffers: {}", e)).await; } - } - } - "nvim_get_diagnostics" => { - let code = r#" - local diags = vim.diagnostic.get(0) - local res = {} - for _, d in ipairs(diags) do - table.insert(res, { - line = d.lnum + 1, - col = d.col, - message = d.message, - severity = d.severity - }) - end - return res - "#; - match execute_nvim_lua(code).await { - Ok(content) => { - send_response(JsonRpcResponse { - jsonrpc: "2.0".to_string(), id, result: Some(json!({ "content": [{ "type": "text", "text": content }] })), error: None, - }).await; - } - Err(e) => { send_error(id, -32603, &format!("Failed to get diagnostics: {}", e)).await; } - } - } - _ => { - send_error(id, -32601, "Tool not found").await; - } - } - } - _ => { - // Ignore other methods - } - } - }); - } -} - -async fn get_socket_path() -> Result { - // 1. Try active_nvim.txt first - if let Ok(home) = std::env::var("HOME") { - let path = format!("{}/.gemini/active_nvim.txt", home); - if let Ok(content) = std::fs::read_to_string(&path) { - let p = content.trim().to_string(); - if !p.is_empty() { - return Ok(p); - } - } - } - - // 2. Fallback: search /tmp/nvim.*/0 - if let Ok(entries) = std::fs::read_dir("/tmp") { - for entry in entries.flatten() { - if let Ok(name) = entry.file_name().into_string() { - if name.starts_with("nvim.") { - let path = entry.path().join("0"); - if path.exists() { - return Ok(path.to_string_lossy().to_string()); - } - } - } - } - } - Err("Could not find Neovim socket".to_string()) -} - -async fn call_nvim(req: rmpv::Value) -> Result { - let msgid = if let rmpv::Value::Array(ref arr) = req { - if arr.len() > 1 { arr[1].clone() } else { rmpv::Value::Nil } - } else { rmpv::Value::Nil }; - - let socket_path = get_socket_path().await?; - let mut stream = UnixStream::connect(socket_path).await.map_err(|e| e.to_string())?; - - let mut buf = Vec::new(); - rmpv::encode::write_value(&mut buf, &req).map_err(|e| e.to_string())?; - stream.write_all(&buf).await.map_err(|e| e.to_string())?; - - let mut resp_buf = Vec::new(); - let mut chunk = vec![0u8; 8192]; - let mut offset = 0; - - loop { - let mut cursor = std::io::Cursor::new(&resp_buf[offset..]); - match rmpv::decode::read_value(&mut cursor) { - Ok(val) => { - offset += cursor.position() as usize; - - if let rmpv::Value::Array(ref arr) = val { - if arr.len() >= 4 && arr[0] == rmpv::Value::Integer(1.into()) && arr[1] == msgid { - return Ok(val); - } - } - continue; - }, - Err(_) => { - let read_future = stream.read(&mut chunk); - match tokio::time::timeout(tokio::time::Duration::from_secs(5), read_future).await { - Ok(Ok(n)) => { - if n == 0 { return Err("Connection closed".into()); } - resp_buf.extend_from_slice(&chunk[..n]); - } - Ok(Err(e)) => return Err(e.to_string()), - Err(_) => return Err("Timeout waiting for Neovim response".into()), - } - } - } - } -} - -async fn send_nvim_command(cmd: &str) -> Result<(), String> { - use rmpv::Value as RmpValue; - let req = RmpValue::Array(vec![ - RmpValue::Integer(0.into()), - RmpValue::Integer(1.into()), // msgid - RmpValue::String("nvim_command".into()), - RmpValue::Array(vec![RmpValue::String(cmd.into())]), - ]); - - let resp = call_nvim(req).await?; - if let RmpValue::Array(arr) = resp { - if !arr[2].is_nil() { - return Err(format!("Neovim error: {:?}", arr[2])); - } - return Ok(()); - } - Err("Invalid response".to_string()) -} - -async fn get_nvim_active_buffer() -> Result { - use rmpv::Value as RmpValue; - let req = RmpValue::Array(vec![ - RmpValue::Integer(0.into()), - RmpValue::Integer(2.into()), // msgid - RmpValue::String("nvim_buf_get_lines".into()), - RmpValue::Array(vec![ - RmpValue::Integer(0.into()), - RmpValue::Integer(0.into()), - RmpValue::Integer((-1).into()), - RmpValue::Boolean(true), - ]), - ]); - - let resp = call_nvim(req).await?; - if let RmpValue::Array(arr) = resp { - if !arr[2].is_nil() { - return Err(format!("Neovim error: {:?}", arr[2])); - } - if let RmpValue::Array(lines) = &arr[3] { - let mut text = String::new(); - for line in lines { - if let RmpValue::String(s) = line { - if let Some(s) = s.as_str() { - text.push_str(s); - text.push('\n'); - } - } - } - return Ok(text); - } - } - Err("Invalid response".to_string()) -} - -async fn get_nvim_cursor() -> Result { - use rmpv::Value as RmpValue; - let req = RmpValue::Array(vec![ - RmpValue::Integer(0.into()), - RmpValue::Integer(3.into()), // msgid - RmpValue::String("nvim_win_get_cursor".into()), - RmpValue::Array(vec![ - RmpValue::Integer(0.into()), - ]), - ]); - - let resp = call_nvim(req).await?; - if let RmpValue::Array(arr) = resp { - if !arr[2].is_nil() { - return Err(format!("Neovim error: {:?}", arr[2])); - } - if let RmpValue::Array(pos) = &arr[3] { - if pos.len() == 2 { - if let (RmpValue::Integer(row), RmpValue::Integer(col)) = (&pos[0], &pos[1]) { - return Ok(format!("Line: {}, Column: {}", row, col)); - } - } - } - } - Err("Invalid response".to_string()) -} - -async fn get_nvim_visual_selection() -> Result { - let lua_script = r#" - local _, csrow, cscol, _ = unpack(vim.fn.getpos("'<")) - local _, cerow, cecol, _ = unpack(vim.fn.getpos("'>")) - local lines = vim.fn.getline(csrow, cerow) - if type(lines) == "table" then - return table.concat(lines, "\n") - else - return lines - end - "#; - - use rmpv::Value as RmpValue; - let req = RmpValue::Array(vec![ - RmpValue::Integer(0.into()), - RmpValue::Integer(4.into()), // msgid - RmpValue::String("nvim_exec_lua".into()), - RmpValue::Array(vec![ - RmpValue::String(lua_script.into()), - RmpValue::Array(vec![]), - ]), - ]); - - let resp = call_nvim(req).await?; - if let RmpValue::Array(arr) = resp { - if !arr[2].is_nil() { - return Err(format!("Neovim error: {:?}", arr[2])); - } - if let RmpValue::String(s) = &arr[3] { - if let Some(text) = s.as_str() { - return Ok(text.to_string()); - } - } - } - Err("Invalid response".to_string()) -} - -async fn set_nvim_diagnostics(line: i64, message: &str) -> Result<(), String> { - let escaped_message = message.replace("\\", "\\\\").replace("\"", "\\\""); - let lua_script = format!(r#" - local ns = vim.api.nvim_create_namespace("gemini_diagnostics") - local diagnostics = {{{{ - lnum = {} - 1, - col = 0, - severity = vim.diagnostic.severity.WARN, - message = "{}", - }}}} - vim.diagnostic.set(ns, 0, diagnostics, {{}}) - "#, line, escaped_message); - - use rmpv::Value as RmpValue; - let req = RmpValue::Array(vec![ - RmpValue::Integer(0.into()), - RmpValue::Integer(5.into()), // msgid - RmpValue::String("nvim_exec_lua".into()), - RmpValue::Array(vec![ - RmpValue::String(lua_script.into()), - RmpValue::Array(vec![]), - ]), - ]); - - let resp = call_nvim(req).await?; - if let RmpValue::Array(arr) = resp { - if !arr[2].is_nil() { - return Err(format!("Neovim error: {:?}", arr[2])); - } - return Ok(()); - } - Err("Invalid response".to_string()) -} - -fn rmpv_to_json(val: &rmpv::Value) -> serde_json::Value { - match val { - rmpv::Value::Nil => serde_json::Value::Null, - rmpv::Value::Boolean(b) => serde_json::json!(b), - rmpv::Value::Integer(i) => { - if let Some(n) = i.as_i64() { - serde_json::json!(n) - } else if let Some(n) = i.as_u64() { - serde_json::json!(n) - } else { - serde_json::Value::Null - } - }, - rmpv::Value::F32(f) => serde_json::json!(f), - rmpv::Value::F64(f) => serde_json::json!(f), - rmpv::Value::String(s) => { - if let Some(str_val) = s.as_str() { - serde_json::json!(str_val) - } else { - serde_json::Value::Null - } - }, - rmpv::Value::Array(arr) => { - let vec: Vec = arr.iter().map(rmpv_to_json).collect(); - serde_json::Value::Array(vec) - }, - rmpv::Value::Map(map) => { - let mut obj = serde_json::Map::new(); - for (k, v) in map { - let key_str = if let rmpv::Value::String(s) = k { - s.as_str().unwrap_or("").to_string() - } else { - format!("{:?}", k) - }; - obj.insert(key_str, rmpv_to_json(v)); - } - serde_json::Value::Object(obj) - }, - _ => serde_json::json!(format!("{:?}", val)), - } -} - -async fn execute_nvim_lua(code: &str) -> Result { - use rmpv::Value as RmpValue; - let req = RmpValue::Array(vec![ - RmpValue::Integer(0.into()), - RmpValue::Integer(6.into()), // msgid - RmpValue::String("nvim_exec_lua".into()), - RmpValue::Array(vec![ - RmpValue::String(code.into()), - RmpValue::Array(vec![]), - ]), - ]); - - let resp = call_nvim(req).await?; - if let RmpValue::Array(arr) = resp { - if !arr[2].is_nil() { - return Err(format!("Neovim error: {:?}", arr[2])); - } - if arr.len() > 3 { - return Ok(serde_json::to_string_pretty(&rmpv_to_json(&arr[3])).unwrap_or_default()); - } - return Ok("".to_string()); - } - Err("Invalid response".to_string()) +#[cfg(not(unix))] +fn main() { + println!("mcp-memory-linux-nvim is only supported on Unix platforms."); } diff --git a/linux-nvim/src/unix_app.rs b/linux-nvim/src/unix_app.rs new file mode 100644 index 0000000..2b3d0db --- /dev/null +++ b/linux-nvim/src/unix_app.rs @@ -0,0 +1,612 @@ +#[path = "mcp.rs"] +pub mod mcp; +use mcp::{read_message, send_response, send_error, JsonRpcResponse}; +use serde_json::json; +use tokio::net::UnixStream; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +fn init_logging(app_name: &str) -> Option { + let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| { + dirs::home_dir() + .map(|mut h| { + h.push(".gemini/mcp_memory"); + h.to_string_lossy().to_string() + }) + .unwrap_or_else(|| ".gemini/mcp_memory".into()) + }); + let log_dir = std::path::PathBuf::from(base_dir).join("logs"); + std::fs::create_dir_all(&log_dir).unwrap_or_default(); + + let file_appender = tracing_appender::rolling::daily(log_dir, format!("{}.log", app_name)); + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + + let _ = tracing_subscriber::fmt() + .with_writer(non_blocking) + .with_ansi(false) + .with_max_level(tracing::Level::INFO) + .try_init(); + + Some(guard) +} + +#[tokio::main] +pub async fn main() { + let _guard = init_logging("linux-nvim"); + let mut stdin = tokio::io::BufReader::new(tokio::io::stdin()); + loop { + let msg = match read_message(&mut stdin).await { + Some(m) => m, + None => break, + }; + + tokio::spawn(async move { + let id = msg.id.clone().unwrap_or(json!(null)); + + match msg.method.as_str() { + "initialize" => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "capabilities": {}, + "serverInfo": { + "name": "mcp-memory-linux-nvim", + "version": "0.1.0" + } + })), + error: None, + }).await; + } + "tools/list" => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "tools": [ + { + "name": "nvim_goto_line", + "description": "Open a file and jump to a specific line", + "inputSchema": { + "type": "object", + "properties": { + "file": { "type": "string" }, + "line": { "type": "integer" } + }, + "required": ["file", "line"] + } + }, + { + "name": "nvim_get_active_buffer", + "description": "Get the contents of the currently active Neovim buffer", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "nvim_get_cursor", + "description": "Get the current cursor position (line and column) in the active Neovim buffer", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "nvim_get_visual_selection", + "description": "Get the text that is currently highlighted or was last highlighted in Visual mode", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "nvim_set_diagnostics", + "description": "Push a diagnostic message (like an LSP warning) to a specific line in the active buffer", + "inputSchema": { + "type": "object", + "properties": { + "line": { "type": "integer" }, + "message": { "type": "string" } + }, + "required": ["line", "message"] + } + }, + { + "name": "nvim_execute_lua", + "description": "Execute arbitrary Lua code in Neovim and return the result (JSON serialized).", + "inputSchema": { + "type": "object", + "properties": { + "code": { "type": "string" } + }, + "required": ["code"] + } + }, + { + "name": "nvim_list_buffers", + "description": "Get a list of all loaded Neovim buffers and their IDs.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "nvim_get_diagnostics", + "description": "Get all LSP diagnostics (errors, warnings) for the active buffer.", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ] + })), + error: None, + }).await; + } + "tools/call" => { + let params = msg.params.clone().unwrap_or(json!({})); + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let args = params.get("arguments").cloned().unwrap_or(json!({})); + + match name { + "nvim_goto_line" => { + let file = args.get("file").and_then(|v| v.as_str()).unwrap_or(""); + let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(1); + + let cmd = format!("edit {} | {} | normal! zz", file, line); + match send_nvim_command(&cmd).await { + Ok(_) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "content": [ + { "type": "text", "text": format!("Successfully jumped to {}:{}", file, line) } + ] + })), + error: None, + }).await; + } + Err(e) => { + send_error(id, -32603, &format!("Failed to execute command: {}", e)).await; + } + } + } + "nvim_get_active_buffer" => { + match get_nvim_active_buffer().await { + Ok(content) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "content": [ + { "type": "text", "text": content } + ] + })), + error: None, + }).await; + } + Err(e) => { + send_error(id, -32603, &format!("Failed to get active buffer: {}", e)).await; + } + } + } + "nvim_get_cursor" => { + match get_nvim_cursor().await { + Ok(content) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "content": [ + { "type": "text", "text": content } + ] + })), + error: None, + }).await; + } + Err(e) => { + send_error(id, -32603, &format!("Failed to get cursor: {}", e)).await; + } + } + } + "nvim_get_visual_selection" => { + match get_nvim_visual_selection().await { + Ok(content) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "content": [ + { "type": "text", "text": content } + ] + })), + error: None, + }).await; + } + Err(e) => { + send_error(id, -32603, &format!("Failed to get visual selection: {}", e)).await; + } + } + } + "nvim_set_diagnostics" => { + let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(1); + let message = args.get("message").and_then(|v| v.as_str()).unwrap_or(""); + + match set_nvim_diagnostics(line, message).await { + Ok(_) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "content": [ + { "type": "text", "text": format!("Successfully set diagnostic on line {}", line) } + ] + })), + error: None, + }).await; + } + Err(e) => { + send_error(id, -32603, &format!("Failed to set diagnostic: {}", e)).await; + } + } + } + + "nvim_execute_lua" => { + let code = args.get("code").and_then(|v| v.as_str()).unwrap_or(""); + match execute_nvim_lua(code).await { + Ok(content) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), id, result: Some(json!({ "content": [{ "type": "text", "text": content }] })), error: None, + }).await; + } + Err(e) => { send_error(id, -32603, &format!("Failed to execute lua: {}", e)).await; } + } + } + "nvim_list_buffers" => { + let code = r#" + local bufs = vim.api.nvim_list_bufs() + local loaded = {} + for _, b in ipairs(bufs) do + if vim.api.nvim_buf_is_loaded(b) then + local name = vim.api.nvim_buf_get_name(b) + table.insert(loaded, {id = b, name = name == "" and "[No Name]" or name}) + end + end + return loaded + "#; + match execute_nvim_lua(code).await { + Ok(content) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), id, result: Some(json!({ "content": [{ "type": "text", "text": content }] })), error: None, + }).await; + } + Err(e) => { send_error(id, -32603, &format!("Failed to list buffers: {}", e)).await; } + } + } + "nvim_get_diagnostics" => { + let code = r#" + local diags = vim.diagnostic.get(0) + local res = {} + for _, d in ipairs(diags) do + table.insert(res, { + line = d.lnum + 1, + col = d.col, + message = d.message, + severity = d.severity + }) + end + return res + "#; + match execute_nvim_lua(code).await { + Ok(content) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), id, result: Some(json!({ "content": [{ "type": "text", "text": content }] })), error: None, + }).await; + } + Err(e) => { send_error(id, -32603, &format!("Failed to get diagnostics: {}", e)).await; } + } + } + _ => { + send_error(id, -32601, "Tool not found").await; + } + } + } + _ => { + // Ignore other methods + } + } + }); + } +} + +async fn get_socket_path() -> Result { + // 1. Try active_nvim.txt first + if let Ok(home) = std::env::var("HOME") { + let path = format!("{}/.gemini/active_nvim.txt", home); + if let Ok(content) = std::fs::read_to_string(&path) { + let p = content.trim().to_string(); + if !p.is_empty() { + return Ok(p); + } + } + } + + // 2. Fallback: search /tmp/nvim.*/0 + if let Ok(entries) = std::fs::read_dir("/tmp") { + for entry in entries.flatten() { + if let Ok(name) = entry.file_name().into_string() { + if name.starts_with("nvim.") { + let path = entry.path().join("0"); + if path.exists() { + return Ok(path.to_string_lossy().to_string()); + } + } + } + } + } + Err("Could not find Neovim socket".to_string()) +} + +async fn call_nvim(req: rmpv::Value) -> Result { + let msgid = if let rmpv::Value::Array(ref arr) = req { + if arr.len() > 1 { arr[1].clone() } else { rmpv::Value::Nil } + } else { rmpv::Value::Nil }; + + let socket_path = get_socket_path().await?; + let mut stream = UnixStream::connect(socket_path).await.map_err(|e| e.to_string())?; + + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &req).map_err(|e| e.to_string())?; + stream.write_all(&buf).await.map_err(|e| e.to_string())?; + + let mut resp_buf = Vec::new(); + let mut chunk = vec![0u8; 8192]; + let mut offset = 0; + + loop { + let mut cursor = std::io::Cursor::new(&resp_buf[offset..]); + match rmpv::decode::read_value(&mut cursor) { + Ok(val) => { + offset += cursor.position() as usize; + + if let rmpv::Value::Array(ref arr) = val { + if arr.len() >= 4 && arr[0] == rmpv::Value::Integer(1.into()) && arr[1] == msgid { + return Ok(val); + } + } + continue; + }, + Err(_) => { + let read_future = stream.read(&mut chunk); + match tokio::time::timeout(tokio::time::Duration::from_secs(5), read_future).await { + Ok(Ok(n)) => { + if n == 0 { return Err("Connection closed".into()); } + resp_buf.extend_from_slice(&chunk[..n]); + } + Ok(Err(e)) => return Err(e.to_string()), + Err(_) => return Err("Timeout waiting for Neovim response".into()), + } + } + } + } +} + +async fn send_nvim_command(cmd: &str) -> Result<(), String> { + use rmpv::Value as RmpValue; + let req = RmpValue::Array(vec![ + RmpValue::Integer(0.into()), + RmpValue::Integer(1.into()), // msgid + RmpValue::String("nvim_command".into()), + RmpValue::Array(vec![RmpValue::String(cmd.into())]), + ]); + + let resp = call_nvim(req).await?; + if let RmpValue::Array(arr) = resp { + if !arr[2].is_nil() { + return Err(format!("Neovim error: {:?}", arr[2])); + } + return Ok(()); + } + Err("Invalid response".to_string()) +} + +async fn get_nvim_active_buffer() -> Result { + use rmpv::Value as RmpValue; + let req = RmpValue::Array(vec![ + RmpValue::Integer(0.into()), + RmpValue::Integer(2.into()), // msgid + RmpValue::String("nvim_buf_get_lines".into()), + RmpValue::Array(vec![ + RmpValue::Integer(0.into()), + RmpValue::Integer(0.into()), + RmpValue::Integer((-1).into()), + RmpValue::Boolean(true), + ]), + ]); + + let resp = call_nvim(req).await?; + if let RmpValue::Array(arr) = resp { + if !arr[2].is_nil() { + return Err(format!("Neovim error: {:?}", arr[2])); + } + if let RmpValue::Array(lines) = &arr[3] { + let mut text = String::new(); + for line in lines { + if let RmpValue::String(s) = line { + if let Some(s) = s.as_str() { + text.push_str(s); + text.push('\n'); + } + } + } + return Ok(text); + } + } + Err("Invalid response".to_string()) +} + +async fn get_nvim_cursor() -> Result { + use rmpv::Value as RmpValue; + let req = RmpValue::Array(vec![ + RmpValue::Integer(0.into()), + RmpValue::Integer(3.into()), // msgid + RmpValue::String("nvim_win_get_cursor".into()), + RmpValue::Array(vec![ + RmpValue::Integer(0.into()), + ]), + ]); + + let resp = call_nvim(req).await?; + if let RmpValue::Array(arr) = resp { + if !arr[2].is_nil() { + return Err(format!("Neovim error: {:?}", arr[2])); + } + if let RmpValue::Array(pos) = &arr[3] { + if pos.len() == 2 { + if let (RmpValue::Integer(row), RmpValue::Integer(col)) = (&pos[0], &pos[1]) { + return Ok(format!("Line: {}, Column: {}", row, col)); + } + } + } + } + Err("Invalid response".to_string()) +} + +async fn get_nvim_visual_selection() -> Result { + let lua_script = r#" + local _, csrow, cscol, _ = unpack(vim.fn.getpos("'<")) + local _, cerow, cecol, _ = unpack(vim.fn.getpos("'>")) + local lines = vim.fn.getline(csrow, cerow) + if type(lines) == "table" then + return table.concat(lines, "\n") + else + return lines + end + "#; + + use rmpv::Value as RmpValue; + let req = RmpValue::Array(vec![ + RmpValue::Integer(0.into()), + RmpValue::Integer(4.into()), // msgid + RmpValue::String("nvim_exec_lua".into()), + RmpValue::Array(vec![ + RmpValue::String(lua_script.into()), + RmpValue::Array(vec![]), + ]), + ]); + + let resp = call_nvim(req).await?; + if let RmpValue::Array(arr) = resp { + if !arr[2].is_nil() { + return Err(format!("Neovim error: {:?}", arr[2])); + } + if let RmpValue::String(s) = &arr[3] { + if let Some(text) = s.as_str() { + return Ok(text.to_string()); + } + } + } + Err("Invalid response".to_string()) +} + +async fn set_nvim_diagnostics(line: i64, message: &str) -> Result<(), String> { + let escaped_message = message.replace("\\", "\\\\").replace("\"", "\\\""); + let lua_script = format!(r#" + local ns = vim.api.nvim_create_namespace("gemini_diagnostics") + local diagnostics = {{{{ + lnum = {} - 1, + col = 0, + severity = vim.diagnostic.severity.WARN, + message = "{}", + }}}} + vim.diagnostic.set(ns, 0, diagnostics, {{}}) + "#, line, escaped_message); + + use rmpv::Value as RmpValue; + let req = RmpValue::Array(vec![ + RmpValue::Integer(0.into()), + RmpValue::Integer(5.into()), // msgid + RmpValue::String("nvim_exec_lua".into()), + RmpValue::Array(vec![ + RmpValue::String(lua_script.into()), + RmpValue::Array(vec![]), + ]), + ]); + + let resp = call_nvim(req).await?; + if let RmpValue::Array(arr) = resp { + if !arr[2].is_nil() { + return Err(format!("Neovim error: {:?}", arr[2])); + } + return Ok(()); + } + Err("Invalid response".to_string()) +} + +fn rmpv_to_json(val: &rmpv::Value) -> serde_json::Value { + match val { + rmpv::Value::Nil => serde_json::Value::Null, + rmpv::Value::Boolean(b) => serde_json::json!(b), + rmpv::Value::Integer(i) => { + if let Some(n) = i.as_i64() { + serde_json::json!(n) + } else if let Some(n) = i.as_u64() { + serde_json::json!(n) + } else { + serde_json::Value::Null + } + }, + rmpv::Value::F32(f) => serde_json::json!(f), + rmpv::Value::F64(f) => serde_json::json!(f), + rmpv::Value::String(s) => { + if let Some(str_val) = s.as_str() { + serde_json::json!(str_val) + } else { + serde_json::Value::Null + } + }, + rmpv::Value::Array(arr) => { + let vec: Vec = arr.iter().map(rmpv_to_json).collect(); + serde_json::Value::Array(vec) + }, + rmpv::Value::Map(map) => { + let mut obj = serde_json::Map::new(); + for (k, v) in map { + let key_str = if let rmpv::Value::String(s) = k { + s.as_str().unwrap_or("").to_string() + } else { + format!("{:?}", k) + }; + obj.insert(key_str, rmpv_to_json(v)); + } + serde_json::Value::Object(obj) + }, + _ => serde_json::json!(format!("{:?}", val)), + } +} + +async fn execute_nvim_lua(code: &str) -> Result { + use rmpv::Value as RmpValue; + let req = RmpValue::Array(vec![ + RmpValue::Integer(0.into()), + RmpValue::Integer(6.into()), // msgid + RmpValue::String("nvim_exec_lua".into()), + RmpValue::Array(vec![ + RmpValue::String(code.into()), + RmpValue::Array(vec![]), + ]), + ]); + + let resp = call_nvim(req).await?; + if let RmpValue::Array(arr) = resp { + if !arr[2].is_nil() { + return Err(format!("Neovim error: {:?}", arr[2])); + } + if arr.len() > 3 { + return Ok(serde_json::to_string_pretty(&rmpv_to_json(&arr[3])).unwrap_or_default()); + } + return Ok("".to_string()); + } + Err("Invalid response".to_string()) +} diff --git a/server/build.rs b/server/build.rs index 0110eb9..0e0609c 100644 --- a/server/build.rs +++ b/server/build.rs @@ -1,16 +1,20 @@ use std::process::Command; -use chrono::Local; fn main() { - let now = Local::now(); - let build_date = now.format("%Y.%m.%d").to_string(); - println!("cargo:rustc-env=BUILD_DATE={}", build_date); + let git_hash = Command::new("git") + .args(&["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .and_then(|out| String::from_utf8(out.stdout).ok()) + .unwrap_or_else(|| "unknown".to_string()); + + let git_date = Command::new("git") + .args(&["log", "-1", "--format=%cd", "--date=format:%Y.%m.%d"]) + .output() + .ok() + .and_then(|out| String::from_utf8(out.stdout).ok()) + .unwrap_or_else(|| "unknown".to_string()); - if let Ok(output) = Command::new("git").args(&["rev-parse", "--short", "HEAD"]).output() { - if let Ok(hash) = String::from_utf8(output.stdout) { - println!("cargo:rustc-env=GIT_HASH={}", hash.trim()); - } - } - println!("cargo:rerun-if-changed=../.git/HEAD"); - println!("cargo:rerun-if-changed=../.git/index"); + let version = format!("{} ({} {})", env!("CARGO_PKG_VERSION"), git_date.trim(), git_hash.trim()); + println!("cargo:rustc-env=APP_VERSION={}", version); } diff --git a/server/src/handlers.rs b/server/src/handlers.rs index 80923e7..fdb192f 100644 --- a/server/src/handlers.rs +++ b/server/src/handlers.rs @@ -2,7 +2,7 @@ use crate::models::*; use crate::state::MemoryState; use crate::tools::*; -use serde::{Deserialize, de::DeserializeOwned}; +use serde::de::DeserializeOwned; use std::collections::HashSet; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -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" @@ -1608,6 +1610,981 @@ crate::mcp::tool_def::("create_entities", "Create new entiti } } +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use crate::state::MemoryState; + use serde_json::json; + + #[tokio::test] + async fn test_handle_initialize() { + let store_dir = std::env::temp_dir().join(format!("mcp_test_handlers_{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs())); + std::fs::create_dir_all(&store_dir).unwrap(); + let redb_path = store_dir.join("mcp_store.redb"); + let db = Arc::new(redb::Database::create(&redb_path).unwrap()); + + { + let write_txn = db.begin_write().unwrap(); + let _ = write_txn.open_table(crate::store::STORE_TABLE); + write_txn.commit().unwrap(); + } + + let state = Arc::new(MemoryState { + base_dir: store_dir.clone(), + master_path: store_dir.join("master.json"), + session_graph: std::sync::RwLock::new(crate::models::KnowledgeGraph::default()), + master_cache: std::sync::RwLock::new((crate::models::KnowledgeGraph::default(), std::time::SystemTime::UNIX_EPOCH)), + search_index: std::sync::RwLock::new(crate::search::MemoryIndex::new(&store_dir).unwrap()), + ledger: crate::store::Store::new("audit_ledger", db.clone()), + sticky: crate::store::Store::new("sticky_notes", db.clone()), + tasks: crate::store::Store::new("tasks", db.clone()), + snippets: crate::store::Store::new("snippets", db.clone()), + adrs: crate::store::Store::new("adrs", db.clone()), + prefs: crate::store::Store::new("preferences", db.clone()), + error_fixes: crate::store::Store::new("error_fixes", db.clone()), + pinned_files: crate::store::Store::new("pinned_files", db.clone()), + session_summaries: crate::store::Store::new("session_summaries", db.clone()), + handoff_memos: crate::store::Store::new("handoff_memos", db.clone()), + env_fingerprints: crate::store::Store::new("env_fingerprints", db.clone()), + env_requirements: crate::store::Store::new("env_requirements", db.clone()), + milestones: crate::store::Store::new("milestones", db.clone()), + environments: crate::store::Store::new("environments", db.clone()), + pr_checklists: crate::store::Store::new("pr_checklists", db.clone()), + tech_debts: crate::store::Store::new("tech_debts", db.clone()), + gates: crate::store::Store::new("gates", db.clone()), + context_workspaces: crate::store::Store::new("context_workspaces", db.clone()), + }); + let handler = MemoryHandler { state }; + + let req = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } + }); + + let response = handler.handle_request(req).await.expect("Expected a response"); + + assert_eq!(response["id"], 1); + assert!(response.get("result").is_some()); + + let result = &response["result"]; + assert_eq!(result["protocolVersion"], "2024-11-05"); + + // CRITICAL BUG FIX CHECK: capabilities MUST contain an empty tools object + // Note: Currently it is set to `{}` which may cause proxy dropping tools. Let's verify it matches the actual behavior. + assert_eq!(result["capabilities"], json!({})); + assert_eq!(result["serverInfo"]["name"], "gemini-mcp-memory"); + } + + fn setup_test_handler(test_name: &str) -> MemoryHandler { + let store_dir = std::env::temp_dir().join(format!("mcp_test_handlers_{}_{}", test_name, uuid::Uuid::new_v4())); + std::fs::create_dir_all(&store_dir).unwrap(); + let redb_path = store_dir.join("mcp_store.redb"); + let db = Arc::new(redb::Database::create(&redb_path).unwrap()); + { + let write_txn = db.begin_write().unwrap(); + let _ = write_txn.open_table(crate::store::STORE_TABLE); + write_txn.commit().unwrap(); + } + + let state = Arc::new(MemoryState { + base_dir: store_dir.clone(), + master_path: store_dir.join("master.json"), + session_graph: std::sync::RwLock::new(crate::models::KnowledgeGraph::default()), + master_cache: std::sync::RwLock::new((crate::models::KnowledgeGraph::default(), std::time::SystemTime::UNIX_EPOCH)), + search_index: std::sync::RwLock::new(crate::search::MemoryIndex::new(&store_dir).unwrap()), + ledger: crate::store::Store::new("audit_ledger", db.clone()), + sticky: crate::store::Store::new("sticky_notes", db.clone()), + tasks: crate::store::Store::new("tasks", db.clone()), + snippets: crate::store::Store::new("snippets", db.clone()), + adrs: crate::store::Store::new("adrs", db.clone()), + prefs: crate::store::Store::new("preferences", db.clone()), + error_fixes: crate::store::Store::new("error_fixes", db.clone()), + pinned_files: crate::store::Store::new("pinned_files", db.clone()), + session_summaries: crate::store::Store::new("session_summaries", db.clone()), + handoff_memos: crate::store::Store::new("handoff_memos", db.clone()), + env_fingerprints: crate::store::Store::new("env_fingerprints", db.clone()), + env_requirements: crate::store::Store::new("env_requirements", db.clone()), + milestones: crate::store::Store::new("milestones", db.clone()), + environments: crate::store::Store::new("environments", db.clone()), + pr_checklists: crate::store::Store::new("pr_checklists", db.clone()), + tech_debts: crate::store::Store::new("tech_debts", db.clone()), + gates: crate::store::Store::new("gates", db.clone()), + context_workspaces: crate::store::Store::new("context_workspaces", db.clone()), + }); + MemoryHandler { state } + } + + #[tokio::test] + async fn test_handle_tools_list() { + let handler = setup_test_handler("tools_list"); + + let req = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + }); + + let response = handler.handle_request(req).await.expect("Expected a response"); + assert_eq!(response["id"], 2); + + let tools = response["result"]["tools"].as_array().expect("Tools must be an array"); + assert!(!tools.is_empty()); + + // Verify a specific tool is registered + let add_task_tool = tools.iter().find(|t| t["name"] == "add_task").expect("add_task tool missing"); + assert_eq!(add_task_tool["description"], "Add a new task to the task tracker."); + } + + #[tokio::test] + async fn test_handle_add_task() { + let handler = setup_test_handler("add_task"); + + let req = json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "add_task", + "arguments": { + "title": "Fix bug in handlers", + "description": "The proxy drops capabilities.", + "git_branch": "master" + } + } + }); + + let response = handler.handle_request(req).await.expect("Expected a response"); + assert_eq!(response["id"], 3); + + let content = &response["result"]["content"][0]; + assert_eq!(content["type"], "text"); + assert!(content["text"].as_str().unwrap().starts_with("Task added with ID: ")); + + // Verify task was actually added to store + let tasks = handler.state.tasks.read(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].title, "Fix bug in handlers"); + assert_eq!(tasks[0].status, "pending"); + } + + #[tokio::test] + async fn test_handle_create_entities() { + let handler = setup_test_handler("create_entities"); + + let req = json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "create_entities", + "arguments": { + "entities": [ + { + "name": "MemoryHandler", + "entityType": "struct", + "observations": ["Handles MCP requests natively"], + "namespace": "core" + } + ] + } + } + }); + + let response = handler.handle_request(req).await.expect("Expected a response"); + assert_eq!(response["id"], 4); + + let content = &response["result"]["content"][0]; + assert_eq!(content["text"], "Entities created"); + + // Verify entity was actually added to state + let session_graph = handler.state.session_graph.read().unwrap(); + let entity = session_graph.entities.get("MemoryHandler").expect("Entity should be in session graph"); + assert_eq!(entity.entity_type, "struct"); + assert_eq!(entity.observations, vec!["Handles MCP requests natively"]); + assert_eq!(entity.namespace, "core".to_string()); + } + + #[tokio::test] + async fn test_handle_store_snippet() { + let handler = setup_test_handler("store_snippet"); + + let req = json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "store_snippet", + "arguments": { + "name": "Test Snippet", + "description": "A snippet used for testing", + "language": "rust", + "code": "fn main() { println!(\"Hello, World!\"); }" + } + } + }); + + let response = handler.handle_request(req).await.expect("Expected a response"); + assert_eq!(response["id"], 5); + + let snippets = handler.state.snippets.read(); + assert_eq!(snippets.len(), 1); + assert_eq!(snippets[0].name, "Test Snippet"); + assert_eq!(snippets[0].language, "rust"); + } + + #[tokio::test] + async fn test_handle_add_sticky_note() { + let handler = setup_test_handler("add_sticky_note"); + + let req = json!({ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "add_sticky_note", + "arguments": { + "content": "Don't forget to check coverage!" + } + } + }); + + let response = handler.handle_request(req).await.expect("Expected a response"); + assert_eq!(response["id"], 6); + + let notes = handler.state.sticky.read(); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].content, "Don't forget to check coverage!"); + } + + #[tokio::test] + async fn test_handle_create_relations() { + let handler = setup_test_handler("create_relations"); + let req = json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": { + "name": "create_relations", + "arguments": { + "relations": [ + { + "from": "NodeA", + "to": "NodeB", + "relationType": "depends_on", + "namespace": "core" + } + ] + } + } + }); + let response = handler.handle_request(req).await.unwrap(); + assert_eq!(response["id"], 7); + let session = handler.state.session_graph.read().unwrap(); + assert_eq!(session.relations.len(), 1); + assert_eq!(session.relations[0].from, "NodeA"); + assert_eq!(session.relations[0].to, "NodeB"); + } + + #[tokio::test] + async fn test_handle_add_observations() { + let handler = setup_test_handler("add_observations"); + // Pre-populate entity + { + let mut session = handler.state.session_graph.write().unwrap(); + session.entities.insert("NodeA".to_string(), crate::models::Entity { + name: "NodeA".to_string(), + entity_type: "class".to_string(), + observations: vec!["Initial".to_string()], + namespace: "".to_string(), + git_branch: None, + }); + } + let req = json!({ + "jsonrpc": "2.0", + "id": 8, + "method": "tools/call", + "params": { + "name": "add_observations", + "arguments": { + "observations": [ + { + "entityName": "NodeA", + "contents": ["New observation"] + } + ] + } + } + }); + let _ = handler.handle_request(req).await.unwrap(); + let session = handler.state.session_graph.read().unwrap(); + let entity = session.entities.get("NodeA").unwrap(); + assert_eq!(entity.observations, vec!["Initial", "New observation"]); + } + + #[tokio::test] + async fn test_handle_delete_entities() { + let handler = setup_test_handler("delete_entities"); + { + let mut session = handler.state.session_graph.write().unwrap(); + session.entities.insert("ToDelete".to_string(), crate::models::Entity { + name: "ToDelete".to_string(), + entity_type: "var".to_string(), + observations: vec![], + namespace: "".to_string(), + git_branch: None, + }); + } + // Force flush session to master + handler.state.apply_sync_write(|_| {}).await; + + let req = json!({ + "jsonrpc": "2.0", + "id": 9, + "method": "tools/call", + "params": { + "name": "delete_entities", + "arguments": { + "entityNames": ["ToDelete"] + } + } + }); + let _ = handler.handle_request(req).await.unwrap(); + let full_graph = handler.state.get_full_graph(); + assert!(full_graph.entities.get("ToDelete").is_none()); + } + + #[tokio::test] + async fn test_handle_delete_observations() { + let handler = setup_test_handler("delete_observations"); + { + let mut session = handler.state.session_graph.write().unwrap(); + session.entities.insert("NodeA".to_string(), crate::models::Entity { + name: "NodeA".to_string(), + entity_type: "class".to_string(), + observations: vec!["Keep".to_string(), "Drop".to_string()], + namespace: "".to_string(), + git_branch: None, + }); + } + handler.state.apply_sync_write(|_| {}).await; + let req = json!({ + "jsonrpc": "2.0", + "id": 10, + "method": "tools/call", + "params": { + "name": "delete_observations", + "arguments": { + "deletions": [ + { + "entityName": "NodeA", + "observations": ["Drop"] + } + ] + } + } + }); + let _ = handler.handle_request(req).await.unwrap(); + let full = handler.state.get_full_graph(); + let entity = full.entities.get("NodeA").unwrap(); + assert_eq!(entity.observations, vec!["Keep"]); + } + + #[tokio::test] + async fn test_handle_log_code_change() { + let handler = setup_test_handler("log_code_change"); + let req = json!({ + "jsonrpc": "2.0", + "id": 11, + "method": "tools/call", + "params": { + "name": "log_code_change", + "arguments": { + "filePath": "server/src/handlers.rs", + "description": "Added some unit tests", + "git_commit": "1234567" + } + } + }); + let response = handler.handle_request(req).await.unwrap(); + assert_eq!(response["id"], 11); + let ledger = handler.state.ledger.read(); + assert_eq!(ledger.len(), 1); + assert_eq!(ledger[0].file_path, "server/src/handlers.rs"); + assert_eq!(ledger[0].git_commit.as_deref(), Some("1234567")); + } + + #[tokio::test] + async fn test_handle_list_active_tasks() { + let handler = setup_test_handler("list_active_tasks"); + handler.state.tasks.modify(|tasks| { + tasks.push(crate::models::Task { + id: "1".to_string(), + title: "Active Task".to_string(), + status: "pending".to_string(), + description: "".to_string(), + created_at: 0, + updated_at: 0, + git_branch: None, + }); + tasks.push(crate::models::Task { + id: "2".to_string(), + title: "Completed Task".to_string(), + status: "done".to_string(), + description: "".to_string(), + created_at: 0, + updated_at: 0, + git_branch: None, + }); + }); + + let req = json!({ + "jsonrpc": "2.0", + "id": 12, + "method": "tools/call", + "params": { + "name": "list_active_tasks", + "arguments": {} + } + }); + + let response = handler.handle_request(req).await.unwrap(); + let content = response["result"]["content"][0]["text"].as_str().unwrap(); + assert!(content.contains("Active Task")); + assert!(!content.contains("Completed Task")); + } + + #[tokio::test] + async fn test_handle_search_snippets() { + let handler = setup_test_handler("search_snippets"); + handler.state.snippets.modify(|snippets| { + snippets.push(crate::models::Snippet { + name: "React hook".to_string(), + language: "typescript".to_string(), + code: "useMemo(() => {}, [])".to_string(), + description: "React memoization".to_string(), + updated_at: 0, + }); + snippets.push(crate::models::Snippet { + name: "Rust struct".to_string(), + language: "rust".to_string(), + code: "struct A {}".to_string(), + description: "Rust code".to_string(), + updated_at: 0, + }); + }); + + let req = json!({ + "jsonrpc": "2.0", + "id": 13, + "method": "tools/call", + "params": { + "name": "search_snippets", + "arguments": { + "query": "React" + } + } + }); + let response = handler.handle_request(req).await.unwrap(); + let content = response["result"]["content"][0]["text"].as_str().unwrap(); + assert!(content.contains("React hook")); + assert!(!content.contains("Rust struct")); + } + + #[tokio::test] + async fn test_handle_read_sticky_notes() { + let handler = setup_test_handler("read_sticky_notes"); + handler.state.sticky.modify(|sticky| { + sticky.push(crate::models::StickyNote { + content: "Remember to commit".to_string(), + timestamp: 0, + }); + }); + + let req = json!({ + "jsonrpc": "2.0", + "id": 14, + "method": "tools/call", + "params": { + "name": "read_sticky_notes", + "arguments": {} + } + }); + let response = handler.handle_request(req).await.unwrap(); + let content = response["result"]["content"][0]["text"].as_str().unwrap(); + assert!(content.contains("Remember to commit")); + } + + #[tokio::test] + async fn test_handle_delete_relations() { + let handler = setup_test_handler("delete_relations"); + { + let mut session = handler.state.session_graph.write().unwrap(); + session.relations.push(crate::models::Relation { + from: "A".to_string(), + to: "B".to_string(), + relation_type: "calls".to_string(), + namespace: "".to_string(), + }); + } + handler.state.apply_sync_write(|_| {}).await; + + let req = json!({ + "jsonrpc": "2.0", + "id": 15, + "method": "tools/call", + "params": { + "name": "delete_relations", + "arguments": { + "relations": [ + { + "from": "A", + "to": "B", + "relationType": "calls", + "namespace": "" + } + ] + } + } + }); + let _ = handler.handle_request(req).await.unwrap(); + let full = handler.state.get_full_graph(); + assert!(full.relations.is_empty()); + } + + #[tokio::test] + async fn test_handle_read_graph() { + let handler = setup_test_handler("read_graph"); + { + let mut session = handler.state.session_graph.write().unwrap(); + session.entities.insert("NodeA".to_string(), crate::models::Entity { + name: "NodeA".to_string(), + entity_type: "var".to_string(), + observations: vec![], + namespace: "".to_string(), + git_branch: None, + }); + } + handler.state.apply_sync_write(|_| {}).await; + + let req = json!({ + "jsonrpc": "2.0", + "id": 16, + "method": "tools/call", + "params": { + "name": "read_graph", + "arguments": {} + } + }); + let response = handler.handle_request(req).await.unwrap(); + let content = response["result"]["content"][0]["text"].as_str().unwrap(); + assert!(content.contains("NodeA")); + } + + #[tokio::test] + async fn test_handle_open_nodes() { + let handler = setup_test_handler("open_nodes"); + let entity = crate::models::Entity { + name: "UserRepository".to_string(), + entity_type: "class".to_string(), + observations: vec!["Handles user data".to_string()], + namespace: "".to_string(), + git_branch: None, + }; + { + let mut session = handler.state.session_graph.write().unwrap(); + session.entities.insert("UserRepository".to_string(), entity); + } + handler.state.apply_sync_write(|_| {}).await; + + let req = json!({ + "jsonrpc": "2.0", + "id": 17, + "method": "tools/call", + "params": { + "name": "open_nodes", + "arguments": { + "names": ["UserRepository"] + } + } + }); + let response = handler.handle_request(req).await.unwrap(); + let content = response["result"]["content"][0]["text"].as_str().unwrap(); + assert!(content.contains("UserRepository")); + assert!(content.contains("Handles user data")); + } + + #[tokio::test] + async fn test_handle_log_decision() { + let handler = setup_test_handler("log_decision"); + let req = json!({ + "jsonrpc": "2.0", + "id": 20, + "method": "tools/call", + "params": { + "name": "log_decision", + "arguments": { + "title": "Use async I/O", + "context": "Need better throughput", + "decision": "Use tokio", + "consequence": "Requires async all the way down" + } + } + }); + let _ = handler.handle_request(req).await.unwrap(); + let adrs = handler.state.adrs.read(); + assert_eq!(adrs.len(), 1); + assert_eq!(adrs[0].title, "Use async I/O"); + } + + #[tokio::test] + async fn test_handle_query_decisions() { + let handler = setup_test_handler("query_decisions"); + handler.state.adrs.modify(|adrs| { + adrs.push(crate::models::Adr { + id: "adr-1".to_string(), + title: "Use PostgreSQL".to_string(), + context: "Need relational data".to_string(), + decision: "Use pg".to_string(), + consequence: "Maintenance overhead".to_string(), + timestamp: 0, + }); + }); + let req = json!({ + "jsonrpc": "2.0", + "id": 21, + "method": "tools/call", + "params": { + "name": "query_decisions", + "arguments": { + "query": "Postgre" + } + } + }); + let response = handler.handle_request(req).await.unwrap(); + let content = response["result"]["content"][0]["text"].as_str().unwrap(); + assert!(content.contains("PostgreSQL")); + } + + #[tokio::test] + async fn test_handle_log_error_fix() { + let handler = setup_test_handler("log_error_fix"); + let req = json!({ + "jsonrpc": "2.0", + "id": 22, + "method": "tools/call", + "params": { + "name": "log_error_fix", + "arguments": { + "signature": "IndexOutOfBounds", + "solution": "Check array length" + } + } + }); + let _ = handler.handle_request(req).await.unwrap(); + let fixes = handler.state.error_fixes.read(); + assert_eq!(fixes.len(), 1); + assert_eq!(fixes[0].signature, "IndexOutOfBounds"); + } + + #[tokio::test] + async fn test_handle_search_error_fixes() { + let handler = setup_test_handler("search_error_fixes"); + handler.state.error_fixes.modify(|fixes| { + fixes.push(crate::models::ErrorFix { + signature: "NullPointerException".to_string(), + solution: "Initialize the pointer".to_string(), + timestamp: 0, + git_branch: None, + git_commit: None, + }); + }); + let req = json!({ + "jsonrpc": "2.0", + "id": 23, + "method": "tools/call", + "params": { + "name": "search_error_fixes", + "arguments": { + "query": "NullPointer" + } + } + }); + let response = handler.handle_request(req).await.unwrap(); + let content = response["result"]["content"][0]["text"].as_str().unwrap(); + assert!(content.contains("Initialize the pointer")); + } + + #[tokio::test] + async fn test_handle_list_pinned_files() { + let handler = setup_test_handler("list_pinned_files"); + handler.state.pinned_files.modify(|files| { + files.push(crate::models::PinnedFile { + file_path: "src/important.rs".to_string(), + timestamp: 0, + namespace: "".to_string(), + git_branch: None, + }); + }); + let req = json!({ + "jsonrpc": "2.0", + "id": 24, + "method": "tools/call", + "params": { + "name": "list_pinned_files", + "arguments": {} + } + }); + let response = handler.handle_request(req).await.unwrap(); + let content = response["result"]["content"][0]["text"].as_str().unwrap(); + assert!(content.contains("important.rs")); + } + + #[tokio::test] + async fn test_handle_add_session_summary() { + let handler = setup_test_handler("add_session_summary"); + let req = json!({ + "jsonrpc": "2.0", + "id": 25, + "method": "tools/call", + "params": { + "name": "add_session_summary", + "arguments": { + "namespace": "", + "summary": "Finished writing tests" + } + } + }); + let _ = handler.handle_request(req).await.unwrap(); + let summaries = handler.state.session_summaries.read(); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].summary, "Finished writing tests"); + } + + #[tokio::test] + async fn test_handle_get_project_timeline() { + let handler = setup_test_handler("get_project_timeline"); + handler.state.session_summaries.modify(|summaries| { + summaries.push(crate::models::SessionSummary { + summary: "Day 1: Setup project".to_string(), + namespace: "".to_string(), + timestamp: 0, + }); + }); + let req = json!({ + "jsonrpc": "2.0", + "id": 26, + "method": "tools/call", + "params": { + "name": "get_project_timeline", + "arguments": { + "namespace": "" + } + } + }); + let response = handler.handle_request(req).await.unwrap(); + let content = response["result"]["content"][0]["text"].as_str().unwrap(); + assert!(content.contains("Day 1: Setup project")); + } + + #[tokio::test] + async fn test_handle_log_tech_debt() { + let handler = setup_test_handler("log_tech_debt"); + let req = json!({ + "jsonrpc": "2.0", + "id": 27, + "method": "tools/call", + "params": { + "name": "log_tech_debt", + "arguments": { + "namespace": "", + "description": "Hardcoded values", + "ideal_solution": "Remove magic numbers" + } + } + }); + let _ = handler.handle_request(req).await.unwrap(); + let debt = handler.state.tech_debts.read(); + assert_eq!(debt.len(), 1); + assert_eq!(debt[0].description, "Hardcoded values"); + } + + #[tokio::test] + async fn test_handle_list_tech_debt() { + let handler = setup_test_handler("list_tech_debt"); + handler.state.tech_debts.modify(|debts| { + debts.push(crate::models::TechDebt { + id: "debt-1".to_string(), + description: "Bad naming".to_string(), + ideal_solution: "Rename x to num_elements".to_string(), + namespace: "".to_string(), + is_resolved: false, + created_at: 0, + git_branch: None, + git_commit: None, + }); + }); + let req = json!({ + "jsonrpc": "2.0", + "id": 28, + "method": "tools/call", + "params": { + "name": "list_tech_debt", + "arguments": { + "namespace": "", + "include_resolved": false + } + } + }); + let response = handler.handle_request(req).await.unwrap(); + let content = response["result"]["content"][0]["text"].as_str().unwrap(); + assert!(content.contains("Bad naming")); + } + + #[tokio::test] + async fn test_handle_get_project_health() { + let handler = setup_test_handler("get_project_health"); + let req = json!({ + "jsonrpc": "2.0", + "id": 29, + "method": "tools/call", + "params": { + "name": "get_project_health", + "arguments": { + "namespace": "" + } + } + }); + let response = handler.handle_request(req).await.unwrap(); + let content = response["result"]["content"][0]["text"].as_str().unwrap(); + assert!(content.contains("\"active_tasks\"")); + assert!(content.contains("\"unresolved_tech_debt\"")); + } + + #[tokio::test] + async fn test_handle_resolve_tech_debt() { + let handler = setup_test_handler("resolve_tech_debt"); + handler.state.tech_debts.modify(|debts| { + debts.push(crate::models::TechDebt { + id: "debt-2".to_string(), + description: "Old api".to_string(), + ideal_solution: "Use new api".to_string(), + namespace: "".to_string(), + is_resolved: false, + created_at: 0, + git_branch: None, + git_commit: None, + }); + }); + let req = json!({ + "jsonrpc": "2.0", + "id": 30, + "method": "tools/call", + "params": { + "name": "resolve_tech_debt", + "arguments": { + "id": "debt-2" + } + } + }); + let _ = handler.handle_request(req).await.unwrap(); + let debts = handler.state.tech_debts.read(); + assert!(debts[0].is_resolved); + } + + #[tokio::test] + async fn test_handle_leave_handoff_memo() { + let handler = setup_test_handler("leave_handoff_memo"); + let req = json!({ + "jsonrpc": "2.0", + "id": 31, + "method": "tools/call", + "params": { + "name": "leave_handoff_memo", + "arguments": { + "namespace": "", + "content": "Make sure to check the logs.", + "author": "Riz" + } + } + }); + let _ = handler.handle_request(req).await.unwrap(); + let memos = handler.state.handoff_memos.read(); + assert_eq!(memos.len(), 1); + assert_eq!(memos[0].content, "Make sure to check the logs."); + } + + #[tokio::test] + async fn test_handle_query_recent_changes() { + let handler = setup_test_handler("query_recent_changes"); + handler.state.ledger.modify(|ledger| { + ledger.push(crate::models::CodeChange { + timestamp: 0, + file_path: "src/main.rs".to_string(), + description: "Fix bug".to_string(), + git_commit: None, + git_branch: None, + }); + }); + + let req = json!({ + "jsonrpc": "2.0", + "id": 18, + "method": "tools/call", + "params": { + "name": "query_recent_changes", + "arguments": {} + } + }); + let response = handler.handle_request(req).await.unwrap(); + let content = response["result"]["content"][0]["text"].as_str().unwrap(); + assert!(content.contains("Fix bug")); + assert!(content.contains("src/main.rs")); + } + + #[tokio::test] + async fn test_handle_update_task_status() { + let handler = setup_test_handler("update_task_status"); + handler.state.tasks.modify(|tasks| { + tasks.push(crate::models::Task { + id: "test-task-123".to_string(), + title: "In progress task".to_string(), + status: "pending".to_string(), + description: "".to_string(), + created_at: 0, + updated_at: 0, + git_branch: None, + }); + }); + + let req = json!({ + "jsonrpc": "2.0", + "id": 19, + "method": "tools/call", + "params": { + "name": "update_task_status", + "arguments": { + "id": "test-task-123", + "status": "in_progress" + } + } + }); + let _ = handler.handle_request(req).await.unwrap(); + let tasks = handler.state.tasks.read(); + assert_eq!(tasks[0].status, "in_progress"); + } +} + diff --git a/server/src/main.rs b/server/src/main.rs index 9529e46..fc026ef 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(target_os = "windows"), allow(dead_code, unused_imports, unreachable_code))] + mod handlers; mod mcp; mod models; @@ -22,7 +24,7 @@ use clap::{Parser, Subcommand}; use std::collections::HashMap; #[derive(Parser)] -#[command(author, version, about = "Antigravity MCP Memory Server", long_about = None)] +#[command(author, version = env!("APP_VERSION"), about = "Antigravity MCP Memory Server", long_about = None)] struct Cli { #[command(subcommand)] command: Option, @@ -320,7 +322,7 @@ fn run_server(state: Arc) -> Result<(), Box> let app = Router::new() .route("/api/version", get(|| async move { axum::Json(serde_json::json!({ - "version": env!("BUILD_DATE"), + "version": env!("APP_VERSION"), "git_hash": option_env!("GIT_HASH").unwrap_or("unknown") })) })) @@ -546,6 +548,17 @@ async fn handle_socket(socket: WebSocket, state: Arc, client_type: Str } }); + if client_type == "proxy" { + let tx_clone = tx.clone(); + tokio::spawn(async move { + let notify = serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/tools/list_changed" + }); + let _ = tx_clone.send(notify.to_string()).await; + }); + } + let handler = Arc::clone(&state.handler); let state_clone = Arc::clone(&state); let session_id_clone = session_id.clone(); @@ -707,99 +720,102 @@ fn main() -> Result<(), Box> { } } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "windows")] { - // Linux no longer executes server logic natively due to workspace split - return Ok(()); - } + let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| { + dirs::home_dir() + .map(|mut h| { + h.push(".gemini/mcp_memory"); + h.to_string_lossy().into_owned() + }) + .unwrap_or_else(|| ".gemini/mcp_memory".into()) + }); + let base = PathBuf::from(base_dir); + fs::create_dir_all(&base).expect("Failed to create store dir"); - let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| { - dirs::home_dir() - .map(|mut h| { - h.push(".gemini/mcp_memory"); - h.to_string_lossy().into_owned() - }) - .unwrap_or_else(|| ".gemini/mcp_memory".into()) - }); - let base = PathBuf::from(base_dir); - fs::create_dir_all(&base).expect("Failed to create store dir"); - - let redb_path = base.join("mcp_store.redb"); - let db = Arc::new(redb::Database::create(&redb_path).unwrap()); - - // Ensure table exists and migrate old JSON files - { - let write_txn = db.begin_write().unwrap(); + let redb_path = base.join("mcp_store.redb"); + let db = Arc::new(redb::Database::create(&redb_path).unwrap()); + + // Ensure table exists and migrate old JSON files { - let mut table = write_txn.open_table(crate::store::STORE_TABLE).unwrap(); - - let stores = [ - ("audit_ledger", "audit_ledger.json"), - ("sticky_notes", "sticky_notes.json"), - ("tasks", "tasks.json"), - ("snippets", "snippets.json"), - ("adrs", "adrs.json"), - ("preferences", "preferences.json"), - ("error_fixes", "error_fixes.json"), - ("pinned_files", "pinned_files.json"), - ("session_summaries", "session_summaries.json"), - ("handoff_memos", "handoff_memos.json"), - ("env_fingerprints", "env_fingerprints.json"), - ("env_requirements", "env_requirements.json"), - ("milestones", "milestones.json"), - ("environments", "environments.json"), - ("pr_checklists", "pr_checklists.json"), - ("tech_debts", "tech_debts.json"), - ("gates", "gates.json"), - ("context_workspaces", "context_workspaces.json"), - ]; + let write_txn = db.begin_write().unwrap(); + { + let mut table = write_txn.open_table(crate::store::STORE_TABLE).unwrap(); + + let stores = [ + ("audit_ledger", "audit_ledger.json"), + ("sticky_notes", "sticky_notes.json"), + ("tasks", "tasks.json"), + ("snippets", "snippets.json"), + ("adrs", "adrs.json"), + ("preferences", "preferences.json"), + ("error_fixes", "error_fixes.json"), + ("pinned_files", "pinned_files.json"), + ("session_summaries", "session_summaries.json"), + ("handoff_memos", "handoff_memos.json"), + ("env_fingerprints", "env_fingerprints.json"), + ("env_requirements", "env_requirements.json"), + ("milestones", "milestones.json"), + ("environments", "environments.json"), + ("pr_checklists", "pr_checklists.json"), + ("tech_debts", "tech_debts.json"), + ("gates", "gates.json"), + ("context_workspaces", "context_workspaces.json"), + ]; - for (key, file_name) in stores.iter() { - if table.get(*key).unwrap().is_none() { - let json_path = base.join(file_name); - if json_path.exists() { - if let Ok(data) = fs::read(&json_path) { - if serde_json::from_slice::(&data).is_ok() { - table.insert(*key, data.as_slice()).unwrap(); + for (key, file_name) in stores.iter() { + if table.get(*key).unwrap().is_none() { + let json_path = base.join(file_name); + if json_path.exists() { + if let Ok(data) = fs::read(&json_path) { + if serde_json::from_slice::(&data).is_ok() { + table.insert(*key, data.as_slice()).unwrap(); + } } } } } } + write_txn.commit().unwrap(); } - write_txn.commit().unwrap(); + + let state = Arc::new(MemoryState { + master_path: base.join("knowledge_graph_master.json"), + session_graph: RwLock::new(KnowledgeGraph::default()), + base_dir: base.clone(), + master_cache: RwLock::new((KnowledgeGraph::default(), SystemTime::UNIX_EPOCH)), + search_index: RwLock::new(crate::search::MemoryIndex::new(&base).unwrap()), + ledger: Store::new("audit_ledger", db.clone()), + sticky: Store::new("sticky_notes", db.clone()), + tasks: Store::new("tasks", db.clone()), + snippets: Store::new("snippets", db.clone()), + adrs: Store::new("adrs", db.clone()), + prefs: Store::new("preferences", db.clone()), + error_fixes: Store::new("error_fixes", db.clone()), + pinned_files: Store::new("pinned_files", db.clone()), + session_summaries: Store::new("session_summaries", db.clone()), + handoff_memos: Store::new("handoff_memos", db.clone()), + env_fingerprints: Store::new("env_fingerprints", db.clone()), + env_requirements: Store::new("env_requirements", db.clone()), + milestones: Store::new("milestones", db.clone()), + environments: Store::new("environments", db.clone()), + pr_checklists: Store::new("pr_checklists", db.clone()), + tech_debts: Store::new("tech_debts", db.clone()), + gates: Store::new("gates", db.clone()), + context_workspaces: Store::new("context_workspaces", db.clone()), + }); + + state.recover_wal(); + state.rebuild_index(); + + run_server(state) } - let state = Arc::new(MemoryState { - master_path: base.join("knowledge_graph_master.json"), - session_graph: RwLock::new(KnowledgeGraph::default()), - base_dir: base.clone(), - master_cache: RwLock::new((KnowledgeGraph::default(), SystemTime::UNIX_EPOCH)), - search_index: RwLock::new(crate::search::MemoryIndex::new(&base).unwrap()), - ledger: Store::new("audit_ledger", db.clone()), - sticky: Store::new("sticky_notes", db.clone()), - tasks: Store::new("tasks", db.clone()), - snippets: Store::new("snippets", db.clone()), - adrs: Store::new("adrs", db.clone()), - prefs: Store::new("preferences", db.clone()), - error_fixes: Store::new("error_fixes", db.clone()), - pinned_files: Store::new("pinned_files", db.clone()), - session_summaries: Store::new("session_summaries", db.clone()), - handoff_memos: Store::new("handoff_memos", db.clone()), - env_fingerprints: Store::new("env_fingerprints", db.clone()), - env_requirements: Store::new("env_requirements", db.clone()), - milestones: Store::new("milestones", db.clone()), - environments: Store::new("environments", db.clone()), - pr_checklists: Store::new("pr_checklists", db.clone()), - tech_debts: Store::new("tech_debts", db.clone()), - gates: Store::new("gates", db.clone()), - context_workspaces: Store::new("context_workspaces", db.clone()), - }); - - state.recover_wal(); - state.rebuild_index(); - - run_server(state) + #[cfg(not(target_os = "windows"))] + { + // Linux no longer executes server logic natively due to workspace split + Ok(()) + } } diff --git a/server/src/state.rs b/server/src/state.rs index 1bc1d40..7a2dd5b 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -1,7 +1,7 @@ use crate::models::*; use crate::search::MemoryIndex; use crate::store::Store; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::sync::RwLock; diff --git a/stub/build.rs b/stub/build.rs new file mode 100644 index 0000000..0e0609c --- /dev/null +++ b/stub/build.rs @@ -0,0 +1,20 @@ +use std::process::Command; + +fn main() { + let git_hash = Command::new("git") + .args(&["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .and_then(|out| String::from_utf8(out.stdout).ok()) + .unwrap_or_else(|| "unknown".to_string()); + + let git_date = Command::new("git") + .args(&["log", "-1", "--format=%cd", "--date=format:%Y.%m.%d"]) + .output() + .ok() + .and_then(|out| String::from_utf8(out.stdout).ok()) + .unwrap_or_else(|| "unknown".to_string()); + + let version = format!("{} ({} {})", env!("CARGO_PKG_VERSION"), git_date.trim(), git_hash.trim()); + println!("cargo:rustc-env=APP_VERSION={}", version); +} diff --git a/stub/src/main.rs b/stub/src/main.rs index 25563eb..56f7ec3 100644 --- a/stub/src/main.rs +++ b/stub/src/main.rs @@ -5,7 +5,7 @@ use tokio::io::AsyncBufReadExt; use tokio::sync::mpsc; #[derive(Parser)] -#[command(name = "mcp-memory-stub", author, version, about = "Antigravity MCP Memory Stub / Proxy", long_about = None)] +#[command(name = "mcp-memory-stub", author, version = env!("APP_VERSION"), about = "Antigravity MCP Memory Stub / Proxy", long_about = None)] struct Cli { /// Target URL for the stub to proxy messages to #[arg(long, default_value = "http://localhost:3000")] @@ -20,15 +20,19 @@ async fn read_mcp_message(stdin: &mut tokio::io::BufReader) -> let mut length = 0; loop { let mut line = String::new(); - if stdin.read_line(&mut line).await.unwrap_or(0) == 0 { + let bytes_read = stdin.read_line(&mut line).await.unwrap_or(0); + if bytes_read == 0 { + tracing::info!("stdin EOF reached"); return None; } + tracing::info!("Read {} bytes from stdin: {:?}", bytes_read, line); 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); + let lower_line = line.to_lowercase(); + if let Some(len_str) = lower_line.strip_prefix("content-length:") { + length = len_str.trim().parse().unwrap_or(0); } } if length == 0 { diff --git a/stub/tests/e2e.rs b/stub/tests/e2e.rs index 26d7cd2..a41501f 100644 --- a/stub/tests/e2e.rs +++ b/stub/tests/e2e.rs @@ -2,8 +2,6 @@ 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(); @@ -48,14 +46,27 @@ async fn test_full_system_e2e_performance() { 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 exe_dir = std::env::current_exe().unwrap(); + exe_dir.pop(); // pop test executable name + exe_dir.pop(); // pop deps/ - let mut nvim_exe = server_exe.clone(); - nvim_exe.pop(); - nvim_exe.push("mcp-memory-win-nvim.exe"); + 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"); + } // 1. Start Server let mut server = Command::new(&server_exe) @@ -132,3 +143,11 @@ async fn test_full_system_e2e_performance() { let _ = win_nvim.kill(); let _ = server.kill(); } + + + + + + + + diff --git a/win-nvim/build.rs b/win-nvim/build.rs new file mode 100644 index 0000000..0e0609c --- /dev/null +++ b/win-nvim/build.rs @@ -0,0 +1,20 @@ +use std::process::Command; + +fn main() { + let git_hash = Command::new("git") + .args(&["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .and_then(|out| String::from_utf8(out.stdout).ok()) + .unwrap_or_else(|| "unknown".to_string()); + + let git_date = Command::new("git") + .args(&["log", "-1", "--format=%cd", "--date=format:%Y.%m.%d"]) + .output() + .ok() + .and_then(|out| String::from_utf8(out.stdout).ok()) + .unwrap_or_else(|| "unknown".to_string()); + + let version = format!("{} ({} {})", env!("CARGO_PKG_VERSION"), git_date.trim(), git_hash.trim()); + println!("cargo:rustc-env=APP_VERSION={}", version); +} diff --git a/win-nvim/src/main.rs b/win-nvim/src/main.rs index 5ca3778..591d3f2 100644 --- a/win-nvim/src/main.rs +++ b/win-nvim/src/main.rs @@ -31,6 +31,11 @@ fn init_logging(app_name: &str) -> Option { @@ -57,7 +62,9 @@ async fn main() { id, result: Some(json!({ "protocolVersion": "2024-11-05", - "capabilities": {}, + "capabilities": { + "tools": {} + }, "serverInfo": { "name": "mcp-memory-win-nvim", "version": "0.1.0" diff --git a/win-nvim/tests/integration_test.rs b/win-nvim/tests/integration_test.rs index c16390e..9912a77 100644 --- a/win-nvim/tests/integration_test.rs +++ b/win-nvim/tests/integration_test.rs @@ -1,7 +1,6 @@ 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();