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"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "2.13.1"
@@ -392,6 +401,20 @@ dependencies = [
"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]]
name = "datasketches"
version = "0.2.0"
@@ -626,6 +649,12 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -981,7 +1010,7 @@ version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
dependencies = [
"hashbrown",
"hashbrown 0.16.1",
]
[[package]]
@@ -1002,18 +1031,33 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "mcp-memory-linux-nvim"
version = "0.1.0"
dependencies = [
"rmp-serde",
"rmpv",
"schemars 0.8.22",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "mcp-memory-server"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"bincode",
"clap",
"dashmap",
"dirs",
"futures-util",
"glob",
"redb",
"reqwest",
"schemars",
"schemars 1.2.2",
"serde",
"serde_json",
"tantivy",
@@ -1036,6 +1080,18 @@ dependencies = [
"tokio-util",
]
[[package]]
name = "mcp-memory-win-nvim"
version = "0.1.0"
dependencies = [
"rmp-serde",
"rmpv",
"schemars 0.8.22",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "measure_time"
version = "0.9.0"
@@ -1357,6 +1413,15 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "redb"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36"
dependencies = [
"libc",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -1481,6 +1546,34 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rmp"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
dependencies = [
"num-traits",
]
[[package]]
name = "rmp-serde"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
dependencies = [
"rmp",
"serde",
]
[[package]]
name = "rmpv"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417"
dependencies = [
"rmp",
]
[[package]]
name = "rust-stemmers"
version = "1.2.0"
@@ -1557,6 +1650,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "schemars"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
dependencies = [
"dyn-clone",
"schemars_derive 0.8.22",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.2"
@@ -1565,11 +1670,23 @@ checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
dependencies = [
"dyn-clone",
"ref-cast",
"schemars_derive",
"schemars_derive 1.2.2",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals 0.29.1",
"syn 2.0.119",
]
[[package]]
name = "schemars_derive"
version = "1.2.2"
@@ -1578,7 +1695,7 @@ checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"serde_derive_internals 0.30.0",
"syn 3.0.5",
]
@@ -1618,6 +1735,17 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "serde_derive_internals"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "serde_derive_internals"
version = "0.30.0"
+3 -1
View File
@@ -1,6 +1,8 @@
[workspace]
members = [
"server",
"stub"
"stub",
"win-nvim",
"linux-nvim"
]
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:
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):
`json
@@ -51,7 +56,7 @@ Update your WSL ~/.gemini/config/mcp_config.json:
"command": "/home/riz/.local/bin/mcp-memory-stub",
"args": [
"--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
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 Building mcp-memory in release mode...
cargo build --release
setlocal
if %ERRORLEVEL% EQU 0 (
echo.
echo Build successful!
echo Executable is located at: target\release\mcp-memory.exe
) else (
echo.
echo Build failed with error code %ERRORLEVEL%.
echo Building mcp-memory (server and stub) for Windows...
rustup run stable cargo build --release --workspace --exclude mcp-memory-linux-nvim
if %ERRORLEVEL% NEQ 0 (
echo Windows build failed!
exit /b %ERRORLEVEL%
)
echo Building mcp-memory (server and stub) for Linux (WSL)...
rustup run stable cargo zigbuild --release --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.
## 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).
* **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.
* **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.
The binary supports a flexible safety gate authorization system, accessible both via the HTTP API and CLI subcommands.
* **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`.
* **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
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)
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.
* **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.
## 13. Cargo Workspace & Binary Artifacts
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).
* **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.
+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.
- When calling **log_code_change**, **log_error_fix**, or **log_tech_debt**, you should execute git rev-parse HEAD and git branch --show-current in the project directory first (if it's a git repo).
- Pass the resulting hash and branch name into the git_commit and git_branch arguments of those tools to permanently link the memory item to the VCS state.
## 13. Server Lifecycle & Updates (Graceful Shutdown)
When instructed to restart, update, or stop the mcp-memory-server binary, never use brute-force OS kill commands (e.g. Stop-Process, pkill, kill).
- Why: The server uses asynchronous checkpointing. Force-killing it bypasses the shutdown flush, risking data loss in the master JSON graph, and can lead to lingering file locks.
- How: Always use the built-in graceful shutdown mechanisms:
1. CLI Flag: mcp-memory-server --exit (or --restart)
2. HTTP Endpoint: POST http://127.0.0.1:3000/shutdown
+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]
async-trait = "0.1.92"
axum = "0.8"
bincode = "1.3.3"
clap = { version = "4.6.6", features = ["derive"] }
dashmap = "6.2.1"
dirs = "6.0.0"
futures-util = "0.3.34"
glob = "0.3.4"
redb = "4.2.0"
reqwest = { version = "0.12", default-features = false, features = ["stream", "rustls-tls"] }
schemars = "1.2.2"
serde = { version = "1.0.229", features = ["derive"] }
+68 -2
View File
@@ -5,13 +5,13 @@
<style>
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; }
.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:hover { transform: translateY(-5px); }
.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; }
.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>
</head>
<body>
@@ -45,6 +45,58 @@
<div class="stat-label">ADRs</div>
<div class="stat-value" id="val-adrs">-</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>
<script>
@@ -59,6 +111,20 @@
document.getElementById('val-snippets').innerText = data.snippets;
document.getElementById('val-tech_debts').innerText = data.tech_debts;
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) {
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 models;
mod mcp;
mod models;
mod search;
mod state;
mod store;
mod tools;
mod search;
use crate::handlers::MemoryHandler;
use crate::models::*;
@@ -15,24 +15,34 @@ use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use redb::ReadableTable;
use tokio::time::sleep;
use clap::{Parser, Subcommand};
use std::collections::HashMap;
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
#[command(author, version, about = "Antigravity MCP Memory Server", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
/// Target URL for the proxy to connect to (e.g., http://127.0.0.1:3000)
#[arg(long)]
target: Option<String>,
/// Run the server as a background daemon process (Windows only)
#[arg(long)]
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)]
enum Commands {
/// Manage authorization gates and verification for actions
Gate {
#[command(subcommand)]
subcmd: GateCommands,
@@ -71,7 +81,6 @@ enum GateCommands {
},
}
async fn reconcile_worker(state: Arc<MemoryState>) {
loop {
sleep(Duration::from_secs(5)).await;
@@ -104,21 +113,18 @@ async fn reconcile_worker(state: Arc<MemoryState>) {
}
}
use axum::{
extract::{State, Query},
response::sse::{Event, Sse},
routing::{get, post},
Json, Router,
extract::{Query, State},
response::sse::{Event, Sse},
response::IntoResponse,
routing::{get, post},
};
use futures_util::stream::Stream;
use std::convert::Infallible;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use std::sync::atomic::{AtomicUsize, Ordering};
struct AppState {
handler: Arc<MemoryHandler>,
@@ -126,6 +132,103 @@ struct AppState {
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>> {
let rt = tokio::runtime::Runtime::new().unwrap();
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("/messages", post(message_handler))
.route("/health", get(health_handler))
.route("/", 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();
.route("/gate/verify", get(gate_verify_handler))
.route("/gate/set", post(gate_set_handler))
.route(
"/shutdown",
post(|| async move {
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_millis(100));
std::process::exit(0);
});
"Shutting down..."
}),
)
.route(
"/",
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!({
"entities": entities,
"relations": relations,
"tasks": tasks,
"snippets": snippets,
"tech_debts": tech_debts,
"adrs": adrs
}))
}
}))
let ledger = state_clone.ledger.read().len();
let sticky = state_clone.sticky.read().len();
let error_fixes = state_clone.error_fixes.read().len();
let pinned_files = state_clone.pinned_files.read().len();
let session_summaries = state_clone.session_summaries.read().len();
let handoff_memos = state_clone.handoff_memos.read().len();
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);
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 {
Ok(l) => break l,
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;
if retries > 15 {
let log_path = dirs::home_dir().unwrap_or_default().join(".gemini/mcp_memory/daemon_fatal.log");
let _ = std::fs::write(&log_path, format!("FATAL: Could not bind to 127.0.0.1:3000 after 15 seconds: {}\n", e));
let log_path = dirs::home_dir()
.unwrap_or_default()
.join(".gemini/mcp_memory/daemon_fatal.log");
let _ = std::fs::write(
&log_path,
format!(
"FATAL: Could not bind to 127.0.0.1:3000 after 15 seconds: {}\n",
e
),
);
std::process::exit(1);
}
let log_path = dirs::home_dir().unwrap_or_default().join(".gemini/mcp_memory/daemon_error.log");
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) {
let log_path = dirs::home_dir()
.unwrap_or_default()
.join(".gemini/mcp_memory/daemon_error.log");
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
use std::io::Write;
let _ = writeln!(file, "Failed to bind to 127.0.0.1:3000 (attempt {}): {}. Retrying in 1s...", retries, e);
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;
}
@@ -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");
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));
}
Ok(())
@@ -201,11 +381,19 @@ async fn sse_handler(
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let session_id = format!("{}", state.next_id.fetch_add(1, Ordering::SeqCst));
let (tx, rx) = mpsc::channel::<Result<Event, Infallible>>(100);
state.clients.write().unwrap().insert(session_id.clone(), tx.clone());
let _ = tx.send(Ok(Event::default().event("endpoint").data(format!("/messages?sessionId={}", session_id)))).await;
state
.clients
.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);
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 session_id = query.session_id.clone();
let clients = Arc::clone(&state);
tokio::spawn(async move {
if let Some(response) = handler.handle_request(payload).await {
let tx_opt = clients.clients.read().unwrap().get(&session_id).cloned();
if let Some(tx) = tx_opt {
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
}
mod proxy;
fn main() -> Result<(), Box<dyn std::error::Error>> {
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")]
{
use std::os::windows::process::CommandExt;
if !cli.daemon {
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!
let target_url = cli.target.as_deref().unwrap_or("http://127.0.0.1:3000");
match proxy::run_proxy(target_url) {
@@ -266,7 +478,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
} else {
// 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")
.stdin(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);
fs::create_dir_all(&base).expect("Failed to create store dir");
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().unwrap()),
ledger: Store::new(base.join("audit_ledger.json")),
sticky: Store::new(base.join("sticky_notes.json")),
tasks: Store::new(base.join("tasks.json")),
snippets: Store::new(base.join("snippets.json")),
adrs: Store::new(base.join("adrs.json")),
prefs: Store::new(base.join("preferences.json")),
error_fixes: Store::new(base.join("error_fixes.json")),
pinned_files: Store::new(base.join("pinned_files.json")),
session_summaries: Store::new(base.join("session_summaries.json")),
handoff_memos: Store::new(base.join("handoff_memos.json")),
env_fingerprints: Store::new(base.join("env_fingerprints.json")),
env_requirements: Store::new(base.join("env_requirements.json")),
milestones: Store::new(base.join("milestones.json")),
environments: Store::new(base.join("environments.json")),
pr_checklists: Store::new(base.join("pr_checklists.json")),
tech_debts: Store::new(base.join("tech_debts.json")),
gates: Store::new(base.join("gates.json")),
context_workspaces: Store::new(base.join("context_workspaces.json")),
});
let redb_path = base.join("mcp_store.redb");
let db = Arc::new(redb::Database::create(&redb_path).unwrap());
// Ensure table exists and migrate old JSON files
{
let write_txn = db.begin_write().unwrap();
{
let mut table = write_txn.open_table(crate::store::STORE_TABLE).unwrap();
let stores = [
("audit_ledger", "audit_ledger.json"),
("sticky_notes", "sticky_notes.json"),
("tasks", "tasks.json"),
("snippets", "snippets.json"),
("adrs", "adrs.json"),
("preferences", "preferences.json"),
("error_fixes", "error_fixes.json"),
("pinned_files", "pinned_files.json"),
("session_summaries", "session_summaries.json"),
("handoff_memos", "handoff_memos.json"),
("env_fingerprints", "env_fingerprints.json"),
("env_requirements", "env_requirements.json"),
("milestones", "milestones.json"),
("environments", "environments.json"),
("pr_checklists", "pr_checklists.json"),
("tech_debts", "tech_debts.json"),
("gates", "gates.json"),
("context_workspaces", "context_workspaces.json"),
];
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);
for (key, file_name) in stores.iter() {
if table.get(*key).unwrap().is_none() {
let json_path = base.join(file_name);
if json_path.exists() {
if let Ok(data) = fs::read(&json_path) {
if serde_json::from_slice::<serde_json::Value>(&data).is_ok() {
table.insert(*key, data.as_slice()).unwrap();
}
}
}
}
}
}
write_txn.commit().unwrap();
}
let state = Arc::new(MemoryState {
master_path: base.join("knowledge_graph_master.json"),
session_graph: RwLock::new(KnowledgeGraph::default()),
base_dir: base.clone(),
master_cache: RwLock::new((KnowledgeGraph::default(), SystemTime::UNIX_EPOCH)),
search_index: RwLock::new(crate::search::MemoryIndex::new(&base).unwrap()),
ledger: Store::new("audit_ledger", db.clone()),
sticky: Store::new("sticky_notes", db.clone()),
tasks: Store::new("tasks", db.clone()),
snippets: Store::new("snippets", db.clone()),
adrs: Store::new("adrs", db.clone()),
prefs: Store::new("preferences", db.clone()),
error_fixes: Store::new("error_fixes", db.clone()),
pinned_files: Store::new("pinned_files", db.clone()),
session_summaries: Store::new("session_summaries", db.clone()),
handoff_memos: Store::new("handoff_memos", db.clone()),
env_fingerprints: Store::new("env_fingerprints", db.clone()),
env_requirements: Store::new("env_requirements", db.clone()),
milestones: Store::new("milestones", db.clone()),
environments: Store::new("environments", db.clone()),
pr_checklists: Store::new("pr_checklists", db.clone()),
tech_debts: Store::new("tech_debts", db.clone()),
gates: Store::new("gates", db.clone()),
context_workspaces: Store::new("context_workspaces", db.clone()),
});
state.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)
+1 -2
View File
@@ -165,8 +165,7 @@ pub struct ContextWorkspace {
pub saved_at: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Default)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GateRecord {
pub id: 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 std::sync::Arc;
use tokio::io::AsyncBufReadExt;
use tokio::sync::RwLock;
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>> {
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 buffer = String::new();
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());
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 post_url = Arc::new(RwLock::new(String::new()));
let post_url_clone = Arc::clone(&post_url);
let client = reqwest::Client::builder().build().unwrap();
tokio::spawn(async move {
@@ -34,14 +36,16 @@ pub fn run_proxy(target_url: &str) -> Result<bool, Box<dyn std::error::Error + S
loop {
let url = post_url_clone.read().await.clone();
if !url.is_empty() {
let res = client.post(&url)
let res = client
.post(&url)
.header("Accept", "application/json, text/event-stream")
.header("Content-Type", "application/json")
.body(msg.clone())
.send().await;
.send()
.await;
if res.is_ok() {
break;
break;
}
}
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() {
return Ok(false);
}
if shutdown_rx.try_recv().is_ok() {
return Ok(false);
}
let sse_url = format!("{}/sse", target_url);
let client = reqwest::Client::builder().build().unwrap();
match client.get(&sse_url).header("Accept", "text/event-stream").send().await {
Ok(resp) => {
if resp.status() == reqwest::StatusCode::GONE {
return Ok(true);
}
let stream = resp.bytes_stream().map(|res| res.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));
let mut reader = tokio::io::BufReader::new(StreamReader::new(stream));
let mut line = String::new();
let mut is_message = false;
let mut is_endpoint = false;
while let Ok(bytes) = reader.read_line(&mut line).await {
if bytes == 0 { break; }
let trimmed = line.trim();
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 trimmed.starts_with("data: ") {
if is_message {
println!("{}", &trimmed[6..]);
is_message = false;
} else if is_endpoint {
let ep = &trimmed[6..];
let mut p = post_url.write().await;
*p = format!("{}{}", target_url, ep);
is_endpoint = false;
let sse_url = format!("{}/sse", target_url);
let client = reqwest::Client::builder().build().unwrap();
match client
.get(&sse_url)
.header("Accept", "text/event-stream")
.send()
.await
{
Ok(resp) => {
if resp.status() == reqwest::StatusCode::GONE {
return Ok(true);
}
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 line = String::new();
let mut is_message = false;
let mut is_endpoint = false;
loop {
tokio::select! {
_ = shutdown_rx.recv() => {
return Ok(false);
}
res = reader.read_line(&mut line) => {
match res {
Ok(bytes) => {
if bytes == 0 { break; }
let trimmed = line.trim();
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 tantivy::{doc, Index, IndexWriter, IndexReader, ReloadPolicy};
use crate::models::{Adr, Entity, Snippet, Task};
use std::sync::Mutex;
use crate::models::{Entity, Task, Adr, Snippet};
use tantivy::schema::*;
use tantivy::{Index, IndexReader, IndexWriter, ReloadPolicy, doc};
pub struct MemoryIndex {
index: Index,
reader: IndexReader,
writer: Mutex<IndexWriter>,
// Schema fields
pub id_field: Field,
pub title_field: Field,
@@ -17,7 +17,7 @@ pub struct 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 id_field = schema_builder.add_text_field("id", STRING | 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 namespace_field = schema_builder.add_text_field("namespace", STRING | STORED);
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 reader = index
.reader_builder()
.reload_policy(ReloadPolicy::OnCommitWithDelay)
.try_into()?;
Ok(Self {
index,
reader,
@@ -71,23 +74,43 @@ impl MemoryIndex {
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 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 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();
for (_score, doc_address) in top_docs {
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 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 {
if doc_ns != ns && doc_ns != "global" {
let id = retrieved_doc
.get_first(self.id_field)
.and_then(|v| v.as_str())
.unwrap_or("")
.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;
}
}
results.push((id, doc_type));
}
Ok(results)
+42 -11
View File
@@ -1,6 +1,6 @@
use crate::models::*;
use crate::store::{Store, read_json_file, write_json_atomic};
use crate::search::MemoryIndex;
use crate::store::Store;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::PathBuf;
@@ -76,7 +76,21 @@ impl MemoryState {
let mut lock = self.master_cache.write().unwrap();
let new_mtime = self.master_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.0.clone()
@@ -100,12 +114,15 @@ impl MemoryState {
let mut session_graph = self.session_graph.write().unwrap();
update_fn(&mut session_graph);
let wal_path = self.base_dir.join("wal.jsonl");
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) {
if let Ok(payload) = serde_json::to_string(&*session_graph)
&& let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&wal_path)
{
use std::io::Write;
let _ = writeln!(file, "{}", payload);
}
}
}
pub fn apply_sync_write<F: FnOnce(&mut KnowledgeGraph)>(&self, update_fn: F) {
let lock_path = self.base_dir.join("master.lock");
@@ -130,7 +147,18 @@ impl MemoryState {
let _ = fs::remove_file(&wal_path);
*self.session_graph.write().unwrap() = KnowledgeGraph::default();
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();
cache_lock.0 = master;
@@ -140,9 +168,14 @@ impl MemoryState {
}
pub fn rebuild_index(&self) {
if let Ok(new_idx) = MemoryIndex::new() {
let full = self.get_full_graph();
for (_, e) in &full.entities {
if let Ok(new_idx) = MemoryIndex::new(&self.base_dir) {
let session = self.session_graph.read().unwrap();
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);
}
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 std::fs;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use std::time::SystemTime;
use redb::{Database, ReadableDatabase, TableDefinition};
use serde::{Serialize, de::DeserializeOwned};
use std::sync::{Arc, RwLock};
pub fn read_json_file<T: DeserializeOwned + Default>(path: &Path) -> T {
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 const STORE_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("store");
pub struct Store<T> {
pub path: PathBuf,
pub cache: RwLock<(T, SystemTime)>,
pub key: String,
pub db: Arc<Database>,
pub cache: RwLock<T>,
}
impl<T: DeserializeOwned + Default + Serialize + Clone> Store<T> {
fn mtime(&self) -> SystemTime {
fs::metadata(&self.path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH)
pub fn new(key: &str, db: Arc<Database>) -> Self {
let initial_data = Self::load_from_db(key, &db);
Self {
key: key.to_string(),
db,
cache: RwLock::new(initial_data),
}
}
pub fn new(path: PathBuf) -> Self {
let mtime = fs::metadata(&path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
let initial_data = read_json_file(&path);
Self {
path,
cache: RwLock::new((initial_data, mtime)),
fn load_from_db(key: &str, db: &Database) -> T {
let read_txn = db.begin_read().unwrap();
if let Ok(table) = read_txn.open_table(STORE_TABLE) {
if let Ok(Some(value)) = table.get(key) {
if let Ok(parsed) = serde_json::from_slice::<T>(value.value()) {
return parsed;
}
}
}
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 {
let current_mtime = self.mtime();
{
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()
let lock = self.cache.read().unwrap();
lock.clone()
}
pub fn modify<F: FnOnce(&mut T)>(&self, f: F) {
let mut lock = self.cache.write().unwrap();
let current_mtime = self.mtime();
if lock.1 != current_mtime {
lock.0 = read_json_file(&self.path);
}
f(&mut lock.0);
let _ = write_json_atomic(&self.path, &lock.0);
lock.1 = self.mtime();
f(&mut lock);
Self::save_to_db(&self.key, &self.db, &lock);
}
}
+3 -1
View File
@@ -78,7 +78,9 @@ pub struct UpdateTaskStatusTool {
pub status: String,
}
#[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)]
pub struct StoreSnippetTool {
pub name: String,
+81 -54
View File
@@ -3,14 +3,16 @@ use futures_util::StreamExt;
use std::sync::Arc;
use tokio::io::AsyncBufReadExt;
use tokio::sync::RwLock;
use tokio_util::io::StreamReader;
use tokio::sync::mpsc;
use tokio_util::io::StreamReader;
#[derive(Parser)]
#[command(name = "mcp-memory-stub")]
#[command(name = "mcp-memory-stub", author, version, about = "Antigravity MCP Memory Stub / Proxy", long_about = None)]
struct Cli {
/// Target URL for the stub to proxy messages to
#[arg(long, default_value = "http://localhost:3000")]
target: String,
/// Optional command to execute if the target server is unreachable
#[arg(long)]
wake_cmd: Option<String>,
}
@@ -22,46 +24,51 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let (msg_tx, mut msg_rx) = mpsc::channel::<String>(100);
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
tokio::task::spawn_blocking(move || {
let stdin = std::io::stdin();
let mut handle = stdin.lock();
tokio::spawn(async move {
let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
let mut buffer = String::new();
while let Ok(bytes) = std::io::BufRead::read_line(&mut handle, &mut buffer) {
if bytes == 0 { break; }
let _ = msg_tx.blocking_send(buffer.clone());
while let Ok(bytes) = stdin.read_line(&mut buffer).await {
if bytes == 0 {
break;
}
let _ = msg_tx.send(buffer.clone()).await;
buffer.clear();
}
let _ = shutdown_tx.blocking_send(());
let _ = shutdown_tx.send(()).await;
});
let target_url = cli.target;
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()?;
tokio::spawn(async move {
while let Some(msg) = msg_rx.recv().await {
let mut attempts = 0;
loop {
let url = post_url_clone.read().await.clone();
if !url.is_empty() {
let res = client.post(&url)
.header("Accept", "application/json, text/event-stream")
.header("Content-Type", "application/json")
.body(msg.clone())
.send().await;
if res.is_ok() {
break;
let post_url_clone = Arc::clone(&post_url_proxy);
let client = client.clone();
tokio::spawn(async move {
let mut attempts = 0;
loop {
let url = post_url_clone.read().await.clone();
if !url.is_empty() {
let res = client
.post(&url)
.header("Accept", "application/json, text/event-stream")
.header("Content-Type", "application/json")
.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 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) => {
if resp.status() == reqwest::StatusCode::GONE {
eprintln!("[PROXY] Target gone, exiting.");
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 line = String::new();
let mut is_message = false;
let mut is_endpoint = false;
while let Ok(bytes) = reader.read_line(&mut line).await {
if bytes == 0 { break; }
let trimmed = line.trim();
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 trimmed.starts_with("data: ") {
if is_message {
println!("{}", &trimmed[6..]);
is_message = false;
} else if is_endpoint {
let ep = &trimmed[6..];
let mut p = post_url.write().await;
*p = format!("{}{}", target_url, ep);
is_endpoint = false;
loop {
tokio::select! {
_ = shutdown_rx.recv() => {
return Ok(()); // Stdin closed, exit entirely
}
res = reader.read_line(&mut line) => {
match res {
Ok(bytes) => {
if bytes == 0 { break; }
let trimmed = line.trim();
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();
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
}
Err(_) => {
if let Some(ref cmd) = wake_cmd {
@@ -128,3 +153,5 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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;
}