Compare commits

...
10 Commits
Author SHA1 Message Date
Riz Ashraf 1fd1d119e6 fix(proxy): fully non-blocking mcp-memory-stub proxy
This makes the stub purely non-blocking via tokio::spawn and fixes cross-OS compilation boundaries in build.cmd
2026-09-12 08:21:41 +01:00
Reazul Ashraf 67b6a0407e 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.
2026-09-10 23:20:03 +01:00
Riz Ashraf 28f70aafdb docs: Update design.md to reflect new HTTP gate endpoints alongside CLI commands 2026-09-10 15:57:23 +01:00
Riz Ashraf e057dc0fdd feat: Add HTTP API endpoints for gate verification 2026-09-10 15:51:12 +01:00
Riz Ashraf 721f564910 chore: finalize redb migration for all memory stores 2026-09-10 10:48:37 +01:00
Riz Ashraf 9a8b6e52e9 chore: migrate store to redb 2026-09-10 10:39:33 +01:00
Riz Ashraf 7f0286dc40 docs: Update shutdown instructions and wake-cmd paths 2026-09-09 14:14:55 +01:00
Riz Ashraf 0de78e8fae fix: Resolve SSE reading deadlocks in Stdio proxies 2026-09-09 08:27:56 +01:00
Riz Ashraf 0ecd862e89 feat: Expose all MemoryState metrics in API and dashboard 2026-09-09 06:26:58 +01:00
Riz Ashraf 938b8dbf45 docs: Document MPSC queue resilience and 127.0.0.1 binding 2026-09-09 04:32:50 +01:00
25 changed files with 3542 additions and 1481 deletions

No files matched your search

