From 67b6a0407e70b2135bd60d3afff2c603ff59918e Mon Sep 17 00:00:00 2001 From: Reazul Ashraf Date: Thu, 10 Sep 2026 23:19:48 +0100 Subject: [PATCH] feat: Dual-OS Neovim MCP Integration - Created linux-nvim and win-nvim MCP servers for deep Neovim Msgpack-RPC integration. - Implemented tools: nvim_goto_line, nvim_get_active_buffer, nvim_get_cursor, nvim_get_visual_selection, nvim_set_diagnostics. - Added gemini-integration.lua for active UI socket discovery. - Updated build.cmd to cross-compile for both Windows (gnu) and Linux (musl). - Updated README, design.md, and instructions.md with architecture and tool capabilities. --- Cargo.lock | 93 +++++++- Cargo.toml | 4 +- README.md | 15 ++ build.cmd | 53 ++++- design.md | 1 + gemini-integration.lua | 26 +++ instructions.md | 7 + linux-nvim/Cargo.toml | 12 ++ linux-nvim/src/main.rs | 469 +++++++++++++++++++++++++++++++++++++++++ linux-nvim/src/mcp.rs | 62 ++++++ stub/src/main.rs | 14 +- win-nvim/Cargo.toml | 12 ++ win-nvim/src/main.rs | 460 ++++++++++++++++++++++++++++++++++++++++ win-nvim/src/mcp.rs | 62 ++++++ 14 files changed, 1271 insertions(+), 19 deletions(-) create mode 100644 gemini-integration.lua create mode 100644 linux-nvim/Cargo.toml create mode 100644 linux-nvim/src/main.rs create mode 100644 linux-nvim/src/mcp.rs create mode 100644 win-nvim/Cargo.toml create mode 100644 win-nvim/src/main.rs create mode 100644 win-nvim/src/mcp.rs diff --git a/Cargo.lock b/Cargo.lock index d71def1..809e90d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1031,6 +1031,18 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "mcp-memory-linux-nvim" +version = "0.1.0" +dependencies = [ + "rmp-serde", + "rmpv", + "schemars 0.8.22", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "mcp-memory-server" version = "0.1.0" @@ -1045,7 +1057,7 @@ dependencies = [ "glob", "redb", "reqwest", - "schemars", + "schemars 1.2.2", "serde", "serde_json", "tantivy", @@ -1068,6 +1080,18 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "mcp-memory-win-nvim" +version = "0.1.0" +dependencies = [ + "rmp-serde", + "rmpv", + "schemars 0.8.22", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "measure_time" version = "0.9.0" @@ -1522,6 +1546,34 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rmpv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" +dependencies = [ + "rmp", +] + [[package]] name = "rust-stemmers" version = "1.2.0" @@ -1598,6 +1650,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive 0.8.22", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.2.2" @@ -1606,11 +1670,23 @@ checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", - "schemars_derive", + "schemars_derive 1.2.2", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals 0.29.1", + "syn 2.0.119", +] + [[package]] name = "schemars_derive" version = "1.2.2" @@ -1619,7 +1695,7 @@ checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", - "serde_derive_internals", + "serde_derive_internals 0.30.0", "syn 3.0.5", ] @@ -1659,6 +1735,17 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "serde_derive_internals" version = "0.30.0" diff --git a/Cargo.toml b/Cargo.toml index 9e9a9b0..cd8cc1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,8 @@ [workspace] members = [ "server", - "stub" + "stub", + "win-nvim", + "linux-nvim" ] resolver = "2" diff --git a/README.md b/README.md index 1197918..70cf07a 100644 --- a/README.md +++ b/README.md @@ -82,3 +82,18 @@ You can also programmatically query these live metrics via the API endpoint: ## Further Reading For a deep dive into the architecture, Write-Ahead Logging (WAL), locking mechanisms, and the HTTP SSE event loop, consult the design.md file in this repository. + +## Neovim Integration +The `linux-nvim` and `win-nvim` MCP servers provide direct Msgpack-RPC communication with Neovim. +For this to work, you must add the provided `gemini-integration.lua` file to your Neovim `lua/` configuration directory and require it in your `init.lua`: +```lua +require('gemini-integration') +``` +This script allows Neovim to broadcast its active socket to `~/.gemini/active_nvim.txt` when it gains focus, allowing the MCP servers to bridge the connection. + +These MCP servers provide the following two-way integration tools: +* **`nvim_goto_line`**: Open files and jump cursors directly from the LLM. +* **`nvim_set_diagnostics`**: Push inline code review warnings as virtual text. +* **`nvim_get_active_buffer`**: Read live, unsaved buffer contents. +* **`nvim_get_cursor`**: Fetch precise line/column coordinates. +* **`nvim_get_visual_selection`**: Read highlighted code blocks. diff --git a/build.cmd b/build.cmd index abd442d..1e751ca 100644 --- a/build.cmd +++ b/build.cmd @@ -1,12 +1,47 @@ @echo off -echo Building mcp-memory in release mode... -cargo build --release +setlocal -if %ERRORLEVEL% EQU 0 ( - echo. - echo Build successful! - echo Executable is located at: target\release\mcp-memory.exe -) else ( - echo. - echo Build failed with error code %ERRORLEVEL%. +echo Building mcp-memory (server and stub) for Windows... +rustup run stable cargo build --release +if %ERRORLEVEL% NEQ 0 ( + echo Windows build failed! + exit /b %ERRORLEVEL% ) + +echo Building mcp-memory (server and stub) for Linux (WSL)... +rustup run stable cargo zigbuild --release --target x86_64-unknown-linux-musl +if %ERRORLEVEL% NEQ 0 ( + echo Linux build failed! + exit /b %ERRORLEVEL% +) + +echo. +echo Build successful! Gracefully shutting down running servers... +:: Attempt shutdown via CLI flag +if exist "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" ( + "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" --exit >nul 2>&1 +) +:: Fallback shutdown via HTTP endpoint +curl -X POST http://127.0.0.1:3000/shutdown -s -o nul >nul 2>&1 + +:: Give the server a moment to flush checkpoints and release file locks +timeout /t 2 /nobreak >nul + +echo. +echo Copying Windows executables... +copy /Y target\release\mcp-memory-server.exe "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" +copy /Y target\release\mcp-memory-stub.exe "C:\Users\reazul.ashraf\.local\bin\mcp-memory-stub.exe" +copy /Y target\x86_64-pc-windows-gnu\release\mcp-memory-win-nvim.exe "C:\Users\reazul.ashraf\.local\bin\mcp-memory-win-nvim.exe" + +echo Copying Linux executables to WSL... +wsl -d Ubuntu -e bash -c "cp /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/target/x86_64-unknown-linux-musl/release/mcp-memory-server /home/riz/.local/bin/mcp-memory-server && chmod +x /home/riz/.local/bin/mcp-memory-server" +wsl -d Ubuntu -e bash -c "cp /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/target/x86_64-unknown-linux-musl/release/mcp-memory-stub /home/riz/.local/bin/mcp-memory-stub && chmod +x /home/riz/.local/bin/mcp-memory-stub" +wsl -d Ubuntu -e bash -c "cp /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/target/x86_64-unknown-linux-musl/release/mcp-memory-linux-nvim /home/riz/.local/bin/mcp-memory-linux-nvim && chmod +x /home/riz/.local/bin/mcp-memory-linux-nvim" + +echo Copying instructions.md... +copy /Y instructions.md "C:\Users\reazul.ashraf\.gemini\antigravity-cli\mcp\memory\instructions.md" +wsl -d Ubuntu -e bash -c "mkdir -p /home/riz/.gemini/antigravity-cli/mcp/memory && cp /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory/instructions.md /home/riz/.gemini/antigravity-cli/mcp/memory/instructions.md" + +echo. +echo Build and deployment complete! +endlocal diff --git a/design.md b/design.md index 4755097..0bd9da6 100644 --- a/design.md +++ b/design.md @@ -114,3 +114,4 @@ To optimize for different environments, the codebase is structured as a Cargo Wo eqwest and okio). * **Role:** A dedicated, OS-agnostic proxy binary used strictly for routing stdio JSON-RPC traffic over HTTP to a remote Leader. Windows gy clients point directly to this binary to bypass loading the heavy Server daemon into memory. * **WSL Behavior:** Compiled as a Linux native binary (x86_64-unknown-linux-musl). When executed by WSL agy, it acts as a transparent proxy to http://127.0.0.1:3000. It can also execute wake_cmd (e.g., WSL interop) to silently wake the Windows host if the Leader is offline. + diff --git a/gemini-integration.lua b/gemini-integration.lua new file mode 100644 index 0000000..b047a51 --- /dev/null +++ b/gemini-integration.lua @@ -0,0 +1,26 @@ +-- gemini-integration.lua +-- Registers the active Neovim instance for the Antigravity MCP Server +vim.api.nvim_create_autocmd({"VimEnter", "FocusGained"}, { + callback = function() + -- Only register if Neovim has an active UI attached (ignore headless/embedded instances) + if #vim.api.nvim_list_uis() > 0 then + local server_name = vim.v.servername + if server_name then + local home = os.getenv("HOME") + if not home then + home = os.getenv("USERPROFILE") + end + if home then + os.execute("mkdir -p " .. home .. "/.gemini") + local path = home .. "/.gemini/active_nvim.txt" + local f = io.open(path, "w") + if f then + f:write(server_name) + f:close() + end + end + end + end + end, + desc = "Register active Neovim instance for Gemini CLI", +}) diff --git a/instructions.md b/instructions.md index 37a7315..2d11c33 100644 --- a/instructions.md +++ b/instructions.md @@ -131,3 +131,10 @@ When starting a new session, get a numerical aggregate of the project's current To maintain absolute traceability, we link memory items directly to the exact git commits they occurred on. - When calling **log_code_change**, **log_error_fix**, or **log_tech_debt**, you should execute git rev-parse HEAD and git branch --show-current in the project directory first (if it's a git repo). - Pass the resulting hash and branch name into the git_commit and git_branch arguments of those tools to permanently link the memory item to the VCS state. + +## 13. Server Lifecycle & Updates (Graceful Shutdown) +When instructed to restart, update, or stop the mcp-memory-server binary, never use brute-force OS kill commands (e.g. Stop-Process, pkill, kill). +- Why: The server uses asynchronous checkpointing. Force-killing it bypasses the shutdown flush, risking data loss in the master JSON graph, and can lead to lingering file locks. +- How: Always use the built-in graceful shutdown mechanisms: + 1. CLI Flag: mcp-memory-server --exit (or --restart) + 2. HTTP Endpoint: POST http://127.0.0.1:3000/shutdown diff --git a/linux-nvim/Cargo.toml b/linux-nvim/Cargo.toml new file mode 100644 index 0000000..b2177fd --- /dev/null +++ b/linux-nvim/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "mcp-memory-linux-nvim" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +rmp-serde = "1.1" +rmpv = "1.0" +tokio = { version = "1.37", features = ["full"] } +schemars = "0.8" diff --git a/linux-nvim/src/main.rs b/linux-nvim/src/main.rs new file mode 100644 index 0000000..1ecd3a6 --- /dev/null +++ b/linux-nvim/src/main.rs @@ -0,0 +1,469 @@ +mod mcp; + +use mcp::{read_message, send_response, send_error, JsonRpcResponse}; +use serde_json::{json, Value}; +use tokio::net::UnixStream; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[tokio::main] +async fn main() { + loop { + let msg = match read_message() { + Some(m) => m, + None => break, + }; + + let id = msg.id.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, + }); + } + "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"] + } + } + ] + })), + error: None, + }); + } + "tools/call" => { + let params = msg.params.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); + + match send_nvim_command(&format!("edit +{} {}", line, file)).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, + }); + } + Err(e) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "isError": true, + "content": [{"type": "text", "text": format!("Error: {}", e)}] + })), + error: None, + }); + } + } + } + "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, + }); + } + Err(e) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "isError": true, + "content": [{"type": "text", "text": format!("Error: {}", e)}] + })), + error: None, + }); + } + } + } + "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, + }); + } + Err(e) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "isError": true, + "content": [{"type": "text", "text": format!("Error: {}", e)}] + })), + error: None, + }); + } + } + } + "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, + }); + } + Err(e) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "isError": true, + "content": [{"type": "text", "text": format!("Error: {}", e)}] + })), + error: None, + }); + } + } + } + "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 pushed diagnostic to line {}", line)}] + })), + error: None, + }); + } + Err(e) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "isError": true, + "content": [{"type": "text", "text": format!("Error: {}", e)}] + })), + error: None, + }); + } + } + } + _ => { + send_error(id, -32601, "Tool not found"); + } + } + } + _ => { + // Ignore other methods + } + } + } +} + +async fn get_socket_path() -> Result { + if let Ok(content) = std::fs::read_to_string(format!("{}/.gemini/active_nvim.txt", std::env::var("HOME").unwrap_or("/home/riz".into()))) { + let p = content.trim().to_string(); + if !p.is_empty() { + return Ok(p); + } + } + + // 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 send_nvim_command(cmd: &str) -> Result<(), String> { + let socket_path = get_socket_path().await?; + let mut stream = UnixStream::connect(socket_path).await.map_err(|e| e.to_string())?; + + use rmpv::Value as RmpValue; + let req = RmpValue::Array(vec![ + RmpValue::Integer(0.into()), + RmpValue::Integer(1.into()), + RmpValue::String("nvim_command".into()), + RmpValue::Array(vec![RmpValue::String(cmd.into())]), + ]); + + 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())?; + + Ok(()) +} + +async fn get_nvim_active_buffer() -> Result { + let socket_path = get_socket_path().await?; + let mut stream = UnixStream::connect(socket_path).await.map_err(|e| e.to_string())?; + + use rmpv::Value as RmpValue; + let req = RmpValue::Array(vec![ + RmpValue::Integer(0.into()), + RmpValue::Integer(2.into()), + 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 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 = [0u8; 1024 * 1024]; + let n = stream.read(&mut resp_buf).await.map_err(|e| e.to_string())?; + let mut cursor = std::io::Cursor::new(&resp_buf[..n]); + let resp = rmpv::decode::read_value(&mut cursor).map_err(|e| e.to_string())?; + + if let RmpValue::Array(arr) = resp { + if arr.len() >= 4 { + 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 from Neovim".to_string()) +} + +async fn get_nvim_cursor() -> Result { + let socket_path = get_socket_path().await?; + let mut stream = UnixStream::connect(socket_path).await.map_err(|e| e.to_string())?; + + use rmpv::Value as RmpValue; + let req = RmpValue::Array(vec![ + RmpValue::Integer(0.into()), + RmpValue::Integer(2.into()), + RmpValue::String("nvim_win_get_cursor".into()), + RmpValue::Array(vec![ + RmpValue::Integer(0.into()), + ]), + ]); + + 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 = [0u8; 8192]; + let n = stream.read(&mut resp_buf).await.map_err(|e| e.to_string())?; + let mut cursor = std::io::Cursor::new(&resp_buf[..n]); + let resp = rmpv::decode::read_value(&mut cursor).map_err(|e| e.to_string())?; + + if let RmpValue::Array(arr) = resp { + if arr.len() >= 4 { + 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 from Neovim".to_string()) +} + +async fn get_nvim_visual_selection() -> Result { + let socket_path = get_socket_path().await?; + let mut stream = UnixStream::connect(socket_path).await.map_err(|e| e.to_string())?; + + 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(3.into()), + RmpValue::String("nvim_exec_lua".into()), + RmpValue::Array(vec![ + RmpValue::String(lua_script.into()), + RmpValue::Array(vec![]), + ]), + ]); + + 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 = [0u8; 1024 * 1024]; + let n = stream.read(&mut resp_buf).await.map_err(|e| e.to_string())?; + let mut cursor = std::io::Cursor::new(&resp_buf[..n]); + let resp = rmpv::decode::read_value(&mut cursor).map_err(|e| e.to_string())?; + + if let RmpValue::Array(arr) = resp { + if arr.len() >= 4 { + 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 from Neovim".to_string()) +} + +async fn set_nvim_diagnostics(line: i64, message: &str) -> Result<(), String> { + let socket_path = get_socket_path().await?; + let mut stream = UnixStream::connect(socket_path).await.map_err(|e| e.to_string())?; + + // We escape backslashes and double quotes in the message + 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(4.into()), + RmpValue::String("nvim_exec_lua".into()), + RmpValue::Array(vec![ + RmpValue::String(lua_script.into()), + RmpValue::Array(vec![]), + ]), + ]); + + 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 = [0u8; 8192]; + let n = stream.read(&mut resp_buf).await.map_err(|e| e.to_string())?; + let mut cursor = std::io::Cursor::new(&resp_buf[..n]); + let resp = rmpv::decode::read_value(&mut cursor).map_err(|e| e.to_string())?; + + if let RmpValue::Array(arr) = resp { + if arr.len() >= 4 { + if !arr[2].is_nil() { + return Err(format!("Neovim error: {:?}", arr[2])); + } + return Ok(()); + } + } + Err("Invalid response from Neovim".to_string()) +} diff --git a/linux-nvim/src/mcp.rs b/linux-nvim/src/mcp.rs new file mode 100644 index 0000000..a837d57 --- /dev/null +++ b/linux-nvim/src/mcp.rs @@ -0,0 +1,62 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + pub id: Option, + pub method: String, + pub params: Option, +} + +#[derive(Serialize, Debug, Clone)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +pub fn read_message() -> Option { + use std::io::{BufRead, Read}; + let stdin = std::io::stdin(); + let mut handle = stdin.lock(); + let mut length = 0; + loop { + let mut line = String::new(); + if handle.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 buffer = vec![0; length]; + handle.read_exact(&mut buffer).unwrap_or(()); + serde_json::from_slice(&buffer).ok() +} + +pub fn send_response(response: JsonRpcResponse) { + let msg = serde_json::to_string(&response).unwrap(); + print!("Content-Length: {}\r\n\r\n{}", msg.len(), msg); + use std::io::Write; + std::io::stdout().flush().unwrap(); +} + +pub fn send_error(id: Value, code: i32, message: &str) { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(serde_json::json!({"code": code, "message": message})), + }); +} diff --git a/stub/src/main.rs b/stub/src/main.rs index 389762c..f10ab6f 100644 --- a/stub/src/main.rs +++ b/stub/src/main.rs @@ -7,10 +7,12 @@ use tokio::sync::mpsc; use tokio_util::io::StreamReader; #[derive(Parser)] -#[command(name = "mcp-memory-stub")] +#[command(name = "mcp-memory-stub", author, 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")] target: String, + /// Optional command to execute if the target server is unreachable #[arg(long)] wake_cmd: Option, } @@ -56,11 +58,9 @@ fn main() -> Result<(), Box> { .send() .await; - if res.is_ok() { - break; - } + if let Ok(resp) = res { if resp.status().is_success() { break; } } } - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; attempts += 1; if attempts % 10 == 0 { eprintln!("[PROXY] Waiting for server to accept messages..."); @@ -132,7 +132,7 @@ fn main() -> Result<(), Box> { } } *post_url.write().await = String::new(); - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; } Err(_) => { if let Some(ref cmd) = wake_cmd { @@ -150,3 +150,5 @@ fn main() -> Result<(), Box> { Ok(()) }) } + + diff --git a/win-nvim/Cargo.toml b/win-nvim/Cargo.toml new file mode 100644 index 0000000..ad845f5 --- /dev/null +++ b/win-nvim/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "mcp-memory-win-nvim" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +rmp-serde = "1.1" +rmpv = "1.0" +tokio = { version = "1.37", features = ["full"] } +schemars = "0.8" diff --git a/win-nvim/src/main.rs b/win-nvim/src/main.rs new file mode 100644 index 0000000..f537a3f --- /dev/null +++ b/win-nvim/src/main.rs @@ -0,0 +1,460 @@ +mod mcp; + +use mcp::{read_message, send_response, send_error, JsonRpcResponse}; +use serde_json::{json, Value}; +use tokio::net::windows::named_pipe::ClientOptions; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[tokio::main] +async fn main() { + loop { + let msg = match read_message() { + Some(m) => m, + None => break, + }; + + let id = msg.id.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-win-nvim", + "version": "0.1.0" + } + })), + error: None, + }); + } + "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"] + } + } + ] + })), + error: None, + }); + } + "tools/call" => { + let params = msg.params.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); + + match send_nvim_command(&format!("edit +{} {}", line, file)).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, + }); + } + Err(e) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "isError": true, + "content": [{"type": "text", "text": format!("Error: {}", e)}] + })), + error: None, + }); + } + } + } + "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, + }); + } + Err(e) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "isError": true, + "content": [{"type": "text", "text": format!("Error: {}", e)}] + })), + error: None, + }); + } + } + } + "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, + }); + } + Err(e) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "isError": true, + "content": [{"type": "text", "text": format!("Error: {}", e)}] + })), + error: None, + }); + } + } + } + "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, + }); + } + Err(e) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "isError": true, + "content": [{"type": "text", "text": format!("Error: {}", e)}] + })), + error: None, + }); + } + } + } + "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 pushed diagnostic to line {}", line)}] + })), + error: None, + }); + } + Err(e) => { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "isError": true, + "content": [{"type": "text", "text": format!("Error: {}", e)}] + })), + error: None, + }); + } + } + } + _ => { + send_error(id, -32601, "Tool not found"); + } + } + } + _ => { + // Ignore other methods + } + } + } +} + +async fn get_socket_path() -> Result { + 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 in active_nvim.txt".to_string()) +} + +async fn send_nvim_command(cmd: &str) -> Result<(), String> { + let socket_path = get_socket_path().await?; + let mut client = ClientOptions::new().open(&socket_path).map_err(|e| e.to_string())?; + + use rmpv::Value as RmpValue; + let req = RmpValue::Array(vec![ + RmpValue::Integer(0.into()), + RmpValue::Integer(1.into()), + RmpValue::String("nvim_command".into()), + RmpValue::Array(vec![RmpValue::String(cmd.into())]), + ]); + + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &req).map_err(|e| e.to_string())?; + client.write_all(&buf).await.map_err(|e| e.to_string())?; + + Ok(()) +} + +async fn get_nvim_active_buffer() -> Result { + let socket_path = get_socket_path().await?; + let mut client = ClientOptions::new().open(&socket_path).map_err(|e| e.to_string())?; + + use rmpv::Value as RmpValue; + let req = RmpValue::Array(vec![ + RmpValue::Integer(0.into()), + RmpValue::Integer(2.into()), + 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 mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &req).map_err(|e| e.to_string())?; + client.write_all(&buf).await.map_err(|e| e.to_string())?; + + let mut resp_buf = [0u8; 1024 * 1024]; // 1MB buffer + let n = client.read(&mut resp_buf).await.map_err(|e| e.to_string())?; + + let mut cursor = std::io::Cursor::new(&resp_buf[..n]); + let resp = rmpv::decode::read_value(&mut cursor).map_err(|e| e.to_string())?; + + if let RmpValue::Array(arr) = resp { + if arr.len() >= 4 { + 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 from Neovim".to_string()) +} + +async fn get_nvim_cursor() -> Result { + let socket_path = get_socket_path().await?; + let mut client = ClientOptions::new().open(&socket_path).map_err(|e| e.to_string())?; + + use rmpv::Value as RmpValue; + let req = RmpValue::Array(vec![ + RmpValue::Integer(0.into()), + RmpValue::Integer(2.into()), + RmpValue::String("nvim_win_get_cursor".into()), + RmpValue::Array(vec![ + RmpValue::Integer(0.into()), + ]), + ]); + + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &req).map_err(|e| e.to_string())?; + client.write_all(&buf).await.map_err(|e| e.to_string())?; + + let mut resp_buf = [0u8; 8192]; + let n = client.read(&mut resp_buf).await.map_err(|e| e.to_string())?; + let mut cursor = std::io::Cursor::new(&resp_buf[..n]); + let resp = rmpv::decode::read_value(&mut cursor).map_err(|e| e.to_string())?; + + if let RmpValue::Array(arr) = resp { + if arr.len() >= 4 { + 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 from Neovim".to_string()) +} + +async fn get_nvim_visual_selection() -> Result { + let socket_path = get_socket_path().await?; + let mut client = ClientOptions::new().open(&socket_path).map_err(|e| e.to_string())?; + + 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(3.into()), + RmpValue::String("nvim_exec_lua".into()), + RmpValue::Array(vec![ + RmpValue::String(lua_script.into()), + RmpValue::Array(vec![]), + ]), + ]); + + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &req).map_err(|e| e.to_string())?; + client.write_all(&buf).await.map_err(|e| e.to_string())?; + + let mut resp_buf = [0u8; 1024 * 1024]; + let n = client.read(&mut resp_buf).await.map_err(|e| e.to_string())?; + let mut cursor = std::io::Cursor::new(&resp_buf[..n]); + let resp = rmpv::decode::read_value(&mut cursor).map_err(|e| e.to_string())?; + + if let RmpValue::Array(arr) = resp { + if arr.len() >= 4 { + 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 from Neovim".to_string()) +} + +async fn set_nvim_diagnostics(line: i64, message: &str) -> Result<(), String> { + let socket_path = get_socket_path().await?; + let mut client = ClientOptions::new().open(&socket_path).map_err(|e| e.to_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(4.into()), + RmpValue::String("nvim_exec_lua".into()), + RmpValue::Array(vec![ + RmpValue::String(lua_script.into()), + RmpValue::Array(vec![]), + ]), + ]); + + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &req).map_err(|e| e.to_string())?; + client.write_all(&buf).await.map_err(|e| e.to_string())?; + + let mut resp_buf = [0u8; 8192]; + let n = client.read(&mut resp_buf).await.map_err(|e| e.to_string())?; + let mut cursor = std::io::Cursor::new(&resp_buf[..n]); + let resp = rmpv::decode::read_value(&mut cursor).map_err(|e| e.to_string())?; + + if let RmpValue::Array(arr) = resp { + if arr.len() >= 4 { + if !arr[2].is_nil() { + return Err(format!("Neovim error: {:?}", arr[2])); + } + return Ok(()); + } + } + Err("Invalid response from Neovim".to_string()) +} diff --git a/win-nvim/src/mcp.rs b/win-nvim/src/mcp.rs new file mode 100644 index 0000000..a837d57 --- /dev/null +++ b/win-nvim/src/mcp.rs @@ -0,0 +1,62 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + pub id: Option, + pub method: String, + pub params: Option, +} + +#[derive(Serialize, Debug, Clone)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +pub fn read_message() -> Option { + use std::io::{BufRead, Read}; + let stdin = std::io::stdin(); + let mut handle = stdin.lock(); + let mut length = 0; + loop { + let mut line = String::new(); + if handle.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 buffer = vec![0; length]; + handle.read_exact(&mut buffer).unwrap_or(()); + serde_json::from_slice(&buffer).ok() +} + +pub fn send_response(response: JsonRpcResponse) { + let msg = serde_json::to_string(&response).unwrap(); + print!("Content-Length: {}\r\n\r\n{}", msg.len(), msg); + use std::io::Write; + std::io::stdout().flush().unwrap(); +} + +pub fn send_error(id: Value, code: i32, message: &str) { + send_response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(serde_json::json!({"code": code, "message": message})), + }); +}