Generated
+132 -4
View File
@@ -157,6 +157,15 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.1" version = "2.13.1"
@@ -392,6 +401,20 @@ dependencies = [
"syn 3.0.5", "syn 3.0.5",
] ]
[[package]]
name = "dashmap"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]] [[package]]
name = "datasketches" name = "datasketches"
version = "0.2.0" version = "0.2.0"
@@ -626,6 +649,12 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
@@ -981,7 +1010,7 @@ version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
dependencies = [ dependencies = [
"hashbrown", "hashbrown 0.16.1",
] ]
[[package]] [[package]]
@@ -1002,18 +1031,33 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" 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]] [[package]]
name = "mcp-memory-server" name = "mcp-memory-server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"bincode",
"clap", "clap",
"dashmap",
"dirs", "dirs",
"futures-util", "futures-util",
"glob", "glob",
"redb",
"reqwest", "reqwest",
"schemars", "schemars 1.2.2",
"serde", "serde",
"serde_json", "serde_json",
"tantivy", "tantivy",
@@ -1036,6 +1080,18 @@ dependencies = [
"tokio-util", "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]] [[package]]
name = "measure_time" name = "measure_time"
version = "0.9.0" version = "0.9.0"
@@ -1357,6 +1413,15 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "redb"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -1481,6 +1546,34 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rust-stemmers" name = "rust-stemmers"
version = "1.2.0" version = "1.2.0"
@@ -1557,6 +1650,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 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]] [[package]]
name = "schemars" name = "schemars"
version = "1.2.2" version = "1.2.2"
@@ -1565,11 +1670,23 @@ checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
dependencies = [ dependencies = [
"dyn-clone", "dyn-clone",
"ref-cast", "ref-cast",
"schemars_derive", "schemars_derive 1.2.2",
"serde", "serde",
"serde_json", "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]] [[package]]
name = "schemars_derive" name = "schemars_derive"
version = "1.2.2" version = "1.2.2"
@@ -1578,7 +1695,7 @@ checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"serde_derive_internals", "serde_derive_internals 0.30.0",
"syn 3.0.5", "syn 3.0.5",
] ]
@@ -1618,6 +1735,17 @@ dependencies = [
"syn 3.0.5", "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]] [[package]]
name = "serde_derive_internals" name = "serde_derive_internals"
version = "0.30.0" version = "0.30.0"
+3 -1
View File
@@ -1,6 +1,8 @@
[workspace] [workspace]
members = [ members = [
"server", "server",
"stub" "stub",
"win-nvim",
"linux-nvim"
] ]
resolver = "2" resolver = "2"
+21 -1
View File
@@ -23,6 +23,11 @@ Copy-Item target\release\mcp-memory-stub.exe C:\Users\reazul.ashraf\.local\bin\m
# Add this to your PowerShell profile: # Add this to your PowerShell profile:
if (-not (Get-Process mcp-memory-server -ErrorAction SilentlyContinue)) { Start-Process -FilePath "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" -ArgumentList "--daemon" -WindowStyle Hidden -ErrorAction SilentlyContinue } if (-not (Get-Process mcp-memory-server -ErrorAction SilentlyContinue)) { Start-Process -FilePath "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" -ArgumentList "--daemon" -WindowStyle Hidden -ErrorAction SilentlyContinue }
``` ```
**Shutting Down:** If you need to stop the background daemon (e.g., to replace the executable after a recompile), use the built-in `--exit` flag instead of forcefully killing the process.
```powershell
mcp-memory-server.exe --exit
```
` `
**Step 2:** Update your Windows ~/.gemini/config/mcp_config.json to point the CLI to the ultra-lightweight stub (since the server is already running in the background): **Step 2:** Update your Windows ~/.gemini/config/mcp_config.json to point the CLI to the ultra-lightweight stub (since the server is already running in the background):
`json `json
@@ -51,7 +56,7 @@ Update your WSL ~/.gemini/config/mcp_config.json:
"command": "/home/riz/.local/bin/mcp-memory-stub", "command": "/home/riz/.local/bin/mcp-memory-stub",
"args": [ "args": [
"--target", "http://127.0.0.1:3000", "--target", "http://127.0.0.1:3000",
"--wake-cmd", "/mnt/c/Windows/System32/cmd.exe /c start /B C:\\Users\\reazul.ashraf\\.local\\bin\\mcp-memory-server.exe --daemon" "--wake-cmd", "/mnt/c/Users/reazul.ashraf/.local/bin/mcp-memory-server.exe --daemon"
] ]
} }
} }
@@ -77,3 +82,18 @@ You can also programmatically query these live metrics via the API endpoint:
## Further Reading ## 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. 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.
+44 -9
View File
@@ -1,12 +1,47 @@
@echo off @echo off
echo Building mcp-memory in release mode... setlocal
cargo build --release
if %ERRORLEVEL% EQU 0 ( echo Building mcp-memory (server and stub) for Windows...
echo. rustup run stable cargo build --release --workspace --exclude mcp-memory-linux-nvim
echo Build successful! if %ERRORLEVEL% NEQ 0 (
echo Executable is located at: target\release\mcp-memory.exe echo Windows build failed!
) else ( exit /b %ERRORLEVEL%
echo.
echo Build failed with error code %ERRORLEVEL%.
) )
echo Building mcp-memory (server and stub) for Linux (WSL)...
rustup run stable cargo zigbuild --release --workspace --exclude mcp-memory-win-nvim --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\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
+6 -4
View File
@@ -63,9 +63,9 @@ econcile_worker) on a 5-second polling loop responsible for:
* **Ephemeral Data Cleanup:** Automatically expiring and purging sticky_notes.json that are older than 24 hours. * **Ephemeral Data Cleanup:** Automatically expiring and purging sticky_notes.json that are older than 24 hours.
## 8. The Gate System (Push Safety Verification) ## 8. The Gate System (Push Safety Verification)
The binary includes dedicated CLI subcommands (gate set and gate verify) that interact with a persistent gates.json store to enforce safety policies (like ensuring tests pass before a git push). The binary supports a flexible safety gate authorization system, accessible both via the HTTP API and CLI subcommands.
* **mcp-memory-stub gate set**: Records an authorization status (authorized, blocked, or pending) for a specific target and namespace, alongside optional failure reasons and parameters. * **API / CLI set**: Records an authorization status (`authorized`, `blocked`, or `pending`) for a specific target and namespace. Can be invoked via `POST /gate/set` (JSON) or `mcp-memory-stub gate set`.
* **mcp-memory-stub gate verify**: Evaluates a pending action against the gate store. It returns exit code if authorized, 1 if explicitly blocked, and 2 if no gate record exists. It also supports a --consume flag to immediately revoke the authorization after a successful check. * **API / CLI verify**: Evaluates a pending action against the gate store. It returns standard HTTP status codes (`200 OK`, `403 Forbidden`, `404 Not Found`) via `GET /gate/verify?action=...` or POSIX exit codes (0, 1, 2) via `mcp-memory-stub gate verify`. Both support a `consume` parameter/flag to immediately revoke the authorization after a successful check.
## 9. Operational Configuration & Paths ## 9. Operational Configuration & Paths
The physical storage location of the knowledge graph and all persistent stores is strictly controlled by the MCP_MEMORY_STORE_DIR environment variable. The physical storage location of the knowledge graph and all persistent stores is strictly controlled by the MCP_MEMORY_STORE_DIR environment variable.
@@ -96,7 +96,8 @@ Every time a Windows gy session starts, it blindly spawns mcp-memory-stub.exe a
### WSL (Linux) Client Lifecycle (Permanent Stub) ### WSL (Linux) Client Lifecycle (Permanent Stub)
To provide a seamless experience without complex configuration drift, the WSL environment utilizes a native Linux binary (`/home/riz/.local/bin/mcp-memory-stub`) that acts as a **Permanent Stub**. To provide a seamless experience without complex configuration drift, the WSL environment utilizes a native Linux binary (`/home/riz/.local/bin/mcp-memory-stub`) that acts as a **Permanent Stub**.
* **Transparent Proxying:** The WSL `agy` CLI spawns this Linux binary via standard `stdio`. The binary immediately proxies all `stdio` JSON-RPC requests over HTTP to the Windows Leader at `http://127.0.0.1:3000/messages`, handling SSE streams transparently. * **Transparent Proxying:** The WSL `agy` CLI spawns this Linux binary via standard `stdio`. The binary immediately proxies all `stdio` JSON-RPC requests over HTTP to the Windows Leader at `http://127.0.0.1:3000/messages`, handling SSE streams transparently.
* **Startup via Interop:** Upon launch, the Linux Stub pings the Windows host. If port 3000 is dead, the Linux binary automatically executes WSL interop (`cmd.exe /c start /B C:\Users\reazul.ashraf\.local\bin\mcp-memory.exe`) to silently wake up the Windows Leader before commencing the proxy loop. * **MPSC Queue Resilience:** The stub utilizes an asynchronous multi-producer, single-consumer (MPSC) channel queue. If the Windows Leader daemon restarts or momentarily drops, the proxy buffers incoming JSON-RPC tool calls and infinitely retries them until the connection is restored. This guarantees **zero message loss** and **zero thread leaks** without crashing the active `agy` session.
* **Startup via Interop:** Upon launch, the Linux Stub pings the Windows host. If port 3000 is dead, the Linux binary automatically executes the provided `wake-cmd` (e.g. `/mnt/c/Users/reazul.ashraf/.local/bin/mcp-memory-server.exe --daemon`) to silently wake up the Windows Leader before commencing the proxy loop.
* **Zero I/O Penalty:** This ensures the Linux binary never directly touches the Windows NTFS files, reserving all heavy disk operations for the native Windows host. * **Zero I/O Penalty:** This ensures the Linux binary never directly touches the Windows NTFS files, reserving all heavy disk operations for the native Windows host.
## 13. Cargo Workspace & Binary Artifacts ## 13. Cargo Workspace & Binary Artifacts
To optimize for different environments, the codebase is structured as a Cargo Workspace containing two distinct crates: To optimize for different environments, the codebase is structured as a Cargo Workspace containing two distinct crates:
@@ -113,3 +114,4 @@ To optimize for different environments, the codebase is structured as a Cargo Wo
eqwest and okio). 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. * **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. * **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.
+8
View File
@@ -0,0 +1,8 @@
import re
with open(r'C:\Users\reazul.ashraf\workspace\rust\mcp-memory\server\src\main.rs', 'r', encoding='utf-8') as f:
text = f.read()
text = re.sub(r'#\[cfg\(not\(target_os = "windows"\)\)\].*?return Ok\(\(\);\n \}', '', text, flags=re.DOTALL)
with open(r'C:\Users\reazul.ashraf\workspace\rust\mcp-memory\server\src\main.rs', 'w', encoding='utf-8') as f:
f.write(text)
+26
View File
@@ -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",
})
+7
View File
@@ -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. 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). - 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. - 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
+12
View File
@@ -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"
+432
View File
@@ -0,0 +1,432 @@
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().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"]
}
}
]
})),
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;
}
}
}
_ => {
send_error(id, -32601, "Tool not found").await;
}
}
}
_ => {
// Ignore other methods
}
}
});
}
}
async fn get_socket_path() -> Result<String, String> {
// 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<rmpv::Value, String> {
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 n = stream.read(&mut chunk).await.map_err(|e| e.to_string())?;
if n == 0 {
return Err("Unexpected EOF from Neovim".to_string());
}
resp_buf.extend_from_slice(&chunk[..n]);
}
}
}
}
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<String, String> {
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<String, String> {
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<String, 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(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())
}
+62
View File
@@ -0,0 +1,62 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct JsonRpcRequest {
pub jsonrpc: String,
pub id: Option<Value>,
pub method: String,
pub params: Option<Value>,
}
#[derive(Serialize, Debug, Clone)]
pub struct JsonRpcResponse {
pub jsonrpc: String,
pub id: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<Value>,
}
pub async fn read_message() -> Option<JsonRpcRequest> {
let mut stdin = BufReader::new(tokio::io::stdin());
let mut length = 0;
loop {
let mut line = String::new();
if stdin.read_line(&mut line).await.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];
stdin.read_exact(&mut buffer).await.unwrap_or(0);
serde_json::from_slice(&buffer).ok()
}
pub async fn send_response(response: JsonRpcResponse) {
let msg = serde_json::to_string(&response).unwrap();
let payload = format!("Content-Length: {}\r\n\r\n{}", msg.len(), msg);
let mut stdout = tokio::io::stdout();
let _ = stdout.write_all(payload.as_bytes()).await;
let _ = stdout.flush().await;
}
pub async 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})),
}).await;
}
+3
View File
@@ -6,10 +6,13 @@ edition = "2024"
[dependencies] [dependencies]
async-trait = "0.1.92" async-trait = "0.1.92"
axum = "0.8" axum = "0.8"
bincode = "1.3.3"
clap = { version = "4.6.6", features = ["derive"] } clap = { version = "4.6.6", features = ["derive"] }
dashmap = "6.2.1"
dirs = "6.0.0" dirs = "6.0.0"
futures-util = "0.3.34" futures-util = "0.3.34"
glob = "0.3.4" glob = "0.3.4"
redb = "4.2.0"
reqwest = { version = "0.12", default-features = false, features = ["stream", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["stream", "rustls-tls"] }
schemars = "1.2.2" schemars = "1.2.2"
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
+68 -2
View File
@@ -5,13 +5,13 @@
<style> <style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 40px; background-color: #f0f2f5; color: #333; } body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 40px; background-color: #f0f2f5; color: #333; }
h1 { color: #2c3e50; text-align: center; margin-bottom: 30px; } h1 { color: #2c3e50; text-align: center; margin-bottom: 30px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; max-width: 800px; margin: 0 auto; } .grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; max-width: 1000px; margin: 0 auto; }
.stat-card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); text-align: center; transition: transform 0.2s ease; } .stat-card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); text-align: center; transition: transform 0.2s ease; }
.stat-card:hover { transform: translateY(-5px); } .stat-card:hover { transform: translateY(-5px); }
.stat-value { font-size: 2.5em; font-weight: bold; color: #3498db; margin: 10px 0; } .stat-value { font-size: 2.5em; font-weight: bold; color: #3498db; margin: 10px 0; }
.stat-label { font-size: 1.1em; color: #7f8c8d; text-transform: uppercase; letter-spacing: 1px; } .stat-label { font-size: 1.1em; color: #7f8c8d; text-transform: uppercase; letter-spacing: 1px; }
.status-dot { display: inline-block; width: 10px; height: 10px; background-color: #2ecc71; border-radius: 50%; margin-right: 8px; box-shadow: 0 0 5px #2ecc71; } .status-dot { display: inline-block; width: 10px; height: 10px; background-color: #2ecc71; border-radius: 50%; margin-right: 8px; box-shadow: 0 0 5px #2ecc71; }
.header-bar { max-width: 800px; margin: 0 auto 20px; display: flex; justify-content: space-between; align-items: center; } .header-bar { max-width: 1000px; margin: 0 auto 20px; display: flex; justify-content: space-between; align-items: center; }
</style> </style>
</head> </head>
<body> <body>
@@ -45,6 +45,58 @@
<div class="stat-label">ADRs</div> <div class="stat-label">ADRs</div>
<div class="stat-value" id="val-adrs">-</div> <div class="stat-value" id="val-adrs">-</div>
</div> </div>
<div class="stat-card">
<div class="stat-label">Audit Ledger</div>
<div class="stat-value" id="val-ledger">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Sticky Notes</div>
<div class="stat-value" id="val-sticky">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Error Fixes</div>
<div class="stat-value" id="val-error_fixes">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Pinned Files</div>
<div class="stat-value" id="val-pinned_files">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Session Summaries</div>
<div class="stat-value" id="val-session_summaries">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Handoff Memos</div>
<div class="stat-value" id="val-handoff_memos">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Env Fingerprints</div>
<div class="stat-value" id="val-env_fingerprints">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Env Requirements</div>
<div class="stat-value" id="val-env_requirements">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Milestones</div>
<div class="stat-value" id="val-milestones">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Environments</div>
<div class="stat-value" id="val-environments">-</div>
</div>
<div class="stat-card">
<div class="stat-label">PR Checklists</div>
<div class="stat-value" id="val-pr_checklists">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Safety Gates</div>
<div class="stat-value" id="val-gates">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Workspaces</div>
<div class="stat-value" id="val-context_workspaces">-</div>
</div>
</div> </div>
<script> <script>
@@ -59,6 +111,20 @@
document.getElementById('val-snippets').innerText = data.snippets; document.getElementById('val-snippets').innerText = data.snippets;
document.getElementById('val-tech_debts').innerText = data.tech_debts; document.getElementById('val-tech_debts').innerText = data.tech_debts;
document.getElementById('val-adrs').innerText = data.adrs; document.getElementById('val-adrs').innerText = data.adrs;
document.getElementById('val-ledger').innerText = data.ledger;
document.getElementById('val-sticky').innerText = data.sticky;
document.getElementById('val-error_fixes').innerText = data.error_fixes;
document.getElementById('val-pinned_files').innerText = data.pinned_files;
document.getElementById('val-session_summaries').innerText = data.session_summaries;
document.getElementById('val-handoff_memos').innerText = data.handoff_memos;
document.getElementById('val-env_fingerprints').innerText = data.env_fingerprints;
document.getElementById('val-env_requirements').innerText = data.env_requirements;
document.getElementById('val-milestones').innerText = data.milestones;
document.getElementById('val-environments').innerText = data.environments;
document.getElementById('val-pr_checklists').innerText = data.pr_checklists;
document.getElementById('val-gates').innerText = data.gates;
document.getElementById('val-context_workspaces').innerText = data.context_workspaces;
} catch (err) { } catch (err) {
console.error("Failed to fetch stats:", err); console.error("Failed to fetch stats:", err);
} }
+1509 -1113
View File
File diff suppressed because it is too large. Load diff
+431 -146
View File
@@ -1,10 +1,10 @@
mod handlers; mod handlers;
mod models;
mod mcp; mod mcp;
mod models;
mod search;
mod state; mod state;
mod store; mod store;
mod tools; mod tools;
mod search;
use crate::handlers::MemoryHandler; use crate::handlers::MemoryHandler;
use crate::models::*; use crate::models::*;
@@ -15,24 +15,34 @@ use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use redb::ReadableTable;
use tokio::time::sleep; use tokio::time::sleep;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::collections::HashMap; use std::collections::HashMap;
#[derive(Parser)] #[derive(Parser)]
#[command(author, version, about, long_about = None)] #[command(author, version, about = "Antigravity MCP Memory Server", long_about = None)]
struct Cli { struct Cli {
#[command(subcommand)] #[command(subcommand)]
command: Option<Commands>, command: Option<Commands>,
/// Target URL for the proxy to connect to (e.g., http://127.0.0.1:3000)
#[arg(long)] #[arg(long)]
target: Option<String>, target: Option<String>,
/// Run the server as a background daemon process (Windows only)
#[arg(long)] #[arg(long)]
daemon: bool, daemon: bool,
/// Send a shutdown request to the currently running server
#[arg(long)]
exit: bool,
/// Send a shutdown request to the existing server and wait for it to exit
#[arg(long)]
restart: bool,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
enum Commands { enum Commands {
/// Manage authorization gates and verification for actions
Gate { Gate {
#[command(subcommand)] #[command(subcommand)]
subcmd: GateCommands, subcmd: GateCommands,
@@ -71,7 +81,6 @@ enum GateCommands {
}, },
} }
async fn reconcile_worker(state: Arc<MemoryState>) { async fn reconcile_worker(state: Arc<MemoryState>) {
loop { loop {
sleep(Duration::from_secs(5)).await; sleep(Duration::from_secs(5)).await;
@@ -104,21 +113,18 @@ async fn reconcile_worker(state: Arc<MemoryState>) {
} }
} }
use axum::{ use axum::{
extract::{State, Query},
response::sse::{Event, Sse},
routing::{get, post},
Json, Router, Json, Router,
extract::{Query, State},
response::sse::{Event, Sse},
response::IntoResponse,
routing::{get, post},
}; };
use futures_util::stream::Stream; use futures_util::stream::Stream;
use std::convert::Infallible; use std::convert::Infallible;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use std::sync::atomic::{AtomicUsize, Ordering};
struct AppState { struct AppState {
handler: Arc<MemoryHandler>, handler: Arc<MemoryHandler>,
@@ -126,6 +132,103 @@ struct AppState {
next_id: AtomicUsize, next_id: AtomicUsize,
} }
#[derive(serde::Deserialize)]
struct GateVerifyReq {
action: String,
target: String,
namespace: Option<String>,
#[serde(default)]
params: HashMap<String, String>,
#[serde(default)]
consume: bool,
}
#[derive(serde::Deserialize)]
struct GateSetReq {
action: String,
target: String,
namespace: Option<String>,
#[serde(default)]
params: HashMap<String, String>,
authorize: Option<bool>,
block: Option<bool>,
reason: Option<String>,
}
async fn gate_verify_handler(
State(app_state): State<Arc<AppState>>,
Query(q): Query<GateVerifyReq>,
) -> axum::response::Response {
let mut found = None;
let mut to_remove = None;
app_state.handler.state.gates.modify(|gates| {
if let Some(idx) = gates.iter().position(|g| {
g.action == q.action
&& g.target == q.target
&& g.namespace == q.namespace
&& g.params == q.params
}) {
found = Some(gates[idx].clone());
if q.consume {
to_remove = Some(idx);
}
}
if let Some(idx) = to_remove {
gates.remove(idx);
}
});
match found {
Some(record) => {
if record.status == "authorized" {
(axum::http::StatusCode::OK, "Authorized").into_response()
} else {
let msg = if let Some(r) = record.reason {
format!("Action blocked. Reason: {}", r)
} else {
"Action blocked.".to_string()
};
(axum::http::StatusCode::FORBIDDEN, msg).into_response()
}
}
None => {
(axum::http::StatusCode::NOT_FOUND, "Action not yet authorized (no gate record found).").into_response()
}
}
}
async fn gate_set_handler(
State(app_state): State<Arc<AppState>>,
Json(body): Json<GateSetReq>,
) -> axum::response::Response {
let status = if body.block.unwrap_or(false) {
"blocked".to_string()
} else if body.authorize.unwrap_or(false) {
"authorized".to_string()
} else {
"pending".to_string()
};
let record = GateRecord {
id: uuid::Uuid::new_v4().to_string(),
action: body.action.clone(),
target: body.target.clone(),
namespace: body.namespace.clone(),
params: body.params.clone(),
status,
reason: body.reason.clone(),
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
};
app_state.handler.state.gates.modify(|gates| {
gates.retain(|g| !(g.action == record.action && g.target == record.target));
gates.push(record);
});
(axum::http::StatusCode::OK, "Gate state updated.").into_response()
}
fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>> { fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>> {
let rt = tokio::runtime::Runtime::new().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async { rt.block_on(async {
@@ -140,31 +243,74 @@ fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>>
.route("/sse", get(sse_handler)) .route("/sse", get(sse_handler))
.route("/messages", post(message_handler)) .route("/messages", post(message_handler))
.route("/health", get(health_handler)) .route("/health", get(health_handler))
.route("/", get(|| async move { .route("/gate/verify", get(gate_verify_handler))
axum::response::Html(include_str!("dashboard.html")) .route("/gate/set", post(gate_set_handler))
})) .route(
.route("/api/stats", get({ "/shutdown",
let state_clone = app_state.handler.state.clone(); post(|| async move {
move || async move { std::thread::spawn(|| {
let (entities, relations) = { std::thread::sleep(std::time::Duration::from_millis(100));
let graph = state_clone.get_full_graph(); std::process::exit(0);
(graph.entities.len(), graph.relations.len()) });
}; "Shutting down..."
let tasks = state_clone.tasks.read().len(); }),
let snippets = state_clone.snippets.read().len(); )
let tech_debts = state_clone.tech_debts.read().len(); .route(
let adrs = state_clone.adrs.read().len(); "/",
get(|| async move { axum::response::Html(include_str!("dashboard.html")) }),
)
.route(
"/api/stats",
get({
let state_clone = app_state.handler.state.clone();
move || async move {
let (entities, relations) = {
let graph = state_clone.get_full_graph();
(graph.entities.len(), graph.relations.len())
};
let tasks = state_clone.tasks.read().len();
let snippets = state_clone.snippets.read().len();
let tech_debts = state_clone.tech_debts.read().len();
let adrs = state_clone.adrs.read().len();
axum::Json(serde_json::json!({ let ledger = state_clone.ledger.read().len();
"entities": entities, let sticky = state_clone.sticky.read().len();
"relations": relations, let error_fixes = state_clone.error_fixes.read().len();
"tasks": tasks, let pinned_files = state_clone.pinned_files.read().len();
"snippets": snippets, let session_summaries = state_clone.session_summaries.read().len();
"tech_debts": tech_debts, let handoff_memos = state_clone.handoff_memos.read().len();
"adrs": adrs let env_fingerprints = state_clone.env_fingerprints.read().len();
})) let env_requirements = state_clone.env_requirements.read().len();
} let milestones = state_clone.milestones.read().len();
})) let environments = state_clone.environments.read().len();
let pr_checklists = state_clone.pr_checklists.read().len();
let gates = state_clone.gates.read().len();
let context_workspaces = state_clone.context_workspaces.read().len();
axum::Json(serde_json::json!({
"entities": entities,
"relations": relations,
"tasks": tasks,
"snippets": snippets,
"tech_debts": tech_debts,
"adrs": adrs,
"ledger": ledger,
"sticky": sticky,
"error_fixes": error_fixes,
"pinned_files": pinned_files,
"session_summaries": session_summaries,
"handoff_memos": handoff_memos,
"env_fingerprints": env_fingerprints,
"env_requirements": env_requirements,
"milestones": milestones,
"environments": environments,
"pr_checklists": pr_checklists,
"gates": gates,
"context_workspaces": context_workspaces
}))
}
}),
)
.with_state(app_state); .with_state(app_state);
let mut retries = 0; let mut retries = 0;
@@ -172,16 +318,48 @@ fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>>
match tokio::net::TcpListener::bind("127.0.0.1:3000").await { match tokio::net::TcpListener::bind("127.0.0.1:3000").await {
Ok(l) => break l, Ok(l) => break l,
Err(e) => { Err(e) => {
// Check if it's already running and healthy
if let Ok(mut stream) = std::net::TcpStream::connect("127.0.0.1:3000") {
use std::io::{Read, Write};
let _ = stream.write_all(
b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
);
let mut response = String::new();
let _ = stream.read_to_string(&mut response);
if response.contains("200 OK") {
// Already healthy! Just exit cleanly instead of panicking/retrying loop.
std::process::exit(0);
}
}
retries += 1; retries += 1;
if retries > 15 { if retries > 15 {
let log_path = dirs::home_dir().unwrap_or_default().join(".gemini/mcp_memory/daemon_fatal.log"); let log_path = dirs::home_dir()
let _ = std::fs::write(&log_path, format!("FATAL: Could not bind to 127.0.0.1:3000 after 15 seconds: {}\n", e)); .unwrap_or_default()
.join(".gemini/mcp_memory/daemon_fatal.log");
let _ = std::fs::write(
&log_path,
format!(
"FATAL: Could not bind to 127.0.0.1:3000 after 15 seconds: {}\n",
e
),
);
std::process::exit(1); std::process::exit(1);
} }
let log_path = dirs::home_dir().unwrap_or_default().join(".gemini/mcp_memory/daemon_error.log"); let log_path = dirs::home_dir()
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) { .unwrap_or_default()
.join(".gemini/mcp_memory/daemon_error.log");
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
use std::io::Write; use std::io::Write;
let _ = writeln!(file, "Failed to bind to 127.0.0.1:3000 (attempt {}): {}. Retrying in 1s...", retries, e); let _ = writeln!(
file,
"Failed to bind to 127.0.0.1:3000 (attempt {}): {}. Retrying in 1s...",
retries, e
);
} }
tokio::time::sleep(std::time::Duration::from_secs(1)).await; tokio::time::sleep(std::time::Duration::from_secs(1)).await;
} }
@@ -189,7 +367,9 @@ fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>>
}; };
eprintln!("MCP Memory Server running on http://127.0.0.1:3000/sse"); eprintln!("MCP Memory Server running on http://127.0.0.1:3000/sse");
if let Err(e) = axum::serve(listener, app).await { if let Err(e) = axum::serve(listener, app).await {
let log_path = dirs::home_dir().unwrap_or_default().join(".gemini/mcp_memory/daemon_error.log"); let log_path = dirs::home_dir()
.unwrap_or_default()
.join(".gemini/mcp_memory/daemon_error.log");
let _ = std::fs::write(&log_path, format!("Server crashed: {}\n", e)); let _ = std::fs::write(&log_path, format!("Server crashed: {}\n", e));
} }
Ok(()) Ok(())
@@ -201,11 +381,19 @@ async fn sse_handler(
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> { ) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let session_id = format!("{}", state.next_id.fetch_add(1, Ordering::SeqCst)); let session_id = format!("{}", state.next_id.fetch_add(1, Ordering::SeqCst));
let (tx, rx) = mpsc::channel::<Result<Event, Infallible>>(100); let (tx, rx) = mpsc::channel::<Result<Event, Infallible>>(100);
state.clients.write().unwrap().insert(session_id.clone(), tx.clone()); state
.clients
let _ = tx.send(Ok(Event::default().event("endpoint").data(format!("/messages?sessionId={}", session_id)))).await; .write()
.unwrap()
.insert(session_id.clone(), tx.clone());
let _ = tx
.send(Ok(Event::default()
.event("endpoint")
.data(format!("/messages?sessionId={}", session_id))))
.await;
let stream = ReceiverStream::new(rx); let stream = ReceiverStream::new(rx);
Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::new()) Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::new())
} }
@@ -228,32 +416,56 @@ async fn message_handler(
let handler = Arc::clone(&state.handler); let handler = Arc::clone(&state.handler);
let session_id = query.session_id.clone(); let session_id = query.session_id.clone();
let clients = Arc::clone(&state); let clients = Arc::clone(&state);
tokio::spawn(async move { tokio::spawn(async move {
if let Some(response) = handler.handle_request(payload).await { if let Some(response) = handler.handle_request(payload).await {
let tx_opt = clients.clients.read().unwrap().get(&session_id).cloned(); let tx_opt = clients.clients.read().unwrap().get(&session_id).cloned();
if let Some(tx) = tx_opt { if let Some(tx) = tx_opt {
let data = serde_json::to_string(&response).unwrap(); let data = serde_json::to_string(&response).unwrap();
let _ = tx.send(Ok(Event::default().event("message").data(data))).await; let _ = tx
.send(Ok(Event::default().event("message").data(data)))
.await;
} }
} }
}); });
axum::http::StatusCode::ACCEPTED axum::http::StatusCode::ACCEPTED
} }
mod proxy; mod proxy;
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse(); let cli = Cli::parse();
if cli.exit {
if let Ok(mut stream) = std::net::TcpStream::connect("127.0.0.1:3000") {
use std::io::Write;
let _ = stream.write_all(
b"POST /shutdown HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
);
}
println!("Sent shutdown request to server.");
return Ok(());
}
if cli.restart {
if let Ok(mut stream) = std::net::TcpStream::connect("127.0.0.1:3000") {
use std::io::Write;
let _ = stream.write_all(
b"POST /shutdown HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
);
println!("Sent shutdown request to existing server. Waiting for it to exit...");
std::thread::sleep(std::time::Duration::from_millis(1500));
}
return Ok(());
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
if !cli.daemon { if !cli.daemon {
loop { loop {
if let Err(_) = std::net::TcpListener::bind("127.0.0.1:3000") { if std::net::TcpListener::bind("127.0.0.1:3000").is_err() {
// Port in use, become a stub proxy! // Port in use, become a stub proxy!
let target_url = cli.target.as_deref().unwrap_or("http://127.0.0.1:3000"); let target_url = cli.target.as_deref().unwrap_or("http://127.0.0.1:3000");
match proxy::run_proxy(target_url) { match proxy::run_proxy(target_url) {
@@ -266,7 +478,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} else { } else {
// Port is free. We must spawn the daemon, then loop again to become proxy // Port is free. We must spawn the daemon, then loop again to become proxy
std::process::Command::new(std::env::current_exe().unwrap()) #[allow(clippy::zombie_processes)]
let _ = std::process::Command::new(std::env::current_exe().unwrap())
.arg("--daemon") .arg("--daemon")
.stdin(std::process::Stdio::null()) .stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null()) .stdout(std::process::Stdio::null())
@@ -300,106 +513,178 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let base = PathBuf::from(base_dir); let base = PathBuf::from(base_dir);
fs::create_dir_all(&base).expect("Failed to create store dir"); fs::create_dir_all(&base).expect("Failed to create store dir");
let state = Arc::new(MemoryState { let redb_path = base.join("mcp_store.redb");
master_path: base.join("knowledge_graph_master.json"), let db = Arc::new(redb::Database::create(&redb_path).unwrap());
session_graph: RwLock::new(KnowledgeGraph::default()),
base_dir: base.clone(), // Ensure table exists and migrate old JSON files
master_cache: RwLock::new((KnowledgeGraph::default(), SystemTime::UNIX_EPOCH)), {
search_index: RwLock::new(crate::search::MemoryIndex::new().unwrap()), let write_txn = db.begin_write().unwrap();
ledger: Store::new(base.join("audit_ledger.json")), {
sticky: Store::new(base.join("sticky_notes.json")), let mut table = write_txn.open_table(crate::store::STORE_TABLE).unwrap();
tasks: Store::new(base.join("tasks.json")),
snippets: Store::new(base.join("snippets.json")), let stores = [
adrs: Store::new(base.join("adrs.json")), ("audit_ledger", "audit_ledger.json"),
prefs: Store::new(base.join("preferences.json")), ("sticky_notes", "sticky_notes.json"),
error_fixes: Store::new(base.join("error_fixes.json")), ("tasks", "tasks.json"),
pinned_files: Store::new(base.join("pinned_files.json")), ("snippets", "snippets.json"),
session_summaries: Store::new(base.join("session_summaries.json")), ("adrs", "adrs.json"),
handoff_memos: Store::new(base.join("handoff_memos.json")), ("preferences", "preferences.json"),
env_fingerprints: Store::new(base.join("env_fingerprints.json")), ("error_fixes", "error_fixes.json"),
env_requirements: Store::new(base.join("env_requirements.json")), ("pinned_files", "pinned_files.json"),
milestones: Store::new(base.join("milestones.json")), ("session_summaries", "session_summaries.json"),
environments: Store::new(base.join("environments.json")), ("handoff_memos", "handoff_memos.json"),
pr_checklists: Store::new(base.join("pr_checklists.json")), ("env_fingerprints", "env_fingerprints.json"),
tech_debts: Store::new(base.join("tech_debts.json")), ("env_requirements", "env_requirements.json"),
gates: Store::new(base.join("gates.json")), ("milestones", "milestones.json"),
context_workspaces: Store::new(base.join("context_workspaces.json")), ("environments", "environments.json"),
}); ("pr_checklists", "pr_checklists.json"),
("tech_debts", "tech_debts.json"),
("gates", "gates.json"),
("context_workspaces", "context_workspaces.json"),
];
state.rebuild_index(); for (key, file_name) in stores.iter() {
if table.get(*key).unwrap().is_none() {
if let Some(command) = cli.command { let json_path = base.join(file_name);
match command { if json_path.exists() {
Commands::Gate { subcmd } => { if let Ok(data) = fs::read(&json_path) {
match subcmd { if serde_json::from_slice::<serde_json::Value>(&data).is_ok() {
GateCommands::Set { action, target, namespace, params, authorize, block, reason } => { table.insert(*key, data.as_slice()).unwrap();
let status = if authorize { "authorized".to_string() } else if block { "blocked".to_string() } else { "pending".to_string() };
let mut param_map = HashMap::new();
for p in params {
if let Some((k, v)) = p.split_once('=') {
param_map.insert(k.to_string(), v.to_string());
}
}
let record = GateRecord {
id: uuid::Uuid::new_v4().to_string(),
action: action.clone(),
target: target.clone(),
namespace,
params: param_map,
status,
reason,
timestamp: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(),
};
state.gates.modify(|gates| {
gates.retain(|g| !(g.action == record.action && g.target == record.target));
gates.push(record);
});
println!("Gate state updated.");
std::process::exit(0);
}
GateCommands::Verify { action, target, namespace, params, consume } => {
let mut param_map = HashMap::new();
for p in params {
if let Some((k, v)) = p.split_once('=') {
param_map.insert(k.to_string(), v.to_string());
}
}
let mut found = None;
let mut to_remove = None;
state.gates.modify(|gates| {
if let Some(idx) = gates.iter().position(|g| g.action == action && g.target == target && g.namespace == namespace && g.params == param_map) {
found = Some(gates[idx].clone());
if consume {
to_remove = Some(idx);
}
}
if let Some(idx) = to_remove {
gates.remove(idx);
}
});
match found {
Some(record) => {
if record.status == "authorized" {
std::process::exit(0);
} else {
if let Some(r) = record.reason {
eprintln!("❌ Action blocked. Reason: {}", r);
} else {
eprintln!("❌ Action blocked.");
}
std::process::exit(1);
}
}
None => {
eprintln!("❌ Action not yet authorized (no gate record found).");
std::process::exit(2);
} }
} }
} }
} }
} }
} }
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.rebuild_index();
if let Some(command) = cli.command {
match command {
Commands::Gate { subcmd } => match subcmd {
GateCommands::Set {
action,
target,
namespace,
params,
authorize,
block,
reason,
} => {
let status = if authorize {
"authorized".to_string()
} else if block {
"blocked".to_string()
} else {
"pending".to_string()
};
let mut param_map = HashMap::new();
for p in params {
if let Some((k, v)) = p.split_once('=') {
param_map.insert(k.to_string(), v.to_string());
}
}
let record = GateRecord {
id: uuid::Uuid::new_v4().to_string(),
action: action.clone(),
target: target.clone(),
namespace,
params: param_map,
status,
reason,
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
};
state.gates.modify(|gates| {
gates.retain(|g| !(g.action == record.action && g.target == record.target));
gates.push(record);
});
println!("Gate state updated.");
std::process::exit(0);
}
GateCommands::Verify {
action,
target,
namespace,
params,
consume,
} => {
let mut param_map = HashMap::new();
for p in params {
if let Some((k, v)) = p.split_once('=') {
param_map.insert(k.to_string(), v.to_string());
}
}
let mut found = None;
let mut to_remove = None;
state.gates.modify(|gates| {
if let Some(idx) = gates.iter().position(|g| {
g.action == action
&& g.target == target
&& g.namespace == namespace
&& g.params == param_map
}) {
found = Some(gates[idx].clone());
if consume {
to_remove = Some(idx);
}
}
if let Some(idx) = to_remove {
gates.remove(idx);
}
});
match found {
Some(record) => {
if record.status == "authorized" {
std::process::exit(0);
} else {
if let Some(r) = record.reason {
eprintln!("❌ Action blocked. Reason: {}", r);
} else {
eprintln!("❌ Action blocked.");
}
std::process::exit(1);
}
}
None => {
eprintln!("❌ Action not yet authorized (no gate record found).");
std::process::exit(2);
}
}
}
},
}
} }
run_server(state) run_server(state)
+1 -2
View File
@@ -165,8 +165,7 @@ pub struct ContextWorkspace {
pub saved_at: u64, pub saved_at: u64,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Default)]
pub struct GateRecord { pub struct GateRecord {
pub id: String, pub id: String,
pub action: String, pub action: String,
+69 -51
View File
@@ -1,9 +1,9 @@
use tokio_util::io::StreamReader;
use tokio::io::AsyncBufReadExt;
use futures_util::StreamExt; use futures_util::StreamExt;
use std::sync::Arc; use std::sync::Arc;
use tokio::io::AsyncBufReadExt;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_util::io::StreamReader;
pub fn run_proxy(target_url: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { pub fn run_proxy(target_url: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let rt = tokio::runtime::Runtime::new().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
@@ -16,7 +16,9 @@ pub fn run_proxy(target_url: &str) -> Result<bool, Box<dyn std::error::Error + S
let mut handle = stdin.lock(); let mut handle = stdin.lock();
let mut buffer = String::new(); let mut buffer = String::new();
while let Ok(bytes) = std::io::BufRead::read_line(&mut handle, &mut buffer) { while let Ok(bytes) = std::io::BufRead::read_line(&mut handle, &mut buffer) {
if bytes == 0 { break; } if bytes == 0 {
break;
}
let _ = msg_tx.blocking_send(buffer.clone()); let _ = msg_tx.blocking_send(buffer.clone());
buffer.clear(); buffer.clear();
} }
@@ -26,7 +28,7 @@ pub fn run_proxy(target_url: &str) -> Result<bool, Box<dyn std::error::Error + S
let target_url = target_url.to_string(); let target_url = target_url.to_string();
let post_url = Arc::new(RwLock::new(String::new())); let post_url = Arc::new(RwLock::new(String::new()));
let post_url_clone = Arc::clone(&post_url); let post_url_clone = Arc::clone(&post_url);
let client = reqwest::Client::builder().build().unwrap(); let client = reqwest::Client::builder().build().unwrap();
tokio::spawn(async move { tokio::spawn(async move {
@@ -34,14 +36,16 @@ pub fn run_proxy(target_url: &str) -> Result<bool, Box<dyn std::error::Error + S
loop { loop {
let url = post_url_clone.read().await.clone(); let url = post_url_clone.read().await.clone();
if !url.is_empty() { if !url.is_empty() {
let res = client.post(&url) let res = client
.post(&url)
.header("Accept", "application/json, text/event-stream") .header("Accept", "application/json, text/event-stream")
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.body(msg.clone()) .body(msg.clone())
.send().await; .send()
.await;
if res.is_ok() { if res.is_ok() {
break; break;
} }
} }
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
@@ -49,55 +53,69 @@ pub fn run_proxy(target_url: &str) -> Result<bool, Box<dyn std::error::Error + S
} }
}); });
loop { if shutdown_rx.try_recv().is_ok() {
if shutdown_rx.try_recv().is_ok() { return Ok(false);
return Ok(false); }
}
let sse_url = format!("{}/sse", target_url); let sse_url = format!("{}/sse", target_url);
let client = reqwest::Client::builder().build().unwrap(); let client = reqwest::Client::builder().build().unwrap();
match client.get(&sse_url).header("Accept", "text/event-stream").send().await { match client
Ok(resp) => { .get(&sse_url)
if resp.status() == reqwest::StatusCode::GONE { .header("Accept", "text/event-stream")
return Ok(true); .send()
} .await
{
let stream = resp.bytes_stream().map(|res| res.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))); Ok(resp) => {
let mut reader = tokio::io::BufReader::new(StreamReader::new(stream)); if resp.status() == reqwest::StatusCode::GONE {
let mut line = String::new(); return Ok(true);
let mut is_message = false; }
let mut is_endpoint = false;
let stream = resp.bytes_stream().map(|res| {
while let Ok(bytes) = reader.read_line(&mut line).await { res.map_err(std::io::Error::other)
if bytes == 0 { break; } });
let trimmed = line.trim(); let mut reader = tokio::io::BufReader::new(StreamReader::new(stream));
if trimmed.starts_with("event: message") { let mut line = String::new();
is_message = true; let mut is_message = false;
is_endpoint = false; let mut is_endpoint = false;
} else if trimmed.starts_with("event: endpoint") {
is_endpoint = true; loop {
is_message = false; tokio::select! {
} else if trimmed.starts_with("data: ") { _ = shutdown_rx.recv() => {
if is_message { return Ok(false);
println!("{}", &trimmed[6..]); }
is_message = false; res = reader.read_line(&mut line) => {
} else if is_endpoint { match res {
let ep = &trimmed[6..]; Ok(bytes) => {
let mut p = post_url.write().await; if bytes == 0 { break; }
*p = format!("{}{}", target_url, ep); let trimmed = line.trim();
is_endpoint = false; if trimmed.starts_with("event: message") {
is_message = true;
is_endpoint = false;
} else if trimmed.starts_with("event: endpoint") {
is_endpoint = true;
is_message = false;
} else if let Some(stripped) = trimmed.strip_prefix("data: ") {
if is_message {
println!("{}", stripped);
is_message = false;
} else if is_endpoint {
let mut p = post_url.write().await;
*p = format!("{}{}", target_url, stripped);
is_endpoint = false;
}
}
line.clear();
}
Err(_) => break,
} }
} }
line.clear();
} }
*post_url.write().await = String::new();
return Ok(true);
}
Err(_) => {
return Ok(true);
} }
*post_url.write().await = String::new();
Ok(true)
} }
Err(_) => Ok(true),
} }
}) })
} }
+41 -18
View File
@@ -1,13 +1,13 @@
use tantivy::schema::*; use crate::models::{Adr, Entity, Snippet, Task};
use tantivy::{doc, Index, IndexWriter, IndexReader, ReloadPolicy};
use std::sync::Mutex; use std::sync::Mutex;
use crate::models::{Entity, Task, Adr, Snippet}; use tantivy::schema::*;
use tantivy::{Index, IndexReader, IndexWriter, ReloadPolicy, doc};
pub struct MemoryIndex { pub struct MemoryIndex {
index: Index, index: Index,
reader: IndexReader, reader: IndexReader,
writer: Mutex<IndexWriter>, writer: Mutex<IndexWriter>,
// Schema fields // Schema fields
pub id_field: Field, pub id_field: Field,
pub title_field: Field, pub title_field: Field,
@@ -17,7 +17,7 @@ pub struct MemoryIndex {
} }
impl MemoryIndex { impl MemoryIndex {
pub fn new() -> tantivy::Result<Self> { pub fn new(store_dir: &std::path::Path) -> tantivy::Result<Self> {
let mut schema_builder = Schema::builder(); let mut schema_builder = Schema::builder();
let id_field = schema_builder.add_text_field("id", STRING | STORED); let id_field = schema_builder.add_text_field("id", STRING | STORED);
let title_field = schema_builder.add_text_field("title", TEXT | STORED); let title_field = schema_builder.add_text_field("title", TEXT | STORED);
@@ -25,14 +25,17 @@ impl MemoryIndex {
let type_field = schema_builder.add_text_field("type", STRING | STORED); let type_field = schema_builder.add_text_field("type", STRING | STORED);
let namespace_field = schema_builder.add_text_field("namespace", STRING | STORED); let namespace_field = schema_builder.add_text_field("namespace", STRING | STORED);
let schema = schema_builder.build(); let schema = schema_builder.build();
let index = Index::create_in_ram(schema.clone()); let index_dir = store_dir.join("tantivy_index");
std::fs::create_dir_all(&index_dir).unwrap();
let index = Index::open_in_dir(&index_dir).unwrap_or_else(|_| Index::create_in_dir(&index_dir, schema.clone()).unwrap());
let writer = index.writer(50_000_000)?; let writer = index.writer(50_000_000)?;
let reader = index let reader = index
.reader_builder() .reader_builder()
.reload_policy(ReloadPolicy::OnCommitWithDelay) .reload_policy(ReloadPolicy::OnCommitWithDelay)
.try_into()?; .try_into()?;
Ok(Self { Ok(Self {
index, index,
reader, reader,
@@ -71,23 +74,43 @@ impl MemoryIndex {
Ok(()) Ok(())
} }
pub fn search(&self, query: &str, namespace: Option<&str>) -> tantivy::Result<Vec<(String, String)>> { pub fn search(
&self,
query: &str,
namespace: Option<&str>,
) -> tantivy::Result<Vec<(String, String)>> {
let searcher = self.reader.searcher(); let searcher = self.reader.searcher();
let query_parser = tantivy::query::QueryParser::for_index(&self.index, vec![self.title_field, self.body_field]); let query_parser = tantivy::query::QueryParser::for_index(
&self.index,
vec![self.title_field, self.body_field],
);
let q = query_parser.parse_query(query)?; let q = query_parser.parse_query(query)?;
let top_docs = searcher.search(&q, &tantivy::collector::TopDocs::with_limit(50).order_by_score())?; let top_docs = searcher.search(
&q,
&tantivy::collector::TopDocs::with_limit(50).order_by_score(),
)?;
let mut results = Vec::new(); let mut results = Vec::new();
for (_score, doc_address) in top_docs { for (_score, doc_address) in top_docs {
let retrieved_doc = searcher.doc::<tantivy::TantivyDocument>(doc_address)?; let retrieved_doc = searcher.doc::<tantivy::TantivyDocument>(doc_address)?;
let id = retrieved_doc.get_first(self.id_field).and_then(|v| v.as_str()).unwrap_or("").to_string(); let id = retrieved_doc
let doc_type = retrieved_doc.get_first(self.type_field).and_then(|v| v.as_str()).unwrap_or("").to_string(); .get_first(self.id_field)
let doc_ns = retrieved_doc.get_first(self.namespace_field).and_then(|v| v.as_str()).unwrap_or(""); .and_then(|v| v.as_str())
if let Some(ns) = namespace { .unwrap_or("")
if doc_ns != ns && doc_ns != "global" { .to_string();
let doc_type = retrieved_doc
.get_first(self.type_field)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let doc_ns = retrieved_doc
.get_first(self.namespace_field)
.and_then(|v| v.as_str())
.unwrap_or("");
if let Some(ns) = namespace
&& doc_ns != ns && doc_ns != "global" {
continue; continue;
} }
}
results.push((id, doc_type)); results.push((id, doc_type));
} }
Ok(results) Ok(results)
+42 -11
View File
@@ -1,6 +1,6 @@
use crate::models::*; use crate::models::*;
use crate::store::{Store, read_json_file, write_json_atomic};
use crate::search::MemoryIndex; use crate::search::MemoryIndex;
use crate::store::Store;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@@ -76,7 +76,21 @@ impl MemoryState {
let mut lock = self.master_cache.write().unwrap(); let mut lock = self.master_cache.write().unwrap();
let new_mtime = self.master_mtime(); let new_mtime = self.master_mtime();
if lock.1 != new_mtime { if lock.1 != new_mtime {
lock.0 = read_json_file(&self.master_path); if let Ok(data) = fs::read(&self.master_path)
&& let Ok(parsed) = serde_json::from_slice(&data)
{
lock.0 = parsed;
} else {
let bak_path = self.master_path.with_extension("json.bak");
if let Ok(data) = fs::read(&bak_path)
&& let Ok(parsed) = serde_json::from_slice(&data)
{
let _ = fs::write(&self.master_path, data);
lock.0 = parsed;
} else {
lock.0 = KnowledgeGraph::default();
}
}
lock.1 = new_mtime; lock.1 = new_mtime;
} }
lock.0.clone() lock.0.clone()
@@ -100,12 +114,15 @@ impl MemoryState {
let mut session_graph = self.session_graph.write().unwrap(); let mut session_graph = self.session_graph.write().unwrap();
update_fn(&mut session_graph); update_fn(&mut session_graph);
let wal_path = self.base_dir.join("wal.jsonl"); let wal_path = self.base_dir.join("wal.jsonl");
if let Ok(payload) = serde_json::to_string(&*session_graph) { if let Ok(payload) = serde_json::to_string(&*session_graph)
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&wal_path) { && let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&wal_path)
{
use std::io::Write; use std::io::Write;
let _ = writeln!(file, "{}", payload); let _ = writeln!(file, "{}", payload);
} }
}
} }
pub fn apply_sync_write<F: FnOnce(&mut KnowledgeGraph)>(&self, update_fn: F) { pub fn apply_sync_write<F: FnOnce(&mut KnowledgeGraph)>(&self, update_fn: F) {
let lock_path = self.base_dir.join("master.lock"); let lock_path = self.base_dir.join("master.lock");
@@ -130,7 +147,18 @@ impl MemoryState {
let _ = fs::remove_file(&wal_path); let _ = fs::remove_file(&wal_path);
*self.session_graph.write().unwrap() = KnowledgeGraph::default(); *self.session_graph.write().unwrap() = KnowledgeGraph::default();
update_fn(&mut master); update_fn(&mut master);
let _ = write_json_atomic(&self.master_path, &master);
let write_json = |path: &std::path::Path, data: &KnowledgeGraph| -> std::io::Result<()> {
if path.exists() {
let bak_path = path.with_extension("json.bak");
let _ = fs::copy(path, &bak_path);
}
let tmp_path = path.with_extension("json.tmp");
let json_data = serde_json::to_string_pretty(data)?;
fs::write(&tmp_path, json_data)?;
fs::rename(&tmp_path, path)
};
let _ = write_json(&self.master_path, &master);
let mut cache_lock = self.master_cache.write().unwrap(); let mut cache_lock = self.master_cache.write().unwrap();
cache_lock.0 = master; cache_lock.0 = master;
@@ -140,9 +168,14 @@ impl MemoryState {
} }
pub fn rebuild_index(&self) { pub fn rebuild_index(&self) {
if let Ok(new_idx) = MemoryIndex::new() { if let Ok(new_idx) = MemoryIndex::new(&self.base_dir) {
let full = self.get_full_graph(); let session = self.session_graph.read().unwrap();
for (_, e) in &full.entities { let mut full = {
let cache = self.master_cache.read().unwrap();
cache.0.clone()
};
Self::merge_graphs(&mut full, &session);
for e in full.entities.values() {
let _ = new_idx.index_entity(e); let _ = new_idx.index_entity(e);
} }
for t in self.tasks.read() { for t in self.tasks.read() {
@@ -160,5 +193,3 @@ impl MemoryState {
} }
} }
} }
+37 -64
View File
@@ -1,82 +1,55 @@
use serde::{de::DeserializeOwned, Serialize}; use redb::{Database, ReadableDatabase, TableDefinition};
use std::fs; use serde::{Serialize, de::DeserializeOwned};
use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock};
use std::sync::RwLock;
use std::time::SystemTime;
pub fn read_json_file<T: DeserializeOwned + Default>(path: &Path) -> T { pub const STORE_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("store");
if let Ok(data) = fs::read(path)
&& let Ok(parsed) = serde_json::from_slice(&data) {
return parsed;
}
let bak_path = path.with_extension("json.bak");
if let Ok(data) = fs::read(&bak_path)
&& let Ok(parsed) = serde_json::from_slice(&data) {
let _ = fs::write(path, data);
return parsed;
}
T::default()
}
pub fn write_json_atomic<T: Serialize>(path: &Path, data: &T) -> std::io::Result<()> {
if path.exists() {
let bak_path = path.with_extension("json.bak");
let _ = fs::copy(path, &bak_path);
}
let tmp_path = path.with_extension("json.tmp");
let json_data = serde_json::to_string_pretty(data)?;
fs::write(&tmp_path, json_data)?;
fs::rename(&tmp_path, path)
}
pub struct Store<T> { pub struct Store<T> {
pub path: PathBuf, pub key: String,
pub cache: RwLock<(T, SystemTime)>, pub db: Arc<Database>,
pub cache: RwLock<T>,
} }
impl<T: DeserializeOwned + Default + Serialize + Clone> Store<T> { impl<T: DeserializeOwned + Default + Serialize + Clone> Store<T> {
fn mtime(&self) -> SystemTime { pub fn new(key: &str, db: Arc<Database>) -> Self {
fs::metadata(&self.path) let initial_data = Self::load_from_db(key, &db);
.and_then(|m| m.modified()) Self {
.unwrap_or(SystemTime::UNIX_EPOCH) key: key.to_string(),
db,
cache: RwLock::new(initial_data),
}
} }
pub fn new(path: PathBuf) -> Self { fn load_from_db(key: &str, db: &Database) -> T {
let mtime = fs::metadata(&path) let read_txn = db.begin_read().unwrap();
.and_then(|m| m.modified()) if let Ok(table) = read_txn.open_table(STORE_TABLE) {
.unwrap_or(SystemTime::UNIX_EPOCH); if let Ok(Some(value)) = table.get(key) {
let initial_data = read_json_file(&path); if let Ok(parsed) = serde_json::from_slice::<T>(value.value()) {
Self { return parsed;
path, }
cache: RwLock::new((initial_data, mtime)), }
} }
T::default()
}
fn save_to_db(key: &str, db: &Database, data: &T) {
let write_txn = db.begin_write().unwrap();
{
let mut table = write_txn.open_table(STORE_TABLE).unwrap();
let json_data = serde_json::to_vec(data).unwrap();
table.insert(key, json_data.as_slice()).unwrap();
}
write_txn.commit().unwrap();
} }
pub fn read(&self) -> T { pub fn read(&self) -> T {
let current_mtime = self.mtime(); let lock = self.cache.read().unwrap();
{ lock.clone()
let lock = self.cache.read().unwrap();
if lock.1 == current_mtime {
return lock.0.clone();
}
}
let mut lock = self.cache.write().unwrap();
let new_mtime = self.mtime();
if lock.1 != new_mtime {
lock.0 = read_json_file(&self.path);
lock.1 = new_mtime;
}
lock.0.clone()
} }
pub fn modify<F: FnOnce(&mut T)>(&self, f: F) { pub fn modify<F: FnOnce(&mut T)>(&self, f: F) {
let mut lock = self.cache.write().unwrap(); let mut lock = self.cache.write().unwrap();
let current_mtime = self.mtime(); f(&mut lock);
if lock.1 != current_mtime { Self::save_to_db(&self.key, &self.db, &lock);
lock.0 = read_json_file(&self.path);
}
f(&mut lock.0);
let _ = write_json_atomic(&self.path, &lock.0);
lock.1 = self.mtime();
} }
} }
+3 -1
View File
@@ -78,7 +78,9 @@ pub struct UpdateTaskStatusTool {
pub status: String, pub status: String,
} }
#[derive(Debug, Deserialize, Serialize, JsonSchema)] #[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ListActiveTasksTool { pub git_branch: Option<String>, } pub struct ListActiveTasksTool {
pub git_branch: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)] #[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct StoreSnippetTool { pub struct StoreSnippetTool {
pub name: String, pub name: String,
+81 -54
View File
@@ -3,14 +3,16 @@ use futures_util::StreamExt;
use std::sync::Arc; use std::sync::Arc;
use tokio::io::AsyncBufReadExt; use tokio::io::AsyncBufReadExt;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tokio_util::io::StreamReader;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_util::io::StreamReader;
#[derive(Parser)] #[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 { struct Cli {
/// Target URL for the stub to proxy messages to
#[arg(long, default_value = "http://localhost:3000")] #[arg(long, default_value = "http://localhost:3000")]
target: String, target: String,
/// Optional command to execute if the target server is unreachable
#[arg(long)] #[arg(long)]
wake_cmd: Option<String>, wake_cmd: Option<String>,
} }
@@ -22,46 +24,51 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let (msg_tx, mut msg_rx) = mpsc::channel::<String>(100); let (msg_tx, mut msg_rx) = mpsc::channel::<String>(100);
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1); let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
tokio::task::spawn_blocking(move || { tokio::spawn(async move {
let stdin = std::io::stdin(); let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
let mut handle = stdin.lock();
let mut buffer = String::new(); let mut buffer = String::new();
while let Ok(bytes) = std::io::BufRead::read_line(&mut handle, &mut buffer) { while let Ok(bytes) = stdin.read_line(&mut buffer).await {
if bytes == 0 { break; } if bytes == 0 {
let _ = msg_tx.blocking_send(buffer.clone()); break;
}
let _ = msg_tx.send(buffer.clone()).await;
buffer.clear(); buffer.clear();
} }
let _ = shutdown_tx.blocking_send(()); let _ = shutdown_tx.send(()).await;
}); });
let target_url = cli.target; let target_url = cli.target;
let post_url = Arc::new(RwLock::new(String::new())); let post_url = Arc::new(RwLock::new(String::new()));
let post_url_clone = Arc::clone(&post_url); let post_url_proxy = Arc::clone(&post_url);
let client = reqwest::Client::builder().build()?; let client = reqwest::Client::builder().build()?;
tokio::spawn(async move { tokio::spawn(async move {
while let Some(msg) = msg_rx.recv().await { while let Some(msg) = msg_rx.recv().await {
let mut attempts = 0; let post_url_clone = Arc::clone(&post_url_proxy);
loop { let client = client.clone();
let url = post_url_clone.read().await.clone(); tokio::spawn(async move {
if !url.is_empty() { let mut attempts = 0;
let res = client.post(&url) loop {
.header("Accept", "application/json, text/event-stream") let url = post_url_clone.read().await.clone();
.header("Content-Type", "application/json") if !url.is_empty() {
.body(msg.clone()) let res = client
.send().await; .post(&url)
.header("Accept", "application/json, text/event-stream")
if res.is_ok() { .header("Content-Type", "application/json")
break; .body(msg.clone())
.send()
.await;
if let Ok(resp) = res { if resp.status().is_success() { break; } }
}
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
attempts += 1;
if attempts % 10 == 0 {
eprintln!("[PROXY] Waiting for server to accept messages...");
} }
} }
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; });
attempts += 1;
if attempts % 10 == 0 {
eprintln!("[PROXY] Waiting for server to accept messages...");
}
}
} }
}); });
@@ -73,44 +80,62 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let sse_url = format!("{}/sse", target_url); let sse_url = format!("{}/sse", target_url);
let client = reqwest::Client::builder().build()?; let client = reqwest::Client::builder().build()?;
match client.get(&sse_url).header("Accept", "text/event-stream").send().await { match client
.get(&sse_url)
.header("Accept", "text/event-stream")
.send()
.await
{
Ok(resp) => { Ok(resp) => {
if resp.status() == reqwest::StatusCode::GONE { if resp.status() == reqwest::StatusCode::GONE {
eprintln!("[PROXY] Target gone, exiting."); eprintln!("[PROXY] Target gone, exiting.");
break; break;
} }
let stream = resp.bytes_stream().map(|res| res.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))); let stream = resp.bytes_stream().map(|res| {
res.map_err(std::io::Error::other)
});
let mut reader = tokio::io::BufReader::new(StreamReader::new(stream)); let mut reader = tokio::io::BufReader::new(StreamReader::new(stream));
let mut line = String::new(); let mut line = String::new();
let mut is_message = false; let mut is_message = false;
let mut is_endpoint = false; let mut is_endpoint = false;
while let Ok(bytes) = reader.read_line(&mut line).await { loop {
if bytes == 0 { break; } tokio::select! {
let trimmed = line.trim(); _ = shutdown_rx.recv() => {
if trimmed.starts_with("event: message") { return Ok(()); // Stdin closed, exit entirely
is_message = true; }
is_endpoint = false; res = reader.read_line(&mut line) => {
} else if trimmed.starts_with("event: endpoint") { match res {
is_endpoint = true; Ok(bytes) => {
is_message = false; if bytes == 0 { break; }
} else if trimmed.starts_with("data: ") { let trimmed = line.trim();
if is_message { if trimmed.starts_with("event: message") {
println!("{}", &trimmed[6..]); is_message = true;
is_message = false; is_endpoint = false;
} else if is_endpoint { } else if trimmed.starts_with("event: endpoint") {
let ep = &trimmed[6..]; is_endpoint = true;
let mut p = post_url.write().await; is_message = false;
*p = format!("{}{}", target_url, ep); } else if let Some(stripped) = trimmed.strip_prefix("data: ") {
is_endpoint = false; if is_message {
println!("{}", stripped);
is_message = false;
} else if is_endpoint {
let mut p = post_url.write().await;
*p = format!("{}{}", target_url, stripped);
is_endpoint = false;
}
}
line.clear();
}
Err(_) => break,
}
} }
} }
line.clear();
} }
*post_url.write().await = String::new(); *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(_) => { Err(_) => {
if let Some(ref cmd) = wake_cmd { if let Some(ref cmd) = wake_cmd {
@@ -128,3 +153,5 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(()) Ok(())
}) })
} }
+12
View File
@@ -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"
+430
View File
@@ -0,0 +1,430 @@
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().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-win-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"]
}
}
]
})),
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;
}
}
}
_ => {
send_error(id, -32601, "Tool not found").await;
}
}
}
_ => {
// Ignore other methods
}
}
});
}
}
async fn get_socket_path() -> Result<String, String> {
// 1. Try auto-discovery in \\.\pipe\
if let Ok(dir) = std::fs::read_dir(r"\\.\pipe\") {
for entry in dir.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("nvim.") {
return Ok(format!(r"\\.\pipe\{}", name_str));
}
}
}
// 2. Fallback to active_nvim.txt
let profile = std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\reazul.ashraf".into());
let path = format!("{}\\.gemini\\active_nvim.txt", profile);
if let Ok(content) = std::fs::read_to_string(&path) {
let p = content.trim().to_string();
if !p.is_empty() {
return Ok(p);
}
}
Err("Could not find active Windows Neovim named pipe".to_string())
}
async fn call_nvim(req: rmpv::Value) -> Result<rmpv::Value, String> {
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 client = ClientOptions::new().open(&socket_path).map_err(|e| e.to_string())?;
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 = 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 n = client.read(&mut chunk).await.map_err(|e| e.to_string())?;
if n == 0 {
return Err("Unexpected EOF from Neovim".to_string());
}
resp_buf.extend_from_slice(&chunk[..n]);
}
}
}
}
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<String, String> {
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<String, String> {
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<String, 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(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())
}
+62
View File
@@ -0,0 +1,62 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct JsonRpcRequest {
pub jsonrpc: String,
pub id: Option<Value>,
pub method: String,
pub params: Option<Value>,
}
#[derive(Serialize, Debug, Clone)]
pub struct JsonRpcResponse {
pub jsonrpc: String,
pub id: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<Value>,
}
pub async fn read_message() -> Option<JsonRpcRequest> {
let mut stdin = BufReader::new(tokio::io::stdin());
let mut length = 0;
loop {
let mut line = String::new();
if stdin.read_line(&mut line).await.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];
stdin.read_exact(&mut buffer).await.unwrap_or(0);
serde_json::from_slice(&buffer).ok()
}
pub async fn send_response(response: JsonRpcResponse) {
let msg = serde_json::to_string(&response).unwrap();
let payload = format!("Content-Length: {}\r\n\r\n{}", msg.len(), msg);
let mut stdout = tokio::io::stdout();
let _ = stdout.write_all(payload.as_bytes()).await;
let _ = stdout.flush().await;
}
pub async 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})),
}).await;
}