Compare commits

..
27 Commits
Author SHA1 Message Date
Riz Ashraf 0e29b12ac8 refactor: deduplicate arg parsing in handlers and remove redundant python scripts
Created a parse_tool! macro that eliminated ~450 lines of repetitive error-handling boilerplate across 54 MCP tool definitions in server/src/handlers.rs.

Purged 30+ stale, redundant python patching scripts and legacy mcp_client.py test runners that were polluting the workspace root and server/src directories.
2026-09-14 04:57:31 +01:00
Riz Ashraf ee4157d07b docs: complete final doc sweep for Dashboard UI REST APIs and query_graph_path 2026-09-14 04:27:00 +01:00
Riz Ashraf 04a9a4963c docs: document git2 automated context binding in design and instructions 2026-09-14 04:17:33 +01:00
Riz Ashraf c88d312c8c docs: align design docs with redb, tantivy, and native rust e2e testing architectures 2026-09-14 04:13:51 +01:00
Riz Ashraf 1da413568e docs: update architecture and design docs with proxy reconnect and deployment strategies 2026-09-14 04:07:52 +01:00
Riz Ashraf a9a861abe1 test: Revert broken env! macro for bin resolution 2026-09-13 19:02:46 +01:00
Riz Ashraf a87e37cb53 test: Fix test binary resolution for llvm-cov 2026-09-13 19:01:06 +01:00
Riz Ashraf cf5b40ceb6 chore: Ignore coverage binaries 2026-09-13 18:58:19 +01:00
Riz Ashraf e46099826e UI: Remove raw html tags from graph tooltip 2026-09-13 18:58:14 +01:00
Riz Ashraf 2bdafa6cc5 UI: Add Sticky Notes tab and api 2026-09-13 18:47:02 +01:00
Riz Ashraf 34cf8fc8ca UI: Add tabs to dashboard and fix mcp capability regression 2026-09-13 18:43:29 +01:00
Riz Ashraf d71b1e6e35 feat: Replace python tests with native E2E test suite and fix capabilities schema
- Added native E2E test suite in stub to validate server<->stub<->nvim IPC
- Added win-nvim integration tests
- Fixed empty capabilities schema in initialize response in server and win-nvim
- Refactored server to accept dynamic MCP_PORT for test isolation
- Made server exit gracefully on port 3000 collision
2026-09-13 18:03:27 +01:00
Riz Ashraf f2582b58be Fix MCP hangs and add logging 2026-09-13 11:10:35 +01:00
Riz Ashraf 75748f0e21 docs: Document Last Focused Wins telemetry architecture for Neovim 2026-09-12 23:18:11 +01:00
Riz Ashraf 86fab8abb1 feat: Merge V2 WebSocket telemetry push into existing gemini-integration.lua 2026-09-12 23:15:04 +01:00
Riz Ashraf 4d69f5e771 feat: Add telemetry endpoint for tracking active Neovim session via FocusGained 2026-09-12 23:08:56 +01:00
Riz Ashraf 491db39447 chore: Remove unused Value import in nvim plugins 2026-09-12 22:27:05 +01:00
Riz Ashraf 8c25c770aa docs: Document Dual Transport Architecture (SSE and WS) in instructions 2026-09-12 22:25:20 +01:00
Riz Ashraf 6872957195 docs: Prune redundant JSON schemas from instructions in favor of strong static typing 2026-09-12 22:22:09 +01:00
Riz Ashraf 2d3aaed289 refactor: Enforce strict typed JsonSchema for all MCP tools 2026-09-12 22:20:01 +01:00
Riz Ashraf 686fea683d perf: completely remove disk I/O from memory access paths, optimize Tantivy indexing, and fix MemoryIndex commits 2026-09-12 20:41:30 +01:00
Riz Ashraf a5011048b1 refactor: extract inline loops, optimize GC scheduling, and remove redundancies 2026-09-12 20:31:05 +01:00
Riz Ashraf 0723811686 refactor: resolve CLI redundancy and overlapping transport state bounds 2026-09-12 20:27:13 +01:00
Riz Ashraf 04b9388074 feat: restore dual transport (websockets and sse) 2026-09-12 20:22:51 +01:00
Riz Ashraf 8d34634506 docs: define dual websocket and sse transport support 2026-09-12 20:14:41 +01:00
Riz Ashraf 6dde1cc3a3 refactor: physically decouple server and stub architectures 2026-09-12 20:11:46 +01:00
Riz Ashraf 0f4be375c2 docs: formalize strict server/client separation in architecture 2026-09-12 20:07:51 +01:00
47 changed files with 5655 additions and 2269 deletions

No files matched your search

+2
View File
@@ -1 +1,3 @@
/target
cargo-llvm-cov.exe
llvm-cov.zip
Generated
+591 -26
View File
File diff suppressed because it is too large. Load diff
+35 -17
View File
@@ -6,7 +6,8 @@ mcp-memory acts as the persistent "brain" for the agy CLI agents. It tracks enti
`
To eliminate heavy Cross-OS I/O penalties when using WSL and Windows simultaneously, mcp-memory operates using a **Dual-Transport Leader/Stub Architecture**:
* **The Server (mcp-memory-server)**: Runs natively on the Windows host. It binds to .0.0.0:3000, serving standard stdio to the primary Windows agy instance while simultaneously hosting an Axum HTTP server for secondary clients.
* **The Stub (mcp-memory-stub)**: An ultra-lightweight proxy binary. WSL agy instances run this native Linux stub, which transparently pipes stdio JSON-RPC traffic over the network to the Windows HTTP server (http://127.0.0.1:3000), completely bypassing WSL NTFS mounts.
* **The Stub (mcp-memory-stub)**: An ultra-lightweight proxy binary. WSL agy instances run this native Linux stub, which transparently pipes stdio JSON-RPC traffic over the network to the Windows HTTP server (http://127.0.0.1:3000), completely bypassing WSL NTFS mounts. It features full MPSC queue buffering and a WebSocket reconnect handshake (
otifications/tools/list_changed) so that tools automatically refresh seamlessly without disconnecting the CLI if the background server restarts.
`
## Quick Start & Usage
`
@@ -24,10 +25,12 @@ Copy-Item target\release\mcp-memory-stub.exe C:\Users\reazul.ashraf\.local\bin\m
if (-not (Get-Process mcp-memory-server -ErrorAction SilentlyContinue)) { Start-Process -FilePath "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" -ArgumentList "--daemon" -WindowStyle Hidden -ErrorAction SilentlyContinue }
```
**Shutting Down:** If you need to stop the background daemon (e.g., to replace the executable after a recompile), use the built-in `--exit` flag instead of forcefully killing the process.
**Shutting Down:** If you need to stop or restart the background daemon (e.g., to replace the executable after a recompile), you MUST NEVER use brute-force OS kill commands (e.g., Stop-Process, pkill, or kill). Instead, you MUST ALWAYS use the server's built-in graceful shutdown mechanisms:
1. The CLI flag: `--exit` (or `--restart`)
```powershell
mcp-memory-server.exe --exit
```
2. The HTTP endpoint: `POST http://127.0.0.1:3000/shutdown`
`
**Step 2:** Update your Windows ~/.gemini/config/mcp_config.json to point the CLI to the ultra-lightweight stub (since the server is already running in the background):
`json
@@ -72,28 +75,43 @@ mcp-memory gate verify
This queries the daemon (via HTTP) to confirm if pre-push validation (like running tests via PrePushAuditor) has been cleared by the agent.
`
## Brain Monitor Dashboard
The server hosts a live, real-time HTML dashboard called the **Brain Monitor**. It visually tracks the metrics of your Antigravity knowledge graph, such as the total count of Entities, Relations, Tasks, Snippets, and Architectural Decisions (ADRs).
The server hosts a live, real-time SPA dashboard called the **Brain Monitor**.
To view the dashboard, simply navigate to the root endpoint in your browser while the server is running:
`http://127.0.0.1:3000/`
You can also programmatically query these live metrics via the API endpoint:
### Dashboard Features:
* **Interactive Knowledge Graph:** A full physics-simulated network graph with node-type coloring, a drag-to-pan canvas, and an interactive Inspector Panel. Click any node to instantly view its stored observations.
* **Actionable Kanban Board:** Visually track all active agent Tasks. You can click 'Complete' directly from the UI to trigger a `POST /api/tasks/{id}/complete` REST call back to the daemon without needing the CLI.
* **Sticky Notes & Search:** Browse your ephemeral notes and utilize the integrated fuzzy-search bar to locate graph entities instantly.
* **Native Dark Mode:** Fully styled for modern development environments.
You can also programmatically query the live backing APIs:
`curl http://127.0.0.1:3000/api/stats`
`curl http://127.0.0.1:3000/api/graph`
`curl http://127.0.0.1:3000/api/tasks`
## 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, the **Redb LSM-tree** embedded database, `tantivy` indexing, 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.
The linux-nvim and win-nvim MCP servers provide direct Msgpack-RPC communication with Neovim.
For this to work flawlessly across multiple Neovim instances (even split across Windows and WSL), you must load the provided gemini-integration.lua file in your Neovim init.lua:
`lua
dofile("C:/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-integration.lua")
`
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.
### The "Last Focused Wins" Architecture
When you use the gemini-integration.lua script, Neovim acts as an active telemetry broadcaster.
Whenever you alt-tab into a Neovim window (FocusGained) or switch files (BufEnter):
1. **Fallback Sync:** Neovim instantly writes its unique Session ID (Named Pipe / Unix Socket) to ~/.gemini/active_nvim.txt.
2. **WebSocket Telemetry:** Neovim pushes a JSON payload containing the active filename, cursor row, and column to the Rust server's /nvim/telemetry webhook.
3. **UI Broadcast:** The Rust server updates the global state and broadcasts this over WebSockets (/ws) so that the Brain Monitor Dashboard can animate your active file live in the UI!
### Neovim MCP Tools
The LLM agent interacts with your active Neovim session using a dedicated set of MCP tools. *(Note: /nvim/telemetry is strictly a one-way webhook for Neovim; the LLM uses the tools below to interact).*
* **vim_goto_line**: Open files and jump cursors directly from the LLM.
* **vim_set_diagnostics**: Push inline code review warnings as virtual text.
* **vim_get_active_buffer**: Read live, unsaved buffer contents.
* **vim_get_cursor**: Fetch precise line/column coordinates.
* **vim_get_visual_selection**: Read highlighted code blocks.
-47
View File
@@ -1,47 +0,0 @@
@echo off
setlocal
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
+100
View File
@@ -0,0 +1,100 @@
$ErrorActionPreference = 'Stop'
Write-Host "Building mcp-memory (server and stub) for Windows..." -ForegroundColor Cyan
& rustup run stable cargo build --release --workspace --exclude mcp-memory-linux-nvim
if ($LASTEXITCODE -ne 0) {
Write-Error "Windows build failed!"
exit $LASTEXITCODE
}
Write-Host "Building mcp-memory (server and stub) for Linux (WSL)..." -ForegroundColor Cyan
& rustup run stable cargo zigbuild --release --workspace --exclude mcp-memory-win-nvim --target x86_64-unknown-linux-musl
if ($LASTEXITCODE -ne 0) {
Write-Error "Linux build failed!"
exit $LASTEXITCODE
}
Write-Host "`nBuild successful! Gracefully shutting down running servers..." -ForegroundColor Green
$serverExe = "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe"
if (Test-Path $serverExe) {
& $serverExe --exit 2>$null
}
try {
Invoke-RestMethod -Uri "http://127.0.0.1:3000/shutdown" -Method Post -ErrorAction SilentlyContinue | Out-Null
} catch {}
Start-Sleep -Seconds 2
function Get-ExeVersion {
param([string]$Path, [string]$EnvName)
if ($EnvName -eq 'WSL') {
$checkCmd = "if [ -x ""$Path"" ]; then $Path --version; else echo 'Not Found'; fi"
$output = wsl.exe -d Ubuntu -e bash -c $checkCmd
} else {
if (Test-Path $Path) {
$output = & $Path --version 2>&1
} else {
$output = 'Not Found'
}
}
if ([string]::IsNullOrWhiteSpace($output)) { return "No Output / Errored" }
return $output.Trim()
}
function Deploy-If-Needed {
param([string]$Source, [string]$Dest, [string]$EnvName)
$sourceVerPath = $Source
if ($EnvName -eq 'WSL') {
$sourceVerPath = wsl.exe -d Ubuntu -e wslpath -u $Source
}
$sourceVer = Get-ExeVersion -Path $sourceVerPath -EnvName $EnvName
$destVer = Get-ExeVersion -Path $Dest -EnvName $EnvName
if ($sourceVer -eq $destVer -and $sourceVer -ne "Not Found") {
Write-Host " Skip: $($Dest) is already up-to-date ($sourceVer)" -ForegroundColor DarkGray
return
}
Write-Host " Update: $($Dest) ($destVer -> $sourceVer)" -ForegroundColor Yellow
if ($EnvName -eq 'WSL') {
# Translate source path to WSL path
$wslSource = wsl.exe -d Ubuntu -e wslpath -u $Source
wsl.exe -d Ubuntu -e bash -c "mv $Dest ${Dest}.`date +%s`.old 2>/dev/null || true; cp $wslSource $Dest && chmod +x $Dest"
} else {
if (Test-Path $Dest) {
$rand = Get-Random
Move-Item -Path $Dest -Destination "${Dest}.${rand}.old" -Force -ErrorAction SilentlyContinue
}
Copy-Item -Path $Source -Destination $Dest -Force
}
$newDestVer = Get-ExeVersion -Path $Dest -EnvName $EnvName
if ($newDestVer -eq $sourceVer) {
Write-Host " Success: $($Dest) verified as $newDestVer" -ForegroundColor Green
} else {
Write-Error " Failed: $($Dest) version mismatch after copy! Expected $sourceVer, got $newDestVer"
}
}
Write-Host "`nDeploying and verifying binaries..." -ForegroundColor Cyan
$winBase = "C:\Users\reazul.ashraf\.local\bin"
$wslBase = "/home/riz/.local/bin"
$winTarget = "target\release"
$wslTarget = "target\x86_64-unknown-linux-musl\release"
Deploy-If-Needed -Source "$winTarget\mcp-memory-server.exe" -Dest "$winBase\mcp-memory-server.exe" -EnvName "Win"
Deploy-If-Needed -Source "$winTarget\mcp-memory-stub.exe" -Dest "$winBase\mcp-memory-stub.exe" -EnvName "Win"
Deploy-If-Needed -Source "$winTarget\mcp-memory-win-nvim.exe" -Dest "$winBase\mcp-memory-win-nvim.exe" -EnvName "Win"
# Note: Source is local windows path, but Deploy-If-Needed translates it to wslpath
Deploy-If-Needed -Source "$wslTarget\mcp-memory-server" -Dest "$wslBase/mcp-memory-server" -EnvName "WSL"
Deploy-If-Needed -Source "$wslTarget\mcp-memory-stub" -Dest "$wslBase/mcp-memory-stub" -EnvName "WSL"
Deploy-If-Needed -Source "$wslTarget\mcp-memory-linux-nvim" -Dest "$wslBase/mcp-memory-linux-nvim" -EnvName "WSL"
Write-Host "`nCopying instructions.md..." -ForegroundColor Cyan
Copy-Item instructions.md "C:\Users\reazul.ashraf\.gemini\antigravity-cli\mcp\memory\instructions.md" -Force
wsl.exe -d Ubuntu -e bash -c "mkdir -p /home/riz/.gemini/antigravity-cli/mcp/memory && cp $(wsl.exe -d Ubuntu -e wslpath -u instructions.md) /home/riz/.gemini/antigravity-cli/mcp/memory/instructions.md"
Write-Host "`nBuild and deployment complete!" -ForegroundColor Green
+20
View File
@@ -0,0 +1,20 @@
use std::process::Command;
fn main() {
let git_hash = Command::new("git")
.args(&["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string());
let git_date = Command::new("git")
.args(&["log", "-1", "--format=%cd", "--date=format:%Y.%m.%d"])
.output()
.ok()
.and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string());
let version = format!("{} ({} {})", env!("CARGO_PKG_VERSION"), git_date.trim(), git_hash.trim());
println!("cargo:rustc-env=APP_VERSION={}", version);
}
+56
View File
@@ -0,0 +1,56 @@
$ErrorActionPreference = 'Stop'
function Get-ExeVersion {
param(
[string]$Path,
[string]$EnvName
)
if ($EnvName -eq 'WSL') {
$checkCmd = "if [ -x ""$Path"" ]; then $Path --version; else echo 'Not Found'; fi"
$output = wsl.exe -d Ubuntu -e bash -c $checkCmd
} else {
if (Test-Path $Path) {
$output = & $Path --version 2>&1
} else {
$output = 'Not Found'
}
}
if ([string]::IsNullOrWhiteSpace($output)) {
return "No Output / Errored"
}
return $output.Trim()
}
Write-Host "=======================================" -ForegroundColor Cyan
Write-Host " MCP Memory Executable Version Checker " -ForegroundColor Cyan
Write-Host "=======================================" -ForegroundColor Cyan
Write-Host ""
$winBase = "C:\Users\reazul.ashraf\.local\bin"
$wslBase = "/home/riz/.local/bin"
$components = @(
@{ Name = "Server (Win)"; Path = "$winBase\mcp-memory-server.exe"; Env = "Win" },
@{ Name = "Stub (Win)"; Path = "$winBase\mcp-memory-stub.exe"; Env = "Win" },
@{ Name = "Win-Nvim"; Path = "$winBase\mcp-memory-win-nvim.exe"; Env = "Win" },
@{ Name = "Server (WSL)"; Path = "$wslBase/mcp-memory-server"; Env = "WSL" },
@{ Name = "Stub (WSL)"; Path = "$wslBase/mcp-memory-stub"; Env = "WSL" },
@{ Name = "Linux-Nvim"; Path = "$wslBase/mcp-memory-linux-nvim"; Env = "WSL" }
)
foreach ($comp in $components) {
Write-Host ("Checking {0,-15} -> " -f $comp.Name) -NoNewline
$ver = Get-ExeVersion -Path $comp.Path -EnvName $comp.Env
if ($ver -eq "Not Found") {
Write-Host $ver -ForegroundColor Red
} elseif ($ver -eq "No Output / Errored") {
Write-Host $ver -ForegroundColor Yellow
} else {
Write-Host $ver -ForegroundColor Green
}
}
Write-Host ""
Write-Host "Done!" -ForegroundColor Cyan
+4
View File
@@ -0,0 +1,4 @@
Get-Process mcp-memory-* -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 2
foreach ($b in @('mcp-memory-server.exe', 'mcp-memory-stub.exe', 'mcp-memory-win-nvim.exe')) { $s = 'target\release\' + $b; $d = 'C:\Users\reazul.ashraf\.local\bin\' + $b; if (Test-Path $d) { Remove-Item ($d + '.bak') -Force -ErrorAction SilentlyContinue; Rename-Item $d ($b + '.bak') -Force -ErrorAction SilentlyContinue }; Copy-Item $s $d -Force }
& 'C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe' --daemon
+73 -25
View File
@@ -12,13 +12,21 @@ The `mcp-memory` system runs as a **single, continuously running background daem
The daemon uses the `axum` and `rust-mcp-axum` crates, binding to `0.0.0.0:3000` to serve the network.
### Standard MCP Endpoints
* `GET /sse`: The Server-Sent Events (SSE) endpoint. Antigravity clients connect here to keep a one-way pipe open for receiving pushed responses and event notifications from the server.
* `POST /messages`: The JSON-RPC endpoint. Clients use this to send tool calls and resource reads up to the server.
* `GET /ws`: The WebSocket endpoint for efficient, low-latency, full-duplex JSON-RPC communication (preferred for proxies and the UI dashboard).
* `GET /sse`: The Server-Sent Events (SSE) endpoint. Antigravity clients connect here to keep a one-way pipe open for receiving pushed responses.
* `POST /messages`: The JSON-RPC endpoint. Clients use this to send tool calls and resource reads up to the server when connected via SSE.
### Custom Integration Endpoints
* `GET /`: The Brain Monitor live HTML dashboard (returns real-time metrics UI).
### Dashboard REST API
The server hosts a rich Single Page Application (SPA) natively on the root route, backed by a suite of REST endpoints:
* `GET /`: The Brain Monitor live HTML dashboard SPA.
* `GET /api/graph`: Returns the full node/edge topology for the interactive physics-simulated canvas.
* `GET /api/tasks` & `POST /api/tasks/{id}/complete`: Drives the actionable Kanban board.
* `GET /api/sticky` & `GET /api/search`: Powers the sticky notes tab and global fuzzy search UI.
* `GET /api/stats`: Returns a JSON snapshot of current entity, relation, task, and tech debt counts.
* `GET /gate/verify`: A lightweight, deterministic endpoint used by external scripts to verify if an action (like a `git push`) is authorized based on the current state.
### Git & IDE Integration Endpoints
* `POST /nvim/telemetry`: A one-way webhook that Neovim instances hit on `FocusGained` or `BufEnter` to globally broadcast the user's active file to the UI and Agent.
* `GET /gate/verify`: A lightweight, deterministic endpoint used by external scripts (like a `git push` wrapper) to verify if an action is authorized based on the current state.
## 3. Client Connections
The Antigravity configurations (`mcp_config.json`) utilize the dual-transport system:
@@ -39,28 +47,27 @@ Instead of relying on localized per-repository Git hooks (like `.git/hooks/pre-p
* **Universal Enforcement:** The push safety gate is protected across all repositories automatically, without copying hook scripts.
* **No File Locks:** Uses safe, lightweight, parallelizable HTTP requests rather than executing the Rust binary directly.
* **Action Logging:** The wrapper can be seamlessly extended to log actions (like `checkout` or `commit`) directly into the knowledge graph in real-time.
## 5. Storage Architecture & Persistence (WAL)
The daemon utilizes a Write-Ahead Logging (WAL) architecture for robust, high-performance state management, replacing fragmented delta-file reconciliation and massive synchronous JSON rewrites.
## 5. Storage Architecture & Persistence (Redb LSM-Tree)
The daemon has completely eliminated raw JSON file sprawl and fragmented delta-file reconciliation. It now utilizes a pure-Rust, embedded Key-Value engine (`redb`) that implements a robust Log-Structured Merge-Tree (LSM-tree) architecture.
### Key Principles:
* **Append-Only Log:** Every state change (e.g., adding an entity, updating a task) is instantly appended to a sequential, append-only log file (wal.log) *before* in-memory state is altered.
* **Crash Resilience:** Eliminates data corruption. If the daemon is forcefully terminated, the system guarantees zero data loss by replaying the WAL against the last known valid checkpoint upon restart.
* **Asynchronous Checkpointing:** Master JSON store files are no longer rewritten on every tool call. Checkpointing (flushing memory to the master JSON files) is pushed to a background thread to run periodically or upon graceful shutdown, drastically reducing disk I/O.
* **Embedded Database Engine:** All structured components (Tasks, Snippets, Tech Debt, Checklists, etc.) are stored as binary-encoded values inside a unified `redb` database file (`store.redb`).
* **ACID Compliance & File Locks:** The Windows daemon holds an exclusive read-write lock on the database file, guaranteeing zero data corruption, race conditions, or lock contention during concurrent access.
* **Asynchronous Checkpointing:** The core Knowledge Graph (Entities, Relations, Observations) still utilizes a Write-Ahead Logging (WAL) pattern (`wal.jsonl`) and a master snapshot (`master.json`) to allow safe, lock-free memory mutations which are reconciled in the background.
## 6. Domain Models & Component Stores
While the core architecture relies on a unified Knowledge Graph (Entities, Relations, Observations), the daemon leverages a modular, thread-safe generic Store<T> pattern for specialized domains.
The system leverages a modular, thread-safe generic `Store<T>` abstraction that automatically transparently serializes and deserializes native Rust structs directly into the underlying `redb` tables.
Currently implemented persistent stores include:
* **Audit Ledger & Tasks:** Tracks agent actions and active background tasks.
* **Context & Handoffs:** Sticky notes, Session Summaries, Handoff Memos, and Context Workspaces.
* **Engineering Tracking:** ADRs (Architecture Decision Records), Snippets, Error Fixes, Tech Debt, and PR Checklists.
* **Environment State:** Pinned Files, Env Fingerprints, Milestones, and Environments.
* **Safety Gates:** Authorized execution gates (Push Safety).
## 7. Background Maintenance (Reconcile Worker)
The daemon runs a continuous asynchronous background task (
econcile_worker) on a 5-second polling loop responsible for:
* **State Reconciliation:** Reading the append-only wal.jsonl file, squashing the mutations into the master knowledge graph, and cleanly truncating the WAL.
* **Ledger Pruning:** Automatically truncating the audit_ledger.json to keep only the last 7 days of activity, with a hard cap of 1,000 records to prevent infinite bloat.
* **Ephemeral Data Cleanup:** Automatically expiring and purging sticky_notes.json that are older than 24 hours.
## 7. Full-Text Search Engine (Tantivy)
To support blazing-fast, intelligent semantic retrieval across the sprawling knowledge graph, the daemon embeds **Tantivy**, a full-text search engine (inspired by Apache Lucene).
* **The `MemoryIndex`:** Whenever the graph or auxiliary stores mutate, a background thread dynamically rebuilds the Tantivy index (`tantivy_index/` dir).
* **Global Omni-Search:** This architecture powers the `omni_search` tool, allowing subagents to instantly fuzzy-search and rank documents across Entities, Tasks, Snippets, Error Fixes, and ADRs simultaneously in milliseconds, without loading massive JSON arrays into RAM.
## 8. The Gate System (Push Safety Verification)
The binary supports a flexible safety gate authorization system, accessible both via the HTTP API and CLI subcommands.
@@ -88,17 +95,16 @@ With the introduction of the Dual Transport System, the daemon must safely handl
* **Safe Mutation:** Store modifications utilize closure-based modify(|store| { ... }) methods to ensure locks are safely acquired, mutations applied, and file writes executed sequentially without deadlocking the asynchronous Tokio runtimes.
## 12. Lifecycle & Startup Management (Server/Stub Architecture)
The mcp-memory daemon employs a self-healing, morphing Server/Client paradigm to allow multiple concurrent agy sessions without port collisions or file lock contention.
The mcp-memory daemon employs a strict Server/Client paradigm to maintain separation of concerns. There are no dual roles or morphing executables.
### The Windows Background Daemon
Every time a Windows gy session starts, it blindly spawns mcp-memory-stub.exe as a proxy. The proxy expects the mcp-memory-server.exe daemon to be running natively on port 3000.
### The Windows Background Daemon (`mcp-memory-server.exe`)
The server executable is responsible exclusively for running the Axum HTTP and WebSockets daemon and managing the Knowledge Graph. It does not contain any proxy logic. If port 3000 is already in use by an existing server instance, it gracefully exits instead of attempting to run.
### 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.
### The Client Proxy (`mcp-memory-stub.exe`)
All Antigravity sessions (Windows and WSL) use the lightweight `mcp-memory-stub` as their proxy. The stub connects to the server via WebSockets and acts as the bridge for standard `stdio` JSON-RPC traffic.
* **Startup via Interop/Spawn:** Upon launch, the stub attempts to connect to the Windows host on port 3000. If the server is offline, the stub automatically executes a spawn command (e.g., executing `mcp-memory-server.exe --daemon` natively, or via WSL interop) 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 (WSL):** 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:
@@ -115,3 +121,45 @@ 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.
## 14. Neovim Integration Architecture & "God Mode"
To enable seamless pair-programming inside Neovim, the daemon integrates with Neovim using two complementary systems: a Webhook Telemetry pipeline and dedicated MCP binaries.
### Telemetry Pipeline (Last Focused Wins)
A lightweight Lua script (gemini-integration.lua) is loaded into Neovim, which fires an asynchronous curl POST to http://127.0.0.1:3000/nvim/telemetry whenever the user focuses a buffer or moves the cursor. The server then writes this data (including session_id, ile, line, and col) to both the Windows and WSL ctive_nvim.txt files and broadcasts it over WebSockets.
### Native MCP Binaries (win-nvim & linux-nvim)
The project compiles two standalone, highly-performant binaries that implement the MCP JSON-RPC protocol over Stdio and bridge it directly to Neovim's Msgpack-RPC Named Pipes/Sockets. These binaries avoid hardcoding infinite tools by utilizing a "God Mode" escape hatch.
Exposed Neovim Tools:
* **
vim_get_active_buffer &
vim_get_cursor**: Read file state.
* **
vim_goto_line &
vim_set_diagnostics**: Manipulate IDE state.
* **
vim_get_visual_selection**: Read exact highlight coordinates (handles mode dynamically).
* **
vim_list_buffers**: Discover unsaved work and context.
* **
vim_get_diagnostics**: Read live LSP errors dynamically instead of requiring a compiler.
* **
vim_execute_lua ("God Mode")**: The ultimate fallback tool. Evaluates raw Lua scripts inside the active Neovim instance and returns JSON. This prevents the need to continuously recompile the Rust server whenever a new Neovim capability is required.
## 15. Build & Deployment Strategy
Because the background server operates as an always-on Windows daemon, standard recompilation and file-copying strategies will fail due to active Windows OS file locks.
### Randomized Lock Bypassing
The \uild.ps1\ deployment pipeline intercepts locked \.exe\ files by appending a unique, timestamped/randomized suffix (e.g., \mcp-memory-server.exe.12345.old\) when forcing a \Move-Item\. This guarantees that rapid sequential deployments (where a previous \.old\ file might still be locked by a zombie process) never silently fail or collide.
### Dynamic Versioning
To trace binary provenances during rapid deployment cycles, all binaries embed dynamic versioning directly at compile time (via \uild.rs\ and \uild_template.rs\). The injected \APP_VERSION\ environment variable combines the static Cargo \ ersion\ with the live \git\ short hash and UTC timestamp, allowing the CLI \--version\ commands and the HTTP \/api/version\ endpoints to guarantee exactly which iteration of the code is actively executing.
## 16. Testing Architecture (Native Rust E2E)
Historically, the project relied on a complex Python testing suite (\pytest\ + \mcp_client.py\) to validate the server over HTTP/SSE. This has been fully deprecated in favor of **Native Rust End-to-End Testing**.
* **Unit Tests:** Handlers and business logic are tested directly inside \server/src/handlers.rs\ using native \ okio::test\ constructs.
* **E2E Tests:** Integration and full-system tests run via \stub/tests/e2e.rs\ and \win-nvim/tests/integration_test.rs\, ensuring type safety, faster execution, and eliminating Python environment dependencies.
## 17. Automated Git Context Binding (git2)
Instead of forcing the LLM client to manually run `git rev-parse HEAD` and pass `git_branch` / `git_commit` arguments for every single code change, the server integrates the native **`git2`** C bindings.
When engineering endpoints (`log_code_change`, `log_error_fix`, `log_tech_debt`) are invoked, the server asynchronously discovers the surrounding Git repository, peels the HEAD reference, and automatically injects the current commit hash, commit message, and branch name directly into the stored entities and audit logs. This guarantees airtight VCS traceability without wasting LLM tokens or relying on the agent's memory.
-8
View File
@@ -1,8 +0,0 @@
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)
-43
View File
@@ -1,43 +0,0 @@
import re
import sys
main_rs_path = r"C:\Users\reazul.ashraf\workspace\rust\mcp-memory\server\src\main.rs"
with open(main_rs_path, "r", encoding="utf-8") as f:
content = f.read()
# Add the route
route_code = """
.route("/api/tasks/:id/complete", post({
let state_clone = app_state.handler.state.clone();
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
if let Ok(mut tasks) = state_clone.tasks.write() {
let mut found = false;
for t in tasks.iter_mut() {
if t.id == id {
t.status = "completed".to_string();
found = true;
break;
}
}
if found {
let _ = state_clone.flush_store();
return axum::Json(serde_json::json!({"status": "success"}));
}
}
axum::Json(serde_json::json!({"status": "not_found"}))
}
}))
"""
# Find where to insert it (after /api/tasks)
if ".route(\"/api/tasks/:id/complete\"" not in content:
content = content.replace(
'.route("/api/tasks", get({',
route_code + '\n .route("/api/tasks", get({'
)
with open(main_rs_path, "w", encoding="utf-8") as f:
f.write(content)
print("Injected /api/tasks/:id/complete route successfully.")
-49
View File
@@ -1,49 +0,0 @@
import re
main_rs_path = r"C:\Users\reazul.ashraf\workspace\rust\mcp-memory\server\src\main.rs"
with open(main_rs_path, "r", encoding="utf-8") as f:
content = f.read()
# Replace the broken route
old_route = r""" .route("/api/tasks/:id/complete", post({
let state_clone = app_state.handler.state.clone();
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
if let Ok(mut tasks) = state_clone.tasks.write() {
let mut found = false;
for t in tasks.iter_mut() {
if t.id == id {
t.status = "completed".to_string();
found = true;
break;
}
}
if found {
let _ = state_clone.flush_store();
return axum::Json(serde_json::json!({"status": "success"}));
}
}
axum::Json(serde_json::json!({"status": "not_found"}))
}
}))"""
new_route = """ .route("/api/tasks/:id/complete", post({
let state_clone = app_state.handler.state.clone();
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
state_clone.tasks.modify(|tasks| {
for t in tasks.iter_mut() {
if t.id == id {
t.status = "completed".to_string();
break;
}
}
});
axum::Json(serde_json::json!({"status": "success"}))
}
}))"""
content = content.replace(old_route, new_route)
with open(main_rs_path, "w", encoding="utf-8") as f:
f.write(content)
print("Replaced route.")
+23 -6
View File
@@ -1,15 +1,13 @@
-- gemini-integration.lua
-- Registers the active Neovim instance for the Antigravity MCP Server
vim.api.nvim_create_autocmd({"VimEnter", "FocusGained"}, {
callback = function()
vim.api.nvim_create_autocmd({"VimEnter", "FocusGained", "BufEnter"}, {
callback = function(ev)
-- 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
-- 1. Legacy Disk Write (Fallback)
local home = os.getenv("HOME") or os.getenv("USERPROFILE")
if home then
os.execute("mkdir -p " .. home .. "/.gemini")
local path = home .. "/.gemini/active_nvim.txt"
@@ -19,8 +17,27 @@ vim.api.nvim_create_autocmd({"VimEnter", "FocusGained"}, {
f:close()
end
end
-- 2. V2 Telemetry Push (WebSockets & Cross-OS Sync via Rust Server)
local file = vim.api.nvim_buf_get_name(0)
local cursor = vim.api.nvim_win_get_cursor(0)
local row, col = cursor[1], cursor[2]
local payload = string.format(
'{"session_id": "%s", "event": "%s", "file": "%s", "line": %d, "col": %d}',
server_name:gsub("\\", "\\\\"), ev.event, file:gsub("\\", "\\\\"), row, col
)
vim.fn.jobstart({
"curl", "-s", "-X", "POST", "http://127.0.0.1:3000/nvim/telemetry",
"-H", "Content-Type: application/json",
"-d", payload
}, { detach = true })
end
end
end,
desc = "Register active Neovim instance for Gemini CLI",
})
-- Load UI & Keymaps
local ui = dofile("C:/Users/reazul.ashraf/workspace/rust/mcp-memory/gemini-ui.lua")
ui.setup()
_G.gemini = ui -- Expose globally so Agent can call it via execute_lua
+108
View File
@@ -0,0 +1,108 @@
local M = {}
local progress_win = nil
local progress_buf = nil
local job_id = nil
function M.show_progress(msg)
if progress_win and vim.api.nvim_win_is_valid(progress_win) then
local lines = vim.split(msg, "\n")
vim.api.nvim_buf_set_lines(progress_buf, -1, -1, false, lines)
local line_count = vim.api.nvim_buf_line_count(progress_buf)
vim.api.nvim_win_set_cursor(progress_win, {line_count, 0})
return
end
progress_buf = vim.api.nvim_create_buf(false, true)
local lines = vim.split(msg, "\n")
vim.api.nvim_buf_set_lines(progress_buf, 0, -1, false, lines)
local width = math.min(math.floor(vim.o.columns * 0.4), 80)
local height = math.min(math.floor(vim.o.lines * 0.4), 20)
local row = 1
local col = vim.o.columns - width - 2
progress_win = vim.api.nvim_open_win(progress_buf, true, {
relative = 'editor',
width = width,
height = height,
row = row,
col = col,
style = 'minimal',
border = 'rounded',
title = ' Antigravity AI ',
title_pos = 'center'
})
local function close_win()
if job_id then
vim.fn.jobstop(job_id)
job_id = nil
end
if progress_win and vim.api.nvim_win_is_valid(progress_win) then
vim.api.nvim_win_close(progress_win, true)
progress_win = nil
end
end
vim.keymap.set('n', 'q', close_win, { buffer = progress_buf, noremap = true, silent = true })
vim.keymap.set('n', '<Esc>', close_win, { buffer = progress_buf, noremap = true, silent = true })
end
function M.close_progress()
if progress_win and vim.api.nvim_win_is_valid(progress_win) then
vim.api.nvim_win_close(progress_win, true)
progress_win = nil
end
end
local function execute_agent(prompt)
local cmd = { "agy", "--new-project", "-i", prompt }
M.show_progress("Starting Antigravity...")
-- termopen requires an empty and unmodified buffer
vim.api.nvim_buf_set_lines(progress_buf, 0, -1, false, {})
vim.bo[progress_buf].modified = false
-- termopen will attach to the current buffer (which show_progress just focused)
job_id = vim.fn.termopen(cmd, {
on_exit = function(_, code)
job_id = nil
end
})
vim.cmd('startinsert')
end
function M.ask_agent()
local file = vim.api.nvim_buf_get_name(0)
vim.ui.input({ prompt = 'Ask Agent: ' }, function(input)
if not input or input == '' then return end
local final_input = input
if file and file ~= "" then
final_input = input .. string.format(" [Context file: %s]", file)
end
execute_agent(final_input)
end)
end
function M.setup()
vim.keymap.set('n', '<leader>ai', M.ask_agent, { desc = "Ask Antigravity Agent", noremap = true, silent = true })
vim.keymap.set('v', '<leader>ai', function()
local _, csrow, cscol, _ = unpack(vim.fn.getpos("'<"))
local _, cerow, cecol, _ = unpack(vim.fn.getpos("'>"))
vim.ui.input({ prompt = 'Ask Agent (about selection): ' }, function(input)
if not input or input == '' then return end
local file = vim.api.nvim_buf_get_name(0)
local context = string.format(" [Context: %s lines %d-%d]", file, csrow, cerow)
local final_input = input .. context
execute_agent(final_input)
end)
end, { desc = "Ask Antigravity Agent about selection", noremap = true, silent = true })
end
return M
+39 -48
View File
@@ -1,36 +1,7 @@
# MCP Memory Server Guidelines
# Memory MCP Strategic Guidelines
This server provides an OS-agnostic, persistent Knowledge Graph and working memory. The following guidelines dictate **when** and **how** to best utilize these tools.
## 1. Ephemeral Working Memory (Sticky Notes)
- **Tools:** add_sticky_note, ␍ead_sticky_notes
- **When to use:**
- When you need to remember a specific context, broken build state, or pending task across conversation boundaries (e.g., "We are in the middle of refactoring main.rs, next step is testing").
- When saving short-term scratchpad thoughts that don't belong in the permanent knowledge graph.
- **Behavior:** Notes have a 24-hour TTL and are pruned automatically. Always consider reading sticky notes at the start of a session if picking up an ongoing task.
## 2. The Knowledge Graph (Entities & Relations)
- **Tools:** search_nodes, open_nodes, ␍ead_graph
- **When to use search_nodes:** Use this for **fuzzy searching** when you aren't 100% sure of the exact entity name, or you want to find everything related to a topic (e.g., "Docker", "Jenkins"). It uses Jaro-Winkler distance and token matching.
- **When to use open_nodes:** Use this for **1-hop traversal**. If you know a specific entity name (e.g., "auth-service") and want to see how it connects to other services, open_nodes will pull the entity AND all directly connected neighbors.
## 3. Graph Maintenance & Condensation
- **Tool:** condense_entity
- **When to use:** Proactively use this tool if you notice an entity's observations array is getting excessively long (e.g., >10 items) or repetitive. Condense the list into a shorter, more concise summary of the core facts to preserve token limits.
## 4. The Audit Ledger (Code Changes)
- **Tools:** log_code_change, query_recent_changes
- **When to use log_code_change:** *MANDATORY.* Immediately after successfully making significant file modifications (e.g., refactoring a script, fixing a bug). Log the file path and a short description.
- **When to use query_recent_changes:** When the user asks "What did we do yesterday?", "What changed recently?", or when trying to debug a newly introduced issue. (The ledger retains 7 days of history, max 1000 items).
## 5. Architectural Visualization
- **Tool:** isualize_graph
- **When to use:** When you need to explain complex system relationships, dependencies, or architectures to the user. This tool outputs a raw Mermaid.js string. Put the output inside a ``mermaid` markdown block in an Artifact so the user can see a visual diagram.
## 6. Structured Task Queue (Agentic Kanban)
- **Tools:** `add_task`, `update_task_status`, `list_active_tasks`
- **When to use:** Use this for structured checklist tracking of complex, multi-day features or goals (e.g., when the user initiates a `/goal`).
- **Behavior:** Log tasks with a title and description. When resuming sessions, always run `list_active_tasks` to see what is still `pending`, `in_progress`, or `blocked`, and pick up where you left off. Use `update_task_status` to transition tasks to `done`.
This document outlines the STRATEGY and SEMANTICS for using the MCP Memory Server.
You do not need to memorize JSON schemas for these tools; they are strictly defined and typed in the ools/list endpoint. Focus purely on WHEN and WHY to use them.
## 7. Snippet & Command Vault
- **Tools:** `store_snippet`, `search_snippets`, `delete_snippet`
@@ -40,22 +11,23 @@ This server provides an OS-agnostic, persistent Knowledge Graph and working memo
## 8. Context Namespaces (Project Scopes)
- **Feature:** `namespace` optional parameter
- **When to use:** When using `read_graph`, `search_nodes`, or `visualize_graph`, you can now pass `namespace` to isolate graph queries to a specific project scope (e.g. "scascanner").
- **Behavior:** When calling `create_entities` or `create_relations`, you can inject `namespace: "your_project"` into the entity schema to isolate it from the global scope.
- **Behavior:** When calling `create_entities` or `create_relations`, you can inject `namespace: "your_project"` into the entity schema to isolate it from the global scope.
## 9. Architectural Decision Records (ADRs)
- **Tools:** log_decision, query_decisions
- **When to use:** Use this whenever you make a non-trivial architectural, environmental, or tooling decision (e.g. choosing a specific framework, a specific deployment flag, bypassing a rule with a workaround).
- **Behavior:** This permanently stores the context, decision, and consequence of *why* something is done the way it is, preventing future agents from second-guessing or reverting it.
## 10. Graph Refactoring & Maintenance
- **Tools:** merge_entities, ind_orphans
- **When to use:** Run ind_orphans periodically or when you notice graph clutter to safely delete unused nodes. Use merge_entities when you notice duplicated semantic concepts (e.g. API_Gateway vs APIGateway).
- **Behavior:** merge_entities will safely combine their observations and automatically remap all relations pointing to or from the deleted duplicate.
## 10. Graph Refactoring & Algorithms
- **Tools:** merge_entities, find_orphans, query_graph_path
- **When to use:** Run `find_orphans` periodically or when you notice graph clutter to safely delete unused nodes. Use `merge_entities` when you notice duplicated semantic concepts (e.g. API_Gateway vs APIGateway). Use `query_graph_path` when you need to understand how two completely different architectural components are related (e.g. "How does the Frontend connect to the Database?").
- **Behavior:** `merge_entities` will safely combine their observations and automatically remap all relations pointing to or from the deleted duplicate. `query_graph_path` executes a breadth-first search to find the shortest relational path between nodes.
## 11. Dynamic Learned Preferences
- **Tools:** learn_preference, ␍ead_preferences
- **Tools:** learn_preference,
ead_preferences
- **When to use:** When the user corrects you on a specific local nuance (e.g. "Actually, use Python 3.10 instead of 3.12 for this repo").
- **Behavior:** Use this Key-Value store to record dynamic behavioral preferences to ensure you adapt instantly without modifying global Markdown files.
- **Behavior:** Use this Key-Value store to record dynamic behavioral preferences to ensure you adapt instantly without modifying global Markdown files.
## Error Vault / Troubleshooting
When you spend time resolving a tricky environment issue, build error, or logic bug, immediately record the fix to save future time.
@@ -72,7 +44,7 @@ amespace (project).
To maintain a chronological narrative of the project's evolution beyond just code diffs.
- **add_session_summary:** At the end of every major coding session, write a 2-sentence summary of what was accomplished and add it to the
amespace.
- **get_project_timeline:** When rejoining a project after a long time, read the timeline to instantly understand the recent architectural history.
- **get_project_timeline:** When rejoining a project after a long time, read the timeline to instantly understand the recent architectural history.
## Agent Handoffs (The Inbox)
When working in a multi-session or multi-agent environment, agents need to communicate context across time.
@@ -82,7 +54,8 @@ When working in a multi-session or multi-agent environment, agents need to commu
## Environment & Blueprint Tracker
Stop wasting tokens rediscovering how to run or configure the project.
- **update_env_fingerprint:** Run this when you set up a new project to snapshot the OS, shell, and key language versions (e.g. python, ␍ustc).
- **update_env_fingerprint:** Run this when you set up a new project to snapshot the OS, shell, and key language versions (e.g. python,
ustc).
- **read_env_fingerprint:** Query this to instantly know how the project is run.
- **log_env_requirement:** Log required .env variables (e.g. DATABASE_URL) without logging the secret itself.
@@ -90,7 +63,7 @@ Stop wasting tokens rediscovering how to run or configure the project.
Organize granular tasks into high-level phases.
- **add_milestone:** Group a large subset of work into a cohesive phase (e.g., "V1 MVP", "CI/CD Setup").
- **update_milestone:** Mark a milestone as active, blocked, or completed.
- **list_milestones:** Use this to ensure task priorities align with the current active milestone.
- **list_milestones:** Use this to ensure task priorities align with the current active milestone.
## Standup Reports
When asked for a progress update or standup report, do not guess or read raw git logs.
@@ -105,7 +78,7 @@ Avoid asking the user for URLs or connection details repeatedly.
Ensure high code quality and prevent incomplete pull requests.
- **add_pr_checklist_item:** Add recurring repository chores (e.g., "Run cargo fmt", "Update CHANGELOG").
- **get_pr_checklist:** Query and explicitly verify EVERY item on this list before triggering git push or merging PRs.
- **clear_pr_checklist:** Clear if the project lifecycle changes dramatically.
- **clear_pr_checklist:** Clear if the project lifecycle changes dramatically.
## Tech Debt & Refactor Backlog
Keep the main task board clean by isolating "hacky" workarounds.
@@ -117,7 +90,7 @@ Keep the main task board clean by isolating "hacky" workarounds.
When shifting context rapidly (e.g. from building a feature to fixing a prod bug), save your state.
- **save_context_workspace:** Save your active pinned files and task IDs under a named workspace (e.g., "Feature X").
- **load_context_workspace:** Retrieve a saved workspace to instantly restore your context when returning to that task.
- **list_context_workspaces:** List all saved workspaces in a project.
- **list_context_workspaces:** List all saved workspaces in a project.
## Omni-Search (Global Vault Search)
When you remember a vague keyword but don't know which specific vault it's stored in.
@@ -125,16 +98,34 @@ When you remember a vague keyword but don't know which specific vault it's store
## Project Health Dashboard
When starting a new session, get a numerical aggregate of the project's current state.
- **get_project_health:** Returns a quick digest of active tasks, unread memos, unresolved tech debt, and pending PR checklist items.
- **get_project_health:** Returns a quick digest of active tasks, unread memos, unresolved tech debt, and pending PR checklist items.
## Git Context Binding (VCS Sync)
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.
- **Fully Automated (git2):** You no longer need to manually execute `git rev-parse HEAD` or pass `git_branch` / `git_commit` arguments.
- When you call tools like **log_code_change**, **log_error_fix**, or **log_tech_debt**, the Rust server uses the native `git2` crate to automatically discover the repository context of the active working directory, extract the current HEAD commit hash, message, and branch, and permanently bind them to the memory item in the background.
## 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
2. HTTP Endpoint: POST http://127.0.0.1:3000/shutdown
## 14. Dual Transport Architecture (SSE & WebSockets)
- **Constraint:** The MCP Memory Server natively supports a dual transport layer. You MUST maintain both if modifying network code.
- **SSE (/sse & /messages):** Used strictly by the Antigravity LLM client because standard AI runtimes prefer synchronous HTTP JSON-RPC wrappers.
- **WebSockets (/ws):** Used strictly by external UI clients (e.g., dashboard.html) or the mcp-memory-stub proxy.
- **Behavior:** Both transport layers route into the exact same central handle_request pipeline. Do not build feature logic that only works on one transport protocol.
## 15. Neovim Integration & God Mode
The project contains two MCP binaries (win-nvim and linux-nvim) that bridge JSON-RPC over stdio directly to the active Neovim instance (using ctive_nvim.txt for Last Focused Wins telemetry).
- These binaries expose basic tools (
vim_get_cursor,
vim_get_active_buffer,
vim_list_buffers, etc.).
- **God Mode**: They also expose
vim_execute_lua. This is the ultimate fallback tool. If you need to access *any* Neovim API that does not have a dedicated Rust tool (e.g., getting LSP diagnostics, evaluating a visual selection block based on modes, setting registers), you MUST write a short Lua script and pass it to
vim_execute_lua. Do not attempt to recompile the Rust server to add new basic tools; use the Lua escape hatch dynamically.
+4
View File
@@ -10,3 +10,7 @@ rmp-serde = "1.1"
rmpv = "1.0"
tokio = { version = "1.37", features = ["full"] }
schemars = "0.8"
tracing-appender = "0.2.5"
tracing = "0.1.44"
tracing-subscriber = "0.3.23"
dirs = "7.0.0"
+20
View File
@@ -0,0 +1,20 @@
use std::process::Command;
fn main() {
let git_hash = Command::new("git")
.args(&["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string());
let git_date = Command::new("git")
.args(&["log", "-1", "--format=%cd", "--date=format:%Y.%m.%d"])
.output()
.ok()
.and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string());
let version = format!("{} ({} {})", env!("CARGO_PKG_VERSION"), git_date.trim(), git_hash.trim());
println!("cargo:rustc-env=APP_VERSION={}", version);
}
+11 -427
View File
@@ -1,432 +1,16 @@
mod mcp;
#[cfg(unix)]
mod unix_app;
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
}
}
});
#[cfg(unix)]
fn main() {
if std::env::args().any(|a| a == "--version" || a == "-V") {
println!("mcp-memory-linux-nvim {}", env!("APP_VERSION"));
return;
}
unix_app::main();
}
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())
#[cfg(not(unix))]
fn main() {
println!("mcp-memory-linux-nvim is only supported on Unix platforms.");
}
+1 -2
View File
@@ -20,8 +20,7 @@ pub struct JsonRpcResponse {
pub error: Option<Value>,
}
pub async fn read_message() -> Option<JsonRpcRequest> {
let mut stdin = BufReader::new(tokio::io::stdin());
pub async fn read_message(stdin: &mut BufReader<tokio::io::Stdin>) -> Option<JsonRpcRequest> {
let mut length = 0;
loop {
let mut line = String::new();
+612
View File
@@ -0,0 +1,612 @@
#[path = "mcp.rs"]
pub mod mcp;
use mcp::{read_message, send_response, send_error, JsonRpcResponse};
use serde_json::json;
use tokio::net::UnixStream;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
fn init_logging(app_name: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| {
dirs::home_dir()
.map(|mut h| {
h.push(".gemini/mcp_memory");
h.to_string_lossy().to_string()
})
.unwrap_or_else(|| ".gemini/mcp_memory".into())
});
let log_dir = std::path::PathBuf::from(base_dir).join("logs");
std::fs::create_dir_all(&log_dir).unwrap_or_default();
let file_appender = tracing_appender::rolling::daily(log_dir, format!("{}.log", app_name));
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let _ = tracing_subscriber::fmt()
.with_writer(non_blocking)
.with_ansi(false)
.with_max_level(tracing::Level::INFO)
.try_init();
Some(guard)
}
#[tokio::main]
pub async fn main() {
let _guard = init_logging("linux-nvim");
let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
loop {
let msg = match read_message(&mut stdin).await {
Some(m) => m,
None => break,
};
tokio::spawn(async move {
let id = msg.id.clone().unwrap_or(json!(null));
match msg.method.as_str() {
"initialize" => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(json!({
"capabilities": {},
"serverInfo": {
"name": "mcp-memory-linux-nvim",
"version": "0.1.0"
}
})),
error: None,
}).await;
}
"tools/list" => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(json!({
"tools": [
{
"name": "nvim_goto_line",
"description": "Open a file and jump to a specific line",
"inputSchema": {
"type": "object",
"properties": {
"file": { "type": "string" },
"line": { "type": "integer" }
},
"required": ["file", "line"]
}
},
{
"name": "nvim_get_active_buffer",
"description": "Get the contents of the currently active Neovim buffer",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "nvim_get_cursor",
"description": "Get the current cursor position (line and column) in the active Neovim buffer",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "nvim_get_visual_selection",
"description": "Get the text that is currently highlighted or was last highlighted in Visual mode",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "nvim_set_diagnostics",
"description": "Push a diagnostic message (like an LSP warning) to a specific line in the active buffer",
"inputSchema": {
"type": "object",
"properties": {
"line": { "type": "integer" },
"message": { "type": "string" }
},
"required": ["line", "message"]
}
},
{
"name": "nvim_execute_lua",
"description": "Execute arbitrary Lua code in Neovim and return the result (JSON serialized).",
"inputSchema": {
"type": "object",
"properties": {
"code": { "type": "string" }
},
"required": ["code"]
}
},
{
"name": "nvim_list_buffers",
"description": "Get a list of all loaded Neovim buffers and their IDs.",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "nvim_get_diagnostics",
"description": "Get all LSP diagnostics (errors, warnings) for the active buffer.",
"inputSchema": {
"type": "object",
"properties": {}
}
}
]
})),
error: None,
}).await;
}
"tools/call" => {
let params = msg.params.clone().unwrap_or(json!({}));
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
let args = params.get("arguments").cloned().unwrap_or(json!({}));
match name {
"nvim_goto_line" => {
let file = args.get("file").and_then(|v| v.as_str()).unwrap_or("");
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(1);
let cmd = format!("edit {} | {} | normal! zz", file, line);
match send_nvim_command(&cmd).await {
Ok(_) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(json!({
"content": [
{ "type": "text", "text": format!("Successfully jumped to {}:{}", file, line) }
]
})),
error: None,
}).await;
}
Err(e) => {
send_error(id, -32603, &format!("Failed to execute command: {}", e)).await;
}
}
}
"nvim_get_active_buffer" => {
match get_nvim_active_buffer().await {
Ok(content) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(json!({
"content": [
{ "type": "text", "text": content }
]
})),
error: None,
}).await;
}
Err(e) => {
send_error(id, -32603, &format!("Failed to get active buffer: {}", e)).await;
}
}
}
"nvim_get_cursor" => {
match get_nvim_cursor().await {
Ok(content) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(json!({
"content": [
{ "type": "text", "text": content }
]
})),
error: None,
}).await;
}
Err(e) => {
send_error(id, -32603, &format!("Failed to get cursor: {}", e)).await;
}
}
}
"nvim_get_visual_selection" => {
match get_nvim_visual_selection().await {
Ok(content) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(json!({
"content": [
{ "type": "text", "text": content }
]
})),
error: None,
}).await;
}
Err(e) => {
send_error(id, -32603, &format!("Failed to get visual selection: {}", e)).await;
}
}
}
"nvim_set_diagnostics" => {
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(1);
let message = args.get("message").and_then(|v| v.as_str()).unwrap_or("");
match set_nvim_diagnostics(line, message).await {
Ok(_) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(json!({
"content": [
{ "type": "text", "text": format!("Successfully set diagnostic on line {}", line) }
]
})),
error: None,
}).await;
}
Err(e) => {
send_error(id, -32603, &format!("Failed to set diagnostic: {}", e)).await;
}
}
}
"nvim_execute_lua" => {
let code = args.get("code").and_then(|v| v.as_str()).unwrap_or("");
match execute_nvim_lua(code).await {
Ok(content) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(), id, result: Some(json!({ "content": [{ "type": "text", "text": content }] })), error: None,
}).await;
}
Err(e) => { send_error(id, -32603, &format!("Failed to execute lua: {}", e)).await; }
}
}
"nvim_list_buffers" => {
let code = r#"
local bufs = vim.api.nvim_list_bufs()
local loaded = {}
for _, b in ipairs(bufs) do
if vim.api.nvim_buf_is_loaded(b) then
local name = vim.api.nvim_buf_get_name(b)
table.insert(loaded, {id = b, name = name == "" and "[No Name]" or name})
end
end
return loaded
"#;
match execute_nvim_lua(code).await {
Ok(content) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(), id, result: Some(json!({ "content": [{ "type": "text", "text": content }] })), error: None,
}).await;
}
Err(e) => { send_error(id, -32603, &format!("Failed to list buffers: {}", e)).await; }
}
}
"nvim_get_diagnostics" => {
let code = r#"
local diags = vim.diagnostic.get(0)
local res = {}
for _, d in ipairs(diags) do
table.insert(res, {
line = d.lnum + 1,
col = d.col,
message = d.message,
severity = d.severity
})
end
return res
"#;
match execute_nvim_lua(code).await {
Ok(content) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(), id, result: Some(json!({ "content": [{ "type": "text", "text": content }] })), error: None,
}).await;
}
Err(e) => { send_error(id, -32603, &format!("Failed to get diagnostics: {}", e)).await; }
}
}
_ => {
send_error(id, -32601, "Tool not found").await;
}
}
}
_ => {
// Ignore other methods
}
}
});
}
}
async fn get_socket_path() -> Result<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 read_future = stream.read(&mut chunk);
match tokio::time::timeout(tokio::time::Duration::from_secs(5), read_future).await {
Ok(Ok(n)) => {
if n == 0 { return Err("Connection closed".into()); }
resp_buf.extend_from_slice(&chunk[..n]);
}
Ok(Err(e)) => return Err(e.to_string()),
Err(_) => return Err("Timeout waiting for Neovim response".into()),
}
}
}
}
}
async fn send_nvim_command(cmd: &str) -> Result<(), String> {
use rmpv::Value as RmpValue;
let req = RmpValue::Array(vec![
RmpValue::Integer(0.into()),
RmpValue::Integer(1.into()), // msgid
RmpValue::String("nvim_command".into()),
RmpValue::Array(vec![RmpValue::String(cmd.into())]),
]);
let resp = call_nvim(req).await?;
if let RmpValue::Array(arr) = resp {
if !arr[2].is_nil() {
return Err(format!("Neovim error: {:?}", arr[2]));
}
return Ok(());
}
Err("Invalid response".to_string())
}
async fn get_nvim_active_buffer() -> Result<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())
}
fn rmpv_to_json(val: &rmpv::Value) -> serde_json::Value {
match val {
rmpv::Value::Nil => serde_json::Value::Null,
rmpv::Value::Boolean(b) => serde_json::json!(b),
rmpv::Value::Integer(i) => {
if let Some(n) = i.as_i64() {
serde_json::json!(n)
} else if let Some(n) = i.as_u64() {
serde_json::json!(n)
} else {
serde_json::Value::Null
}
},
rmpv::Value::F32(f) => serde_json::json!(f),
rmpv::Value::F64(f) => serde_json::json!(f),
rmpv::Value::String(s) => {
if let Some(str_val) = s.as_str() {
serde_json::json!(str_val)
} else {
serde_json::Value::Null
}
},
rmpv::Value::Array(arr) => {
let vec: Vec<serde_json::Value> = arr.iter().map(rmpv_to_json).collect();
serde_json::Value::Array(vec)
},
rmpv::Value::Map(map) => {
let mut obj = serde_json::Map::new();
for (k, v) in map {
let key_str = if let rmpv::Value::String(s) = k {
s.as_str().unwrap_or("").to_string()
} else {
format!("{:?}", k)
};
obj.insert(key_str, rmpv_to_json(v));
}
serde_json::Value::Object(obj)
},
_ => serde_json::json!(format!("{:?}", val)),
}
}
async fn execute_nvim_lua(code: &str) -> Result<String, String> {
use rmpv::Value as RmpValue;
let req = RmpValue::Array(vec![
RmpValue::Integer(0.into()),
RmpValue::Integer(6.into()), // msgid
RmpValue::String("nvim_exec_lua".into()),
RmpValue::Array(vec![
RmpValue::String(code.into()),
RmpValue::Array(vec![]),
]),
]);
let resp = call_nvim(req).await?;
if let RmpValue::Array(arr) = resp {
if !arr[2].is_nil() {
return Err(format!("Neovim error: {:?}", arr[2]));
}
if arr.len() > 3 {
return Ok(serde_json::to_string_pretty(&rmpv_to_json(&arr[3])).unwrap_or_default());
}
return Ok("".to_string());
}
Err("Invalid response".to_string())
}
-59
View File
@@ -1,59 +0,0 @@
import urllib.request
import json
import threading
import sys
import time
sse_url = "http://localhost:3000/sse"
def call_mcp(method, params):
req = urllib.request.Request(sse_url)
with urllib.request.urlopen(req) as response:
endpoint = None
# read the SSE stream
for line_bytes in response:
line = line_bytes.decode('utf-8').strip()
if line.startswith("data: /messages"):
endpoint = "http://localhost:3000" + line.split("data: ")[1].strip()
break
if not endpoint:
print("Failed to get endpoint")
sys.exit(1)
print(f"Got endpoint: {endpoint}")
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": method,
"arguments": params
}
}
post_req = urllib.request.Request(endpoint, data=json.dumps(payload).encode('utf-8'), headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(post_req) as post_resp:
print(f"POST status: {post_resp.status}")
# continue reading SSE for response
for line_bytes in response:
line = line_bytes.decode('utf-8').strip()
if line.startswith("data: "):
data = line[6:]
if data == "ping": continue
try:
msg = json.loads(data)
if msg.get("id") == 1:
print("RESULT:")
print(json.dumps(msg, indent=2))
return
except:
pass
import sys
if __name__ == "__main__":
method = sys.argv[1]
params = json.loads(sys.argv[2])
call_mcp(method, params)
+5 -1
View File
@@ -5,13 +5,15 @@ edition = "2024"
[dependencies]
async-trait = "0.1.92"
axum = "0.8"
axum = { version = "0.8", features = ["ws"] }
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"
git2 = { version = "0.19.0", default-features = false }
notify = "6.1.1"
redb = "4.2.0"
reqwest = { version = "0.12", default-features = false, features = ["stream", "rustls-tls"] }
schemars = "1.2.2"
@@ -24,6 +26,8 @@ tokio-util = { version = "0.7.19", features = ["io"] }
tracing = "0.1.44"
tracing-subscriber = "0.3.23"
uuid = { version = "1.26.0", features = ["v4"] }
tokio-tungstenite = "0.21.0"
tracing-appender = "0.2.5"
[build-dependencies]
chrono = "0.4.45"
+15 -11
View File
@@ -1,16 +1,20 @@
use std::process::Command;
use chrono::Local;
fn main() {
let now = Local::now();
let build_date = now.format("%Y.%m.%d").to_string();
println!("cargo:rustc-env=BUILD_DATE={}", build_date);
let git_hash = Command::new("git")
.args(&["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string());
let git_date = Command::new("git")
.args(&["log", "-1", "--format=%cd", "--date=format:%Y.%m.%d"])
.output()
.ok()
.and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string());
if let Ok(output) = Command::new("git").args(&["rev-parse", "--short", "HEAD"]).output() {
if let Ok(hash) = String::from_utf8(output.stdout) {
println!("cargo:rustc-env=GIT_HASH={}", hash.trim());
}
}
println!("cargo:rerun-if-changed=../.git/HEAD");
println!("cargo:rerun-if-changed=../.git/index");
let version = format!("{} ({} {})", env!("CARGO_PKG_VERSION"), git_date.trim(), git_hash.trim());
println!("cargo:rustc-env=APP_VERSION={}", version);
}
-39
View File
@@ -1,39 +0,0 @@
import re
endpoints = """
.route("/api/graph", get({
let state_clone = app_state.handler.state.clone();
move || async move {
let graph = state_clone.get_full_graph();
axum::Json(graph)
}
}))
.route("/api/tasks", get({
let state_clone = app_state.handler.state.clone();
move || async move {
let tasks = state_clone.tasks.read();
axum::Json(tasks.clone())
}
}))
.route("/api/search", get({
let state_clone = app_state.handler.state.clone();
move |axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>| async move {
if let Some(q) = params.get("q") {
if let Ok(idx) = state_clone.search_index.read() {
let results = idx.search(q, 50);
return axum::Json(serde_json::json!({ "results": results }));
}
}
axum::Json(serde_json::json!({ "results": [] }))
}
}))
"""
filepath = "C:\\Users\\reazul.ashraf\\workspace\\rust\\mcp-memory\\server\\src\\main.rs"
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
content = content.replace('.route("/api/stats",', endpoints + ' .route("/api/stats",')
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
+190 -58
View File
@@ -105,8 +105,9 @@
}
.main-container {
display: flex;
flex-direction: column;
flex: 1;
gap: 20px;
gap: 10px;
min-height: 0;
}
.panel {
@@ -127,9 +128,11 @@
padding-bottom: 10px;
}
.graph-panel {
flex: 2;
flex: 1;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Filter Bar */
@@ -225,17 +228,58 @@
border-bottom: 1px solid var(--border-color);
}
.tabs {
display: flex;
gap: 5px;
margin-bottom: 10px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 5px;
}
.tab-button {
padding: 8px 16px;
background: var(--panel-bg);
border: 1px solid var(--border-color);
border-bottom: none;
border-radius: 4px 4px 0 0;
color: var(--text-color);
cursor: pointer;
opacity: 0.7;
}
.tab-button.active {
opacity: 1;
background: var(--bg-color);
font-weight: bold;
border-bottom: 1px solid var(--bg-color);
margin-bottom: -6px;
}
.tab-content {
display: none;
height: 100%;
flex-direction: column;
flex: 1;
}
.tab-content.active {
display: flex;
}
.right-column {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
min-width: 300px;
min-height: 0;
}
.right-column-panels {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
overflow-y: auto;
}
.search-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
input[type="text"] {
width: 100%;
@@ -368,10 +412,29 @@
border-color: #2ecc71;
background: rgba(46, 204, 113, 0.1);
}
/* Sticky Notes */
.sticky-note {
background-color: #f1c40f;
color: #2c3e50;
padding: 15px;
border-radius: 4px;
width: 250px;
box-shadow: 2px 2px 8px rgba(0,0,0,0.15);
position: relative;
font-size: 0.9em;
line-height: 1.4;
}
.sticky-note:hover {
transform: scale(1.02);
box-shadow: 2px 4px 12px rgba(0,0,0,0.2);
transition: all 0.2s ease;
}
.activity-panel {
height: 180px;
margin-top: 20px;
flex: 1;
display: flex;
flex-direction: column;
}
#activity-feed {
overflow-y: auto;
@@ -407,42 +470,51 @@
</div>
<div class="main-container">
<div class="panel graph-panel">
<h2>Knowledge Graph</h2>
<div class="graph-filters" id="graph-filters">
<!-- Filters injected dynamically -->
</div>
<div class="graph-controls">
<button onclick="zoomGraph(0.2)" title="Zoom In">+</button>
<button onclick="zoomGraph(-0.2)" title="Zoom Out">-</button>
<button onclick="resetGraph()" title="Reset View">⌂</button>
</div>
<div id="network-container">
<div id="inspector-panel">
<button id="inspector-close" onclick="closeInspector()">✕</button>
<h2 id="inspector-title" style="margin-top:0; color:var(--text-primary);"></h2>
<div class="inspector-meta">
<div><strong>Type:</strong> <span id="inspector-type"></span></div>
<div><strong>Namespace:</strong> <span id="inspector-namespace"></span></div>
<div class="tabs">
<button class="tab-button active" onclick="switchTab('graph-tab', this)">Graph Explorer</button>
<button class="tab-button" onclick="switchTab('search-tab', this)">Omni-Search</button>
<button class="tab-button" onclick="switchTab('task-tab', this)">Task Board</button>
<button class="tab-button" onclick="switchTab('sticky-tab', this)">Sticky Notes</button>
<button class="tab-button" onclick="switchTab('activity-tab', this)">Live Activity</button>
</div>
<div id="graph-tab" class="tab-content active">
<div class="panel graph-panel" style="flex:1;">
<div class="graph-filters" id="graph-filters">
<!-- Filters injected dynamically -->
</div>
<div class="graph-controls">
<button onclick="zoomGraph(0.2)" title="Zoom In">+</button>
<button onclick="zoomGraph(-0.2)" title="Zoom Out">-</button>
<button onclick="resetGraph()" title="Reset View">⌂</button>
</div>
<div id="network-container">
<div id="inspector-panel">
<button id="inspector-close" onclick="closeInspector()">✕</button>
<h2 id="inspector-title" style="margin-top:0; color:var(--text-primary);"></h2>
<div class="inspector-meta">
<div><strong>Type:</strong> <span id="inspector-type"></span></div>
<div><strong>Namespace:</strong> <span id="inspector-namespace"></span></div>
</div>
<h4 style="margin:10px 0 5px 0;">Observations</h4>
<ul id="inspector-observations" style="padding-left:20px; font-size:0.9em; line-height:1.4;"></ul>
<h4 style="margin:15px 0 5px 0;">Relations</h4>
<ul id="inspector-relations" style="padding-left:20px; font-size:0.9em; line-height:1.4;"></ul>
</div>
<h4 style="margin:10px 0 5px 0;">Observations</h4>
<ul id="inspector-observations" style="padding-left:20px; font-size:0.9em; line-height:1.4;"></ul>
<h4 style="margin:15px 0 5px 0;">Relations</h4>
<ul id="inspector-relations" style="padding-left:20px; font-size:0.9em; line-height:1.4;"></ul>
</div>
</div>
</div>
<div class="right-column">
<div id="search-tab" class="tab-content">
<div class="panel search-panel">
<h2>Omni-Search</h2>
<input type="text" id="search-box" placeholder="Search memory (e.g., 'database')..." onkeyup="handleSearch(event)">
<div id="search-results"></div>
</div>
<div class="panel kanban-panel">
<h2>Task Board</h2>
<div class="kanban-board">
</div>
<div id="task-tab" class="tab-content">
<div class="panel kanban-panel" style="flex:1; display:flex; flex-direction:column;">
<div class="kanban-board" style="flex:1;">
<div class="kanban-column">
<h3>TODO / IN PROGRESS</h3>
<div class="kanban-items" id="tasks-active"></div>
@@ -454,14 +526,37 @@
</div>
</div>
</div>
</div>
<div class="panel activity-panel">
<h2>Live Activity Feed</h2>
<div id="activity-feed"></div>
<div id="sticky-tab" class="tab-content">
<div class="panel sticky-panel" style="flex:1; display:flex; flex-direction:column;">
<h2>Sticky Notes</h2>
<div id="sticky-notes-container" style="flex:1; overflow-y:auto; display:flex; flex-wrap:wrap; gap:15px; align-items:flex-start; align-content:flex-start;"></div>
</div>
</div>
<div id="activity-tab" class="tab-content">
<div class="panel activity-panel" style="flex:1; display:flex; flex-direction:column;">
<div id="activity-feed" style="flex:1;"></div>
</div>
</div>
</div>
<script>
// --- Tabs ---
function switchTab(tabId, btn) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab-button').forEach(el => el.classList.remove('active'));
document.getElementById(tabId).classList.add('active');
btn.classList.add('active');
if (tabId === 'graph-tab' && network) {
// Resize graph when becoming visible again
network.redraw();
network.fit();
}
}
// --- Theme Toggle ---
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme');
@@ -574,7 +669,7 @@
newNodes.push({
id: name,
label: name,
title: `<strong>${name}</strong><br/>${entity.entityType}`,
title: `${name}\nType: ${entity.entityType}`,
color: { background: color.bg || color.background, border: color.border },
font: { color: document.documentElement.getAttribute('data-theme') === 'dark' ? '#eee' : '#333' }
});
@@ -710,32 +805,68 @@
}
}
// --- SSE Activity Feed ---
function setupSSE() {
const es = new EventSource('/sse');
// --- WebSocket Activity Feed ---
function setupWS() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${location.host}/ws?client=ui`);
const feed = document.getElementById('activity-feed');
es.addEventListener('activity', function(event) {
if (event.data.startsWith('/messages?')) return;
const div = document.createElement('div');
div.className = 'feed-entry';
const time = new Date().toLocaleTimeString();
div.innerHTML = `<span class="time">[${time}]</span> ${event.data}`;
feed.appendChild(div);
// Auto-scroll logic
const isScrolledToBottom = feed.scrollHeight - feed.clientHeight <= feed.scrollTop + 20;
if (isScrolledToBottom) {
feed.scrollTop = feed.scrollHeight;
ws.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
if (data.type === 'activity') {
const div = document.createElement('div');
div.className = 'feed-entry';
const time = new Date().toLocaleTimeString();
div.innerHTML = `<span class="time">[${time}]</span> ${data.data}`;
feed.appendChild(div);
// Auto-scroll logic
const isScrolledToBottom = feed.scrollHeight - feed.clientHeight <= feed.scrollTop + 20;
if (isScrolledToBottom) {
feed.scrollTop = feed.scrollHeight;
}
}
} catch (e) {
// Ignore non-JSON or other messages for now
}
});
};
ws.onclose = function() {
console.log("WebSocket closed, attempting to reconnect in 3s...");
setTimeout(setupWS, 3000);
};
}
async function loadStickyNotes() {
try {
const res = await fetch('/api/sticky');
const sticky = await res.json();
const container = document.getElementById('sticky-notes-container');
container.innerHTML = '';
sticky.forEach(note => {
const card = document.createElement('div');
card.className = 'sticky-note';
const date = new Date(note.timestamp * 1000).toLocaleString();
card.innerHTML = `<div style="font-size:0.75em; color:#7f8c8d; margin-bottom:8px;">${date}</div>
<div>${note.content}</div>`;
container.appendChild(card);
});
} catch (err) {
console.error("Failed to load sticky notes", err);
}
}
// --- Start ---
loadGraph();
loadTasks();
setupSSE();
loadStickyNotes();
setupWS();
// Listen to theme toggle changes to update graph font colors
const observer = new MutationObserver(() => updateGraphData());
@@ -744,6 +875,7 @@
setInterval(() => {
loadGraph();
loadTasks();
loadStickyNotes();
}, 5000);
</script>
</body>
-44
View File
@@ -1,44 +0,0 @@
import re
def fix_handlers(filepath):
with open(filepath, "r") as f:
content = f.read()
# Find the positions of self.state.write_to_local_delta( and self.state.apply_sync_write(
patterns = ["self.state.write_to_local_delta(|", "self.state.apply_sync_write(|"]
# Actually, we can just find 'write_to_local_delta(|' and 'apply_sync_write(|'
for method in ["write_to_local_delta", "apply_sync_write"]:
start_idx = 0
while True:
idx = content.find(f"self.state.{method}(|", start_idx)
if idx == -1:
break
# Find the closing bracket that matches the first `{` after `|`
brace_idx = content.find("{", idx)
# count braces
brace_count = 1
curr_idx = brace_idx + 1
while brace_count > 0 and curr_idx < len(content):
if content[curr_idx] == '{':
brace_count += 1
elif content[curr_idx] == '}':
brace_count -= 1
curr_idx += 1
# curr_idx is now right after the matching '}'
# the original code was '});'
if content[curr_idx:curr_idx+2] == ");":
# we want to insert .await
content = content[:curr_idx+1] + ".await" + content[curr_idx+1:]
start_idx = curr_idx + 8 # move past `.await);`
else:
start_idx = curr_idx
with open(filepath, "w") as f:
f.write(content)
fix_handlers("C:\\Users\\reazul.ashraf\\workspace\\rust\\mcp-memory\\server\\src\\handlers.rs")
-78
View File
@@ -1,78 +0,0 @@
import re
descriptions = {
"create_entities": "Create new entities in the knowledge graph.",
"create_relations": "Create new relations between entities in the knowledge graph.",
"add_observations": "Add new observations to existing entities in the knowledge graph.",
"delete_entities": "Delete entities from the knowledge graph.",
"delete_observations": "Delete observations from existing entities.",
"delete_relations": "Delete relations between entities.",
"read_graph": "Read the entire knowledge graph.",
"search_nodes": "Search for entities in the knowledge graph by name or type.",
"open_nodes": "Open and retrieve full details of specific nodes in the knowledge graph.",
"log_code_change": "Log a significant code change or refactor in the memory system.",
"query_recent_changes": "Query recently logged code changes.",
"visualize_graph": "Generate a visual representation of the knowledge graph.",
"add_sticky_note": "Add a sticky note for unstructured thoughts or reminders.",
"read_sticky_notes": "Read all active sticky notes.",
"condense_entity": "Condense or summarize an entity's observations to reduce size.",
"add_task": "Add a new task to the task tracker.",
"update_task_status": "Update the status of an existing task.",
"list_active_tasks": "List all currently active tasks.",
"store_snippet": "Store a reusable code snippet.",
"search_snippets": "Search through stored code snippets.",
"delete_snippet": "Delete a stored code snippet.",
"log_decision": "Log an architectural decision record (ADR).",
"query_decisions": "Query architectural decision records.",
"merge_entities": "Merge two entities in the knowledge graph into one.",
"find_orphans": "Find orphaned entities (entities without any relations) in the graph.",
"learn_preference": "Record a user preference or behavior to adapt future interactions.",
"read_preferences": "Read all learned user preferences.",
"log_error_fix": "Log a complex error and its fix for future reference.",
"search_error_fixes": "Search through previously logged error fixes.",
"pin_file": "Pin a file to keep it explicitly in the context workspace.",
"unpin_file": "Unpin a file from the context workspace.",
"list_pinned_files": "List all currently pinned files.",
"add_session_summary": "Add a summary of the current session.",
"get_project_timeline": "Get a timeline of major project events.",
"leave_handoff_memo": "Leave a memo for the next session or agent.",
"read_handoff_memos": "Read pending handoff memos.",
"clear_handoff_memos": "Clear handoff memos after reading.",
"update_env_fingerprint": "Update the environment fingerprint (e.g., OS, tool versions).",
"read_env_fingerprint": "Read the current environment fingerprint.",
"log_env_requirement": "Log a required tool or package for the environment.",
"add_milestone": "Add a new project milestone.",
"update_milestone": "Update the status of a project milestone.",
"list_milestones": "List all project milestones.",
"generate_standup_report": "Generate a standup report based on recent activity.",
"register_environment": "Register details about a specific deployment environment.",
"get_environment_details": "Get details about a registered environment.",
"add_pr_checklist_item": "Add an item to the PR checklist.",
"get_pr_checklist": "Get the current PR checklist.",
"clear_pr_checklist": "Clear the PR checklist.",
"log_tech_debt": "Log identified technical debt.",
"resolve_tech_debt": "Mark a logged technical debt as resolved.",
"list_tech_debt": "List all unresolved technical debt.",
"save_context_workspace": "Save the current set of pinned files and context.",
"load_context_workspace": "Load a previously saved context workspace.",
"list_context_workspaces": "List all saved context workspaces.",
"omni_search": "Search across all memory sources (graph, tasks, snippets, ADRs, etc.) at once.",
"get_project_health": "Get a synthesized health report of the project based on memory data."
}
def fix_handlers(filepath):
with open(filepath, "r") as f:
content = f.read()
for tool_name, description in descriptions.items():
# Match crate::mcp::tool_def::<...>("tool_name", "")
# Note: sometimes it's on multiple lines for GenerateStandupReportTool etc.
# But most are single line.
pattern = r'(crate::mcp::tool_def::<[^>]+>\(\s*"' + tool_name + r'"\s*,\s*)""(\s*\))'
replacement = r'\1"' + description + r'"\2'
content = re.sub(pattern, replacement, content)
with open(filepath, "w") as f:
f.write(content)
fix_handlers("C:\\Users\\reazul.ashraf\\workspace\\rust\\mcp-memory\\server\\src\\handlers.rs")
-50
View File
@@ -1,50 +0,0 @@
import re
endpoints = """
.route("/api/graph", get({
let state_clone = app_state.handler.state.clone();
move || async move {
let graph = state_clone.get_full_graph();
axum::Json(graph)
}
}))
.route("/api/tasks", get({
let state_clone = app_state.handler.state.clone();
move || async move {
let tasks = state_clone.tasks.read();
axum::Json(tasks.clone())
}
}))
.route("/api/search", get({
let state_clone = app_state.handler.state.clone();
move |axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>| async move {
if let Some(q) = params.get("q") {
if let Ok(idx) = state_clone.search_index.read() {
if let Ok(results) = idx.search(q, None) {
let mut formatted_results = Vec::new();
for (type_name, content) in results {
formatted_results.push(serde_json::json!({
"type_name": type_name,
"content": content,
"score": 1.0
}));
}
return axum::Json(serde_json::json!({ "results": formatted_results }));
}
}
}
axum::Json(serde_json::json!({ "results": [] }))
}
}))
"""
filepath = "C:\\Users\\reazul.ashraf\\workspace\\rust\\mcp-memory\\server\\src\\main.rs"
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
# Replace the broken routes with the fixed ones
pattern = r'\.route\("/api/graph".*?\.route\("/api/stats"'
content = re.sub(pattern, endpoints.strip() + '\n .route("/api/stats"', content, flags=re.DOTALL)
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
+1130 -538
View File
File diff suppressed because it is too large. Load diff
+410 -357
View File
@@ -1,3 +1,5 @@
#![cfg_attr(not(target_os = "windows"), allow(dead_code, unused_imports, unreachable_code))]
mod handlers;
mod mcp;
mod models;
@@ -22,7 +24,7 @@ use clap::{Parser, Subcommand};
use std::collections::HashMap;
#[derive(Parser)]
#[command(author, version, about = "Antigravity MCP Memory Server", long_about = None)]
#[command(author, version = env!("APP_VERSION"), about = "Antigravity MCP Memory Server", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
@@ -81,15 +83,109 @@ enum GateCommands {
},
}
async fn garbage_collector_worker(state: Arc<MemoryState>) {
loop {
// Run every 6 hours
tokio::time::sleep(tokio::time::Duration::from_secs(6 * 3600)).await;
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
// 1. Task GC (14 days)
let fourteen_days = 14 * 24 * 3600;
let task_cutoff = now.saturating_sub(fourteen_days);
state.tasks.modify(|tasks| {
let initial_len = tasks.len();
tasks.retain(|task| !(task.status.to_lowercase() == "completed" && task.created_at < task_cutoff));
if tasks.len() < initial_len {
eprintln!("GC: Removed {} old completed tasks", initial_len - tasks.len());
}
});
// 2. Ledger GC (7 days or max 1000 items)
state.ledger.modify(|ledger| {
let seven_days = now.saturating_sub(7 * 24 * 3600);
ledger.retain(|c| c.timestamp >= seven_days);
if ledger.len() > 1000 {
let excess = ledger.len() - 1000;
ledger.drain(0..excess);
}
});
// 3. Sticky Notes GC (24 hours)
state.sticky.modify(|notes| {
notes.retain(|note| note.timestamp >= now.saturating_sub(24 * 3600));
});
}
}
async fn git_sync_worker(state: Arc<MemoryState>) {
let repo_path = std::env::current_dir().unwrap_or_else(|_| ".".into());
let mut last_commit_id = String::new();
loop {
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
let repo_path_clone = repo_path.clone();
let commit_data = tokio::task::spawn_blocking(move || {
if let Ok(repo) = git2::Repository::discover(&repo_path_clone) {
if let Ok(head) = repo.head() {
if let Ok(commit) = head.peel_to_commit() {
let current_id = commit.id().to_string();
let msg = commit.message().unwrap_or("").to_string();
let branch = head.shorthand().unwrap_or("unknown").to_string();
return Some((current_id, msg, branch));
}
}
}
None
})
.await
.unwrap_or(None);
if let Some((current_id, msg, branch)) = commit_data {
if current_id != last_commit_id && !last_commit_id.is_empty() {
state.ledger.modify(|changes| {
changes.push(crate::models::CodeChange {
git_commit: Some(current_id.clone()),
git_branch: Some(branch),
description: format!("Auto-synced commit: {}", msg.trim()),
timestamp: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(),
file_path: "".to_string(),
});
});
tracing::info!("Git Sync: Logged new commit {}", current_id);
state.tasks.modify(|tasks| {
for task in tasks.iter_mut() {
if task.status != "completed" && msg.to_lowercase().contains(&task.title.to_lowercase()) {
task.status = "completed".to_string();
tracing::info!("Git Sync: Auto-completed task '{}'", task.title);
}
}
});
}
last_commit_id = current_id;
}
}
}
async fn reconcile_worker(state: Arc<MemoryState>) {
loop {
sleep(Duration::from_secs(5)).await;
let pattern = format!("{}/delta_*.json", state.base_dir.display());
let has_local = {
let session = state.session_graph.read().unwrap();
!session.entities.is_empty() || !session.relations.is_empty()
};
let has_files = glob::glob(&pattern).map(|p| p.count() > 0).unwrap_or(false);
let base_dir = state.base_dir.clone();
let has_files = tokio::task::spawn_blocking(move || {
let pattern = format!("{}/delta_*.json", base_dir.display());
glob::glob(&pattern).map(|p| p.count() > 0).unwrap_or(false)
})
.await
.unwrap_or(false);
if has_local || has_files {
state.apply_sync_write(|_master| {}).await;
let state_clone = state.clone();
@@ -97,41 +193,22 @@ async fn reconcile_worker(state: Arc<MemoryState>) {
state_clone.rebuild_index();
}).await;
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
state.ledger.modify(|ledger| {
let seven_days = now.saturating_sub(7 * 24 * 60 * 60);
ledger.retain(|c| c.timestamp >= seven_days);
if ledger.len() > 1000 {
let excess = ledger.len() - 1000;
ledger.drain(0..excess);
}
});
state.sticky.modify(|notes| {
notes.retain(|note| note.timestamp >= now.saturating_sub(24 * 60 * 60));
});
}
}
use axum::{
Json, Router,
extract::{Query, State},
response::sse::{Event, Sse},
extract::{Query, State, ws::{WebSocketUpgrade, WebSocket, Message}},
response::IntoResponse,
routing::{get, post},
};
use futures_util::stream::Stream;
use std::convert::Infallible;
use futures_util::{SinkExt, StreamExt};
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
struct AppState {
handler: Arc<MemoryHandler>,
clients: RwLock<HashMap<String, mpsc::Sender<Result<Event, Infallible>>>>,
clients: RwLock<HashMap<String, mpsc::Sender<String>>>,
next_id: AtomicUsize,
}
@@ -237,7 +314,7 @@ fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>>
rt.block_on(async {
tokio::spawn(reconcile_worker(Arc::clone(&state)));
let app_state = Arc::new(AppState {
handler: Arc::new(MemoryHandler { state }),
handler: Arc::new(MemoryHandler { state: Arc::clone(&state) }),
clients: RwLock::new(HashMap::new()),
next_id: AtomicUsize::new(1),
});
@@ -245,13 +322,15 @@ fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>>
let app = Router::new()
.route("/api/version", get(|| async move {
axum::Json(serde_json::json!({
"version": env!("BUILD_DATE"),
"version": env!("APP_VERSION"),
"git_hash": option_env!("GIT_HASH").unwrap_or("unknown")
}))
}))
.route("/ws", get(ws_handler))
.route("/sse", get(sse_handler))
.route("/messages", post(message_handler))
.route("/health", get(health_handler))
.route("/nvim/telemetry", post(nvim_telemetry_handler))
.route("/gate/verify", get(gate_verify_handler))
.route("/gate/set", post(gate_set_handler))
.route(
@@ -276,7 +355,7 @@ fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>>
}
}))
.route("/api/tasks/:id/complete", post({
.route("/api/tasks/{id}/complete", post({
let state_clone = app_state.handler.state.clone();
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
state_clone.tasks.modify(|tasks| {
@@ -298,6 +377,13 @@ fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>>
axum::Json(tasks.clone())
}
}))
.route("/api/sticky", get({
let state_clone = app_state.handler.state.clone();
move || async move {
let sticky = state_clone.sticky.read();
axum::Json(sticky.clone())
}
}))
.route("/api/search", get({
let state_clone = app_state.handler.state.clone();
move |axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>| async move {
@@ -372,60 +458,19 @@ fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>>
)
.with_state(app_state);
let mut retries = 0;
let listener = loop {
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
),
);
std::process::exit(1);
}
let log_path = dirs::home_dir()
.unwrap_or_default()
.join(".gemini/mcp_memory/daemon_error.log");
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
use std::io::Write;
let _ = writeln!(
file,
"Failed to bind to 127.0.0.1:3000 (attempt {}): {}. Retrying in 1s...",
retries, e
);
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
let listener = match tokio::net::TcpListener::bind(std::env::var("MCP_PORT").map(|p| format!("127.0.0.1:{}", p)).unwrap_or_else(|_| std::env::var("MCP_PORT").map(|p| format!("127.0.0.1:{}", p)).unwrap_or_else(|_| "127.0.0.1:3000".to_string()).to_string())).await {
Ok(l) => l,
Err(e) => {
tracing::info!("Port 3000 is already in use ({}). Assuming server is already running and exiting gracefully.", e);
std::process::exit(0);
}
};
eprintln!("MCP Memory Server running on http://127.0.0.1:3000/sse");
if let Err(e) = axum::serve(listener, app).await {
tokio::spawn(garbage_collector_worker(Arc::clone(&state)));
tokio::spawn(git_sync_worker(Arc::clone(&state)));
tracing::info!("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");
@@ -435,87 +480,207 @@ fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>>
})
}
#[derive(serde::Deserialize)]
struct MsgQuery {
session_id: String,
}
async fn message_handler(
State(state): State<Arc<AppState>>,
Query(q): Query<MsgQuery>,
Json(payload): Json<serde_json::Value>,
) -> impl axum::response::IntoResponse {
let session_id = q.session_id;
if let Some(response) = state.handler.handle_request(payload).await {
let res_str = serde_json::to_string(&response).unwrap();
let tx_opt = state.clients.read().unwrap().get(&session_id).cloned();
if let Some(tx) = tx_opt {
let _ = tx.send(res_str).await;
}
}
(axum::http::StatusCode::ACCEPTED, "Accepted").into_response()
}
async fn sse_handler(
State(state): State<Arc<AppState>>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
) -> axum::response::sse::Sse<impl tokio_stream::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>> {
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::<String>(100);
state.clients.write().unwrap().insert(session_id.clone(), tx.clone());
state
.clients
.write()
.unwrap()
.insert(session_id.clone(), tx.clone());
let endpoint = format!("/messages?session_id={}", session_id);
let _ = tx.send(format!("endpoint|{}", endpoint)).await;
let _ = tx
.send(Ok(Event::default()
.event("endpoint")
.data(format!("/messages?sessionId={}", session_id))))
.await;
let rx_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
let event_stream = rx_stream.map(|msg| {
if let Some(ep) = msg.strip_prefix("endpoint|") {
Ok(axum::response::sse::Event::default().event("endpoint").data(ep))
} else {
Ok(axum::response::sse::Event::default().event("message").data(msg))
}
});
let stream = ReceiverStream::new(rx);
Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::new())
axum::response::sse::Sse::new(event_stream).keep_alive(axum::response::sse::KeepAlive::new())
}
async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
Query(query): Query<std::collections::HashMap<String, String>>,
) -> impl axum::response::IntoResponse {
let client_type = query.get("client").cloned().unwrap_or_else(|| "unknown".to_string());
ws.on_upgrade(move |socket| handle_socket(socket, state, client_type))
}
async fn handle_socket(socket: WebSocket, state: Arc<AppState>, client_type: String) {
let session_id = format!("{}", state.next_id.fetch_add(1, Ordering::SeqCst));
let (tx, mut rx) = mpsc::channel::<String>(100);
state.clients.write().unwrap().insert(session_id.clone(), tx.clone());
let (mut sender, mut receiver) = socket.split();
let mut send_task = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if sender.send(Message::Text(msg.into())).await.is_err() {
break;
}
}
});
if client_type == "proxy" {
let tx_clone = tx.clone();
tokio::spawn(async move {
let notify = serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed"
});
let _ = tx_clone.send(notify.to_string()).await;
});
}
let handler = Arc::clone(&state.handler);
let state_clone = Arc::clone(&state);
let session_id_clone = session_id.clone();
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(Message::Text(text))) = receiver.next().await {
if let Ok(payload) = serde_json::from_str::<serde_json::Value>(&text) {
if client_type == "proxy" {
// Send activity broadcast to UI clients
if let Some(method) = payload.get("method").and_then(|m| m.as_str()) {
if method == "tools/call" {
let name = payload.get("params").and_then(|p| p.get("name")).and_then(|n| n.as_str()).unwrap_or("unknown_tool");
let activity_msg = format!("Agent executed tool: {}", name);
let event = serde_json::json!({
"type": "activity",
"data": activity_msg
});
let clients_map = state_clone.clients.read().unwrap().clone();
for (id, client_tx) in clients_map.iter() {
if id != &session_id_clone {
let _ = client_tx.send(event.to_string()).await;
}
}
}
}
// Process MCP request
if let Some(response) = handler.handle_request(payload).await {
let res_str = serde_json::to_string(&response).unwrap();
let tx_opt = state_clone.clients.read().unwrap().get(&session_id_clone).cloned();
if let Some(client_tx) = tx_opt {
let _ = client_tx.send(res_str).await;
}
}
}
}
}
});
tokio::select! {
_ = (&mut send_task) => recv_task.abort(),
_ = (&mut recv_task) => send_task.abort(),
};
state.clients.write().unwrap().remove(&session_id);
}
#[derive(serde::Deserialize, serde::Serialize, Debug)]
pub struct NvimTelemetry {
pub session_id: String,
pub event: String,
pub file: Option<String>,
pub line: Option<i64>,
pub col: Option<i64>,
}
async fn nvim_telemetry_handler(
State(state): State<Arc<AppState>>,
axum::Json(payload): axum::Json<NvimTelemetry>,
) -> impl axum::response::IntoResponse {
// 1. Update active_nvim.txt if FocusGained, BufEnter, or VimEnter
if payload.event == "FocusGained" || payload.event == "BufEnter" || payload.event == "VimEnter" {
let profile = std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\reazul.ashraf".into());
let win_path = format!("{}\\.gemini\\active_nvim.txt", profile);
let _ = std::fs::write(&win_path, &payload.session_id);
let wsl_path = "\\\\wsl.localhost\\Ubuntu\\home\\riz\\.gemini\\active_nvim.txt";
let _ = std::fs::write(wsl_path, &payload.session_id);
}
// 2. Broadcast to UI WebSockets
let ws_msg = serde_json::json!({
"type": "nvim_telemetry",
"data": payload
});
let msg_str = ws_msg.to_string();
let clients = state.clients.read().unwrap().clone();
for (_, tx) in clients.iter() {
let _ = tx.send(msg_str.clone()).await;
}
axum::Json(serde_json::json!({"status": "ok"}))
}
async fn health_handler() -> &'static str {
"OK"
}
#[derive(serde::Deserialize)]
struct SessionQuery {
#[serde(rename = "sessionId")]
session_id: String,
}
async fn message_handler(
State(state): State<Arc<AppState>>,
Query(query): Query<SessionQuery>,
Json(payload): Json<serde_json::Value>,
) -> axum::http::StatusCode {
let handler = Arc::clone(&state.handler);
let session_id = query.session_id.clone();
let clients = Arc::clone(&state);
let activity_msg = if let Some(method) = payload.get("method").and_then(|m| m.as_str()) {
if method == "tools/call" {
let name = payload.get("params").and_then(|p| p.get("name")).and_then(|n| n.as_str()).unwrap_or("unknown_tool");
Some(format!("Agent executed tool: {}", name))
} else {
None
}
} else {
None
};
if let Some(msg) = activity_msg {
let clients_map = clients.clients.read().unwrap().clone();
for tx in clients_map.values() {
let _ = tx.send(Ok(Event::default().event("activity").data(msg.clone())));
}
}
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;
}
}
fn init_logging(app_name: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| {
dirs::home_dir()
.map(|mut h| {
h.push(".gemini/mcp_memory");
h.to_string_lossy().to_string()
})
.unwrap_or_else(|| ".gemini/mcp_memory".into())
});
axum::http::StatusCode::ACCEPTED
let log_dir = std::path::PathBuf::from(base_dir).join("logs");
std::fs::create_dir_all(&log_dir).unwrap_or_default();
let file_appender = tracing_appender::rolling::daily(log_dir, format!("{}.log", app_name));
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let _ = tracing_subscriber::fmt()
.with_writer(non_blocking)
.with_ansi(false)
.with_max_level(tracing::Level::INFO)
.try_init();
Some(guard)
}
mod proxy;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let _guard = init_logging("server");
let cli = Cli::parse();
if cli.exit {
if let Ok(mut stream) = std::net::TcpStream::connect("127.0.0.1:3000") {
if let Ok(mut stream) = std::net::TcpStream::connect(std::env::var("MCP_PORT").map(|p| format!("127.0.0.1:{}", p)).unwrap_or_else(|_| "127.0.0.1:3000".to_string())) {
use std::io::Write;
let _ = stream.write_all(
b"POST /shutdown HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
@@ -526,7 +691,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
if cli.restart {
if let Ok(mut stream) = std::net::TcpStream::connect("127.0.0.1:3000") {
if let Ok(mut stream) = std::net::TcpStream::connect(std::env::var("MCP_PORT").map(|p| format!("127.0.0.1:{}", p)).unwrap_or_else(|_| "127.0.0.1:3000".to_string())) {
use std::io::Write;
let _ = stream.write_all(
b"POST /shutdown HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
@@ -541,230 +706,118 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
{
use std::os::windows::process::CommandExt;
if !cli.daemon {
loop {
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) {
Ok(true) => {
std::thread::sleep(std::time::Duration::from_millis(50));
continue; // Leader died, race to bind 3000
// Just spawn the daemon and exit. We no longer act as a proxy.
#[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())
.stderr(std::process::Stdio::null())
.creation_flags(0x08000000) // CREATE_NO_WINDOW
.spawn()
.expect("Failed to spawn daemon");
return Ok(());
}
}
#[cfg(target_os = "windows")]
{
let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| {
dirs::home_dir()
.map(|mut h| {
h.push(".gemini/mcp_memory");
h.to_string_lossy().into_owned()
})
.unwrap_or_else(|| ".gemini/mcp_memory".into())
});
let base = PathBuf::from(base_dir);
fs::create_dir_all(&base).expect("Failed to create store dir");
let redb_path = base.join("mcp_store.redb");
let db = Arc::new(redb::Database::create(&redb_path).unwrap());
// Ensure table exists and migrate old JSON files
{
let write_txn = db.begin_write().unwrap();
{
let mut table = write_txn.open_table(crate::store::STORE_TABLE).unwrap();
let stores = [
("audit_ledger", "audit_ledger.json"),
("sticky_notes", "sticky_notes.json"),
("tasks", "tasks.json"),
("snippets", "snippets.json"),
("adrs", "adrs.json"),
("preferences", "preferences.json"),
("error_fixes", "error_fixes.json"),
("pinned_files", "pinned_files.json"),
("session_summaries", "session_summaries.json"),
("handoff_memos", "handoff_memos.json"),
("env_fingerprints", "env_fingerprints.json"),
("env_requirements", "env_requirements.json"),
("milestones", "milestones.json"),
("environments", "environments.json"),
("pr_checklists", "pr_checklists.json"),
("tech_debts", "tech_debts.json"),
("gates", "gates.json"),
("context_workspaces", "context_workspaces.json"),
];
for (key, file_name) in stores.iter() {
if table.get(*key).unwrap().is_none() {
let json_path = base.join(file_name);
if json_path.exists() {
if let Ok(data) = fs::read(&json_path) {
if serde_json::from_slice::<serde_json::Value>(&data).is_ok() {
table.insert(*key, data.as_slice()).unwrap();
}
}
}
Ok(false) => return Ok(()), // Stdin closed, user exited
Err(_) => std::thread::sleep(std::time::Duration::from_millis(1000)),
}
} else {
// Port is free. We must spawn the daemon, then loop again to become proxy
#[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())
.stderr(std::process::Stdio::null())
.creation_flags(0x08000000) // CREATE_NO_WINDOW
.spawn()
.expect("Failed to spawn daemon");
std::thread::sleep(std::time::Duration::from_millis(500));
}
}
write_txn.commit().unwrap();
}
let state = Arc::new(MemoryState {
master_path: base.join("knowledge_graph_master.json"),
session_graph: RwLock::new(KnowledgeGraph::default()),
base_dir: base.clone(),
master_cache: RwLock::new((KnowledgeGraph::default(), SystemTime::UNIX_EPOCH)),
search_index: RwLock::new(crate::search::MemoryIndex::new(&base).unwrap()),
ledger: Store::new("audit_ledger", db.clone()),
sticky: Store::new("sticky_notes", db.clone()),
tasks: Store::new("tasks", db.clone()),
snippets: Store::new("snippets", db.clone()),
adrs: Store::new("adrs", db.clone()),
prefs: Store::new("preferences", db.clone()),
error_fixes: Store::new("error_fixes", db.clone()),
pinned_files: Store::new("pinned_files", db.clone()),
session_summaries: Store::new("session_summaries", db.clone()),
handoff_memos: Store::new("handoff_memos", db.clone()),
env_fingerprints: Store::new("env_fingerprints", db.clone()),
env_requirements: Store::new("env_requirements", db.clone()),
milestones: Store::new("milestones", db.clone()),
environments: Store::new("environments", db.clone()),
pr_checklists: Store::new("pr_checklists", db.clone()),
tech_debts: Store::new("tech_debts", db.clone()),
gates: Store::new("gates", db.clone()),
context_workspaces: Store::new("context_workspaces", db.clone()),
});
state.recover_wal();
state.rebuild_index();
run_server(state)
}
#[cfg(not(target_os = "windows"))]
{
// This shouldn't be executed on linux natively anymore due to workspace split,
// but keeping it as a fallback.
let target_url = cli.target.as_deref().unwrap_or("http://127.0.0.1:3000");
let _ = proxy::run_proxy(target_url);
return Ok(());
// Linux no longer executes server logic natively due to workspace split
Ok(())
}
let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| {
dirs::home_dir()
.map(|mut h| {
h.push(".gemini/mcp_memory");
h.to_string_lossy().into_owned()
})
.unwrap_or_else(|| ".gemini/mcp_memory".into())
});
let base = PathBuf::from(base_dir);
fs::create_dir_all(&base).expect("Failed to create store dir");
let 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"),
];
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)
}
+3 -2
View File
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use schemars::JsonSchema;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CodeChange {
@@ -17,7 +18,7 @@ pub struct StickyNote {
pub fn default_namespace() -> String {
"global".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Entity {
pub name: String,
#[serde(rename = "entityType")]
@@ -29,7 +30,7 @@ pub struct Entity {
#[serde(default)]
pub git_branch: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema)]
pub struct Relation {
pub from: String,
pub to: String,
-121
View File
@@ -1,121 +0,0 @@
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();
rt.block_on(async {
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();
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());
buffer.clear();
}
let _ = shutdown_tx.blocking_send(());
});
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 {
while let Some(msg) = msg_rx.recv().await {
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;
}
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
}
});
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(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,
}
}
}
}
*post_url.write().await = String::new();
Ok(true)
}
Err(_) => Ok(true),
}
})
}
+10 -7
View File
@@ -49,7 +49,7 @@ impl MemoryIndex {
}
pub fn index_entity(&self, e: &Entity) -> tantivy::Result<()> {
let mut writer = self.writer.lock().unwrap();
let writer = self.writer.lock().unwrap();
writer.add_document(doc!(
self.id_field => e.name.clone(),
self.title_field => e.name.clone(),
@@ -57,12 +57,11 @@ impl MemoryIndex {
self.type_field => "entity",
self.namespace_field => e.namespace.clone()
))?;
writer.commit()?;
Ok(())
}
pub fn index_task(&self, t: &Task) -> tantivy::Result<()> {
let mut writer = self.writer.lock().unwrap();
let writer = self.writer.lock().unwrap();
writer.add_document(doc!(
self.id_field => t.id.clone(),
self.title_field => t.title.clone(),
@@ -70,6 +69,12 @@ impl MemoryIndex {
self.type_field => "task",
self.namespace_field => "global"
))?;
Ok(())
}
pub fn commit(&self) -> tantivy::Result<()> {
let mut writer = self.writer.lock().unwrap();
writer.commit()?;
Ok(())
}
@@ -117,7 +122,7 @@ impl MemoryIndex {
}
pub fn index_snippet(&self, s: &Snippet) -> tantivy::Result<()> {
let mut writer = self.writer.lock().unwrap();
let writer = self.writer.lock().unwrap();
writer.add_document(doc!(
self.id_field => s.name.clone(),
self.title_field => s.name.clone(),
@@ -125,12 +130,11 @@ impl MemoryIndex {
self.type_field => "snippet",
self.namespace_field => "global"
))?;
writer.commit()?;
Ok(())
}
pub fn index_adr(&self, a: &Adr) -> tantivy::Result<()> {
let mut writer = self.writer.lock().unwrap();
let writer = self.writer.lock().unwrap();
writer.add_document(doc!(
self.id_field => a.id.clone(),
self.title_field => a.title.clone(),
@@ -138,7 +142,6 @@ impl MemoryIndex {
self.type_field => "adr",
self.namespace_field => "global"
))?;
writer.commit()?;
Ok(())
}
}
+62 -33
View File
@@ -1,7 +1,7 @@
use crate::models::*;
use crate::search::MemoryIndex;
use crate::store::Store;
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::RwLock;
@@ -34,35 +34,53 @@ pub struct MemoryState {
}
impl MemoryState {
pub fn unique_items<T: Eq + std::hash::Hash + Clone>(input: Vec<T>) -> Vec<T> {
let mut keys = std::collections::HashSet::new();
input.into_iter().filter(|entry| keys.insert(entry.clone())).collect()
}
fn master_mtime(&self) -> SystemTime {
fs::metadata(&self.master_path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH)
}
pub fn unique_items<T: Eq + std::hash::Hash + Clone>(input: Vec<T>) -> Vec<T> {
let mut keys = HashSet::new();
let mut list = Vec::new();
for entry in input {
if keys.insert(entry.clone()) {
list.push(entry);
pub fn recover_wal(&self) {
let wal_path = self.base_dir.join("wal.jsonl");
if let Ok(content) = std::fs::read_to_string(&wal_path) {
let mut session = self.session_graph.write().unwrap();
for line in content.lines() {
if let Ok(d) = serde_json::from_str::<KnowledgeGraph>(line) {
Self::merge_graphs(&mut session, &d);
}
}
}
list
}
pub fn merge_graphs(dest: &mut KnowledgeGraph, src: &KnowledgeGraph) {
for (name, src_ent) in &src.entities {
let dest_ent = dest
.entities
.entry(name.clone())
.or_insert_with(|| src_ent.clone());
if dest_ent.name == src_ent.name {
dest_ent.observations.extend(src_ent.observations.clone());
dest_ent.observations = Self::unique_items(dest_ent.observations.clone());
.or_insert_with(|| crate::models::Entity {
name: src_ent.name.clone(),
entity_type: src_ent.entity_type.clone(),
observations: Vec::new(),
namespace: src_ent.namespace.clone(),
git_branch: src_ent.git_branch.clone(),
});
for obs in &src_ent.observations {
if !dest_ent.observations.contains(obs) {
dest_ent.observations.push(obs.clone());
}
}
}
for rel in &src.relations {
if !dest.relations.contains(rel) {
dest.relations.push(rel.clone());
}
}
dest.relations.extend(src.relations.clone());
dest.relations = Self::unique_items(dest.relations.clone());
}
pub fn read_master_cached(&self) -> KnowledgeGraph {
@@ -98,14 +116,6 @@ impl MemoryState {
pub fn get_full_graph(&self) -> KnowledgeGraph {
let mut master = self.read_master_cached();
let wal_path = self.base_dir.join("wal.jsonl");
if let Ok(content) = std::fs::read_to_string(&wal_path) {
for line in content.lines() {
if let Ok(d) = serde_json::from_str::<KnowledgeGraph>(line) {
Self::merge_graphs(&mut master, &d);
}
}
}
let session_graph = self.session_graph.read().unwrap();
Self::merge_graphs(&mut master, &session_graph);
master
@@ -182,24 +192,43 @@ impl MemoryState {
pub fn rebuild_index(&self) {
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);
let session_clone = { self.session_graph.read().unwrap().clone() };
let cache_clone = { self.master_cache.read().unwrap().0.clone() };
// Index entities that are only in master, or merge if they are in both
for (name, e) in &cache_clone.entities {
if let Some(session_e) = session_clone.entities.get(name) {
let mut merged_e = e.clone();
for obs in &session_e.observations {
if !merged_e.observations.contains(obs) {
merged_e.observations.push(obs.clone());
}
}
let _ = new_idx.index_entity(&merged_e);
} else {
let _ = new_idx.index_entity(e);
}
}
for t in self.tasks.read() {
// Index entities that are only in session
for (name, session_e) in &session_clone.entities {
if !cache_clone.entities.contains_key(name) {
let _ = new_idx.index_entity(session_e);
}
}
let tasks = self.tasks.read();
for t in tasks {
let _ = new_idx.index_task(&t);
}
for s in self.snippets.read() {
let snippets = self.snippets.read();
for s in snippets {
let _ = new_idx.index_snippet(&s);
}
for a in self.adrs.read() {
let adrs = self.adrs.read();
for a in adrs {
let _ = new_idx.index_adr(&a);
}
let _ = new_idx.commit();
if let Ok(mut w) = self.search_index.write() {
*w = new_idx;
}
+286 -46
View File
@@ -1,276 +1,516 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Create new entities in the knowledge graph.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct CreateEntitiesTool {
pub entities: Vec<serde_json::Value>,
/// Array of entities to create.
pub entities: Vec<crate::models::Entity>,
}
/// Create new relations between entities in the knowledge graph.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct CreateRelationsTool {
pub relations: Vec<serde_json::Value>,
/// Array of relations to create.
pub relations: Vec<crate::models::Relation>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ObservationInput {
#[serde(rename = "entityName")]
pub entity_name: String,
pub contents: Vec<String>,
}
/// Add new observations to existing entities in the knowledge graph.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct AddObservationsTool {
pub observations: Vec<serde_json::Value>,
/// Array of observations to add.
pub observations: Vec<ObservationInput>,
}
/// Delete entities from the knowledge graph.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct DeleteEntitiesTool {
/// Array of entity names to delete.
#[serde(rename = "entityNames")]
pub entity_names: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct DeleteObservationsTool {
pub deletions: Vec<serde_json::Value>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct DeleteRelationsTool {
pub relations: Vec<serde_json::Value>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ReadGraphTool {
pub namespace: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct SearchNodesTool {
pub query: String,
pub namespace: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct OpenNodesTool {
pub names: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct LogCodeChangeTool {
#[serde(rename = "filePath")]
pub file_path: String,
pub description: String,
pub git_commit: Option<String>,
pub git_branch: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct QueryRecentChangesTool {}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct VisualizeGraphTool {
pub query: Option<String>,
pub namespace: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct AddStickyNoteTool {
pub content: String,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ReadStickyNotesTool {}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct CondenseEntityTool {
pub struct DeleteObservationInput {
#[serde(rename = "entityName")]
pub entity_name: String,
pub observations: Vec<String>,
}
/// Delete observations from existing entities.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct DeleteObservationsTool {
/// Array of observation deletions.
pub deletions: Vec<DeleteObservationInput>,
}
/// Delete relations between entities.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct DeleteRelationsTool {
/// Array of relations to delete.
pub relations: Vec<crate::models::Relation>,
}
/// Read the entire knowledge graph.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ReadGraphTool {
/// Optional namespace to restrict the read to.
pub namespace: Option<String>,
}
/// Search for entities in the knowledge graph by name or type.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct SearchNodesTool {
/// The search query.
pub query: String,
/// Optional namespace to restrict the search to.
pub namespace: Option<String>,
}
/// Open and retrieve full details of specific nodes in the knowledge graph.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct OpenNodesTool {
/// Array of entity names to open.
pub names: Vec<String>,
}
/// Log a significant code change or refactor in the memory system.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct LogCodeChangeTool {
/// The path of the file that was changed.
#[serde(rename = "filePath")]
pub file_path: String,
/// A description of the change.
pub description: String,
/// The associated git commit hash, if any.
pub git_commit: Option<String>,
/// The associated git branch, if any.
pub git_branch: Option<String>,
}
/// Query recently logged code changes.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct QueryRecentChangesTool {}
/// Generate a visual representation of the knowledge graph.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct VisualizeGraphTool {
/// Optional search query to filter the graph before visualization.
pub query: Option<String>,
/// Optional namespace to restrict the visualization to.
pub namespace: Option<String>,
}
/// Add a sticky note for unstructured thoughts or reminders.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct AddStickyNoteTool {
/// The content of the sticky note.
pub content: String,
}
/// Read all active sticky notes.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ReadStickyNotesTool {}
/// Condense or summarize an entity's observations to reduce size.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct CondenseEntityTool {
/// The name of the entity to condense.
#[serde(rename = "entityName")]
pub entity_name: String,
/// The condensed observations that will replace the existing ones.
pub summarized_observations: Vec<String>,
}
/// Add a new task to the task tracker.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct AddTaskTool {
/// The title of the task.
pub title: String,
/// A detailed description of the task.
pub description: String,
/// The associated git branch, if any.
pub git_branch: Option<String>,
}
/// Update the status of an existing task.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct UpdateTaskStatusTool {
/// The ID of the task to update.
pub id: String,
/// The new status of the task (e.g., 'pending' or 'completed').
#[schemars(description = "Must be 'pending' or 'completed'")]
pub status: String,
}
/// List all currently active tasks.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ListActiveTasksTool {
/// Optional git branch to filter tasks by.
pub git_branch: Option<String>,
}
/// Store a reusable code snippet.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct StoreSnippetTool {
/// The name of the snippet.
pub name: String,
/// The programming language of the snippet.
pub language: String,
/// The code snippet content.
pub code: String,
/// A description of what the snippet does.
pub description: String,
}
/// Search through stored code snippets.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct SearchSnippetsTool {
/// The search query.
pub query: String,
}
/// Delete a stored code snippet.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct DeleteSnippetTool {
/// The name of the snippet to delete.
pub name: String,
}
/// Log an architectural decision record (ADR).
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct LogDecisionTool {
/// The title of the decision.
pub title: String,
/// The context or problem requiring a decision.
pub context: String,
/// The decision made.
pub decision: String,
/// The consequence of the decision.
pub consequence: String,
}
/// Query architectural decision records.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct QueryDecisionsTool {
/// Optional search query.
pub query: Option<String>,
}
/// Merge two entities in the knowledge graph into one.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct MergeEntitiesTool {
/// The name of the entity to merge from (will be deleted).
#[serde(rename = "sourceEntity")]
pub source_entity: String,
/// The name of the entity to merge into.
#[serde(rename = "targetEntity")]
pub target_entity: String,
}
/// Find orphaned entities (entities without any relations) in the graph.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct FindOrphansTool {}
/// Record a user preference or behavior to adapt future interactions.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct LearnPreferenceTool {
/// The key for the preference.
pub key: String,
/// The value of the preference.
pub value: String,
}
/// Read all learned user preferences.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ReadPreferencesTool {}
/// Log a complex error and its fix for future reference.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct LogErrorFixTool {
/// The error signature or stack trace.
pub signature: String,
/// The solution applied to fix the error.
pub solution: String,
/// The associated git commit hash, if any.
pub git_commit: Option<String>,
/// The associated git branch, if any.
pub git_branch: Option<String>,
}
/// Search through previously logged error fixes.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct SearchErrorFixesTool {
/// The search query.
pub query: String,
}
/// Pin a file to keep it explicitly in the context workspace.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct PinFileTool {
/// The namespace to pin the file in.
pub namespace: String,
/// The absolute path of the file to pin.
pub file_path: String,
/// The associated git branch, if any.
pub git_branch: Option<String>,
}
/// Unpin a file from the context workspace.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct UnpinFileTool {
/// The namespace the file is pinned in.
pub namespace: String,
/// The absolute path of the file to unpin.
pub file_path: String,
}
/// List all currently pinned files.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ListPinnedFilesTool {
/// Optional namespace to filter by.
pub namespace: Option<String>,
/// Optional git branch to filter by.
pub git_branch: Option<String>,
}
/// Add a summary of the current session.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct AddSessionSummaryTool {
/// The summary content.
pub summary: String,
/// The namespace to add the summary to.
pub namespace: String,
}
/// Get a timeline of major project events.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct GetProjectTimelineTool {
/// Optional namespace to restrict the timeline to.
pub namespace: Option<String>,
}
/// Leave a memo for the next session or agent.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct LeaveHandoffMemoTool {
/// The content of the memo.
pub content: String,
/// The namespace to leave the memo in.
pub namespace: String,
}
/// Read pending handoff memos.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ReadHandoffMemosTool {
/// Optional namespace to restrict the read to.
pub namespace: Option<String>,
}
/// Clear handoff memos after reading.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ClearHandoffMemosTool {
/// Array of memo IDs to clear.
pub ids: Vec<String>,
}
/// Update the environment fingerprint (e.g., OS, tool versions).
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct UpdateEnvFingerprintTool {
/// The namespace to update the fingerprint for.
pub namespace: String,
/// A map of tool names to their versions.
pub tool_versions: std::collections::HashMap<String, String>,
}
/// Read the environment fingerprint.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ReadEnvFingerprintTool {
/// The namespace to read the fingerprint for.
pub namespace: String,
}
/// Log a required environment variable or configuration.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct LogEnvRequirementTool {
/// The namespace.
pub namespace: String,
/// The environment variable key (e.g., DATABASE_URL).
pub key: String,
/// A description of what the variable is used for.
pub description: String,
/// Whether the variable contains a secret.
pub is_secret: bool,
}
/// Add a new milestone.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct AddMilestoneTool {
/// The title of the milestone.
pub title: String,
/// The namespace for the milestone.
pub namespace: String,
}
/// Update the status of a milestone.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct UpdateMilestoneTool {
/// The ID of the milestone to update.
pub id: String,
/// The new status (e.g., 'active', 'completed').
pub status: String,
}
/// List all milestones.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ListMilestonesTool {
/// Optional namespace to restrict the list to.
pub namespace: Option<String>,
}
/// Generate a standup report for a specific time window.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct GenerateStandupReportTool {
/// The namespace to generate the report for.
pub namespace: String,
/// The number of hours to look back for activity.
pub hours_lookback: u64,
}
/// Register a new infrastructure environment.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct RegisterEnvironmentTool {
/// The namespace.
pub namespace: String,
/// The name of the environment (e.g., 'staging', 'prod').
pub name: String,
/// The URL or connection string for the environment.
pub url: String,
/// A description of the environment.
pub description: String,
/// Whether a VPN is required to access the environment.
pub requires_vpn: bool,
}
/// Get details about a registered environment.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct GetEnvironmentDetailsTool {
/// The namespace to retrieve details for.
pub namespace: String,
}
/// Add an item to the PR checklist.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct AddPrChecklistItemTool {
/// The namespace.
pub namespace: String,
/// The description of the checklist item.
pub description: String,
}
/// Get the PR checklist.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct GetPrChecklistTool {
/// The namespace.
pub namespace: String,
}
/// Clear the PR checklist.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ClearPrChecklistTool {
/// The namespace.
pub namespace: String,
}
/// Log a technical debt record.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct LogTechDebtTool {
/// The namespace.
pub namespace: String,
/// A description of the technical debt.
pub description: String,
/// The ideal solution to resolve the debt.
pub ideal_solution: String,
/// The associated git commit hash, if any.
pub git_commit: Option<String>,
/// The associated git branch, if any.
pub git_branch: Option<String>,
}
/// Resolve a technical debt record.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ResolveTechDebtTool {
/// The ID of the technical debt record to resolve.
pub id: String,
}
/// List technical debt records.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ListTechDebtTool {
/// The namespace.
pub namespace: String,
/// Whether to include resolved technical debt in the results.
pub include_resolved: bool,
}
/// Save the current context workspace.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct SaveContextWorkspaceTool {
/// The namespace for the workspace.
pub namespace: String,
/// The name to save the workspace as.
pub name: String,
/// Array of pinned file paths.
pub pinned_files: Vec<String>,
/// Array of active task IDs.
pub active_task_ids: Vec<String>,
}
/// Load a saved context workspace.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct LoadContextWorkspaceTool {
/// The namespace of the workspace.
pub namespace: String,
/// The name of the workspace to load.
pub name: String,
}
/// List all saved context workspaces.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ListContextWorkspacesTool {
/// The namespace to list workspaces for.
pub namespace: String,
}
/// Search across all memory stores (Graph, Tasks, Snippets, ADRs, etc.).
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct OmniSearchTool {
/// The search query.
pub query: String,
/// Optional namespace to restrict the search to.
pub namespace: Option<String>,
}
/// Get a health digest of the project.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct GetProjectHealthTool {
/// The namespace to get health for.
pub namespace: String,
}
/// Traverse the knowledge graph to find a path between two entities.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct QueryGraphPathTool {
/// The starting entity name.
pub start_node: String,
/// The ending entity name.
pub end_node: String,
/// Optional maximum depth to search.
pub max_depth: Option<u32>,
}
+823
View File
@@ -0,0 +1,823 @@
mod handlers;
mod mcp;
mod models;
mod search;
mod state;
mod store;
mod tools;
use crate::handlers::MemoryHandler;
use crate::models::*;
use crate::state::MemoryState;
use crate::store::Store;
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 = "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,
},
}
#[derive(Subcommand)]
enum GateCommands {
Set {
#[arg(long)]
action: String,
#[arg(long)]
target: String,
#[arg(long)]
namespace: Option<String>,
#[arg(short = 'p', long = "param")]
params: Vec<String>,
#[arg(long, conflicts_with = "block")]
authorize: bool,
#[arg(long, conflicts_with = "authorize")]
block: bool,
#[arg(long)]
reason: Option<String>,
},
Verify {
#[arg(long)]
action: String,
#[arg(long)]
target: String,
#[arg(long)]
namespace: Option<String>,
#[arg(short = 'p', long = "param")]
params: Vec<String>,
#[arg(long)]
consume: bool,
},
}
async fn reconcile_worker(state: Arc<MemoryState>) {
loop {
sleep(Duration::from_secs(5)).await;
let pattern = format!("{}/delta_*.json", state.base_dir.display());
let has_local = {
let session = state.session_graph.read().unwrap();
!session.entities.is_empty() || !session.relations.is_empty()
};
let has_files = glob::glob(&pattern).map(|p| p.count() > 0).unwrap_or(false);
if has_local || has_files {
state.apply_sync_write(|_master| {}).await;
let state_clone = state.clone();
let _ = tokio::task::spawn_blocking(move || {
state_clone.rebuild_index();
}).await;
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
state.ledger.modify(|ledger| {
let seven_days = now.saturating_sub(7 * 24 * 60 * 60);
ledger.retain(|c| c.timestamp >= seven_days);
if ledger.len() > 1000 {
let excess = ledger.len() - 1000;
ledger.drain(0..excess);
}
});
state.sticky.modify(|notes| {
notes.retain(|note| note.timestamp >= now.saturating_sub(24 * 60 * 60));
});
}
}
use axum::{
Json, Router,
extract::{Query, State, ws::{WebSocketUpgrade, WebSocket, Message}},
response::IntoResponse,
routing::{get, post},
};
use futures_util::{SinkExt, StreamExt};
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::mpsc;
struct AppState {
handler: Arc<MemoryHandler>,
clients: RwLock<HashMap<String, mpsc::Sender<String>>>,
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 {
tokio::spawn(reconcile_worker(Arc::clone(&state)));
let app_state = Arc::new(AppState {
handler: Arc::new(MemoryHandler { state: Arc::clone(&state) }),
clients: RwLock::new(HashMap::new()),
next_id: AtomicUsize::new(1),
});
let app = Router::new()
.route("/api/version", get(|| async move {
axum::Json(serde_json::json!({
"version": env!("BUILD_DATE"),
"git_hash": option_env!("GIT_HASH").unwrap_or("unknown")
}))
}))
.route("/ws", get(ws_handler))
.route("/health", get(health_handler))
.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/graph", get({
let state_clone = app_state.handler.state.clone();
move || async move {
let graph = state_clone.get_full_graph();
axum::Json(graph)
}
}))
.route("/api/tasks/{id}/complete", post({
let state_clone = app_state.handler.state.clone();
move |axum::extract::Path(id): axum::extract::Path<String>| async move {
state_clone.tasks.modify(|tasks| {
for t in tasks.iter_mut() {
if t.id == id {
t.status = "completed".to_string();
break;
}
}
});
axum::Json(serde_json::json!({"status": "success"}))
}
}))
.route("/api/tasks", get({
let state_clone = app_state.handler.state.clone();
move || async move {
let tasks = state_clone.tasks.read();
axum::Json(tasks.clone())
}
}))
.route("/api/search", get({
let state_clone = app_state.handler.state.clone();
move |axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>| async move {
if let Some(q) = params.get("q") {
if let Ok(idx) = state_clone.search_index.read() {
if let Ok(results) = idx.search(q, None) {
let mut formatted_results = Vec::new();
for (type_name, content) in results {
formatted_results.push(serde_json::json!({
"type_name": type_name,
"content": content,
"score": 1.0
}));
}
return axum::Json(serde_json::json!({ "results": formatted_results }));
}
}
}
axum::Json(serde_json::json!({ "results": [] }))
}
}))
.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();
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;
let listener = loop {
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
),
);
std::process::exit(1);
}
let log_path = dirs::home_dir()
.unwrap_or_default()
.join(".gemini/mcp_memory/daemon_error.log");
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
use std::io::Write;
let _ = writeln!(
file,
"Failed to bind to 127.0.0.1:3000 (attempt {}): {}. Retrying in 1s...",
retries, e
);
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
};
// Background Garbage Collection for old tasks
let state_gc = Arc::clone(&state);
tokio::spawn(async move {
loop {
// Run every 24 hours
tokio::time::sleep(tokio::time::Duration::from_secs(24 * 3600)).await;
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let fourteen_days = 14 * 24 * 3600;
let cutoff = now.saturating_sub(fourteen_days);
state_gc.tasks.modify(|tasks| {
let initial_len = tasks.len();
tasks.retain(|task| {
if task.status.to_lowercase() == "completed" && task.created_at < cutoff {
false // remove
} else {
true // keep
}
});
if tasks.len() < initial_len {
eprintln!("GC: Removed {} old completed tasks", initial_len - tasks.len());
}
});
}
});
// Git Native Sync Background Task
let state_git = Arc::clone(&state);
tokio::spawn(async move {
let repo_path = std::env::current_dir().unwrap_or_else(|_| ".".into());
let mut last_commit_id = String::new();
loop {
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
if let Ok(repo) = git2::Repository::discover(&repo_path) {
if let Ok(head) = repo.head() {
if let Ok(commit) = head.peel_to_commit() {
let current_id = commit.id().to_string();
if current_id != last_commit_id && !last_commit_id.is_empty() {
let msg = commit.message().unwrap_or("").to_string();
let branch = head.shorthand().unwrap_or("unknown").to_string();
state_git.ledger.modify(|changes| {
changes.push(crate::models::CodeChange {
git_commit: Some(current_id.clone()),
git_branch: Some(branch),
description: format!("Auto-synced commit: {}", msg.trim()),
timestamp: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(),
file_path: "".to_string(),
});
});
eprintln!("Git Sync: Logged new commit {}", current_id);
state_git.tasks.modify(|tasks| {
for task in tasks.iter_mut() {
if task.status != "completed" && msg.to_lowercase().contains(&task.title.to_lowercase()) {
task.status = "completed".to_string();
eprintln!("Git Sync: Auto-completed task '{}'", task.title);
}
}
});
}
last_commit_id = current_id;
}
}
}
}
});
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 _ = std::fs::write(&log_path, format!("Server crashed: {}\n", e));
}
Ok(())
})
}
async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
Query(query): Query<std::collections::HashMap<String, String>>,
) -> impl axum::response::IntoResponse {
let client_type = query.get("client").cloned().unwrap_or_else(|| "unknown".to_string());
ws.on_upgrade(move |socket| handle_socket(socket, state, client_type))
}
async fn handle_socket(socket: WebSocket, state: Arc<AppState>, client_type: String) {
let session_id = format!("{}", state.next_id.fetch_add(1, Ordering::SeqCst));
let (tx, mut rx) = mpsc::channel::<String>(100);
state.clients.write().unwrap().insert(session_id.clone(), tx.clone());
let (mut sender, mut receiver) = socket.split();
let mut send_task = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if sender.send(Message::Text(msg.into())).await.is_err() {
break;
}
}
});
let handler = Arc::clone(&state.handler);
let state_clone = Arc::clone(&state);
let session_id_clone = session_id.clone();
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(Message::Text(text))) = receiver.next().await {
if let Ok(payload) = serde_json::from_str::<serde_json::Value>(&text) {
if client_type == "proxy" {
// Send activity broadcast to UI clients
if let Some(method) = payload.get("method").and_then(|m| m.as_str()) {
if method == "tools/call" {
let name = payload.get("params").and_then(|p| p.get("name")).and_then(|n| n.as_str()).unwrap_or("unknown_tool");
let activity_msg = format!("Agent executed tool: {}", name);
let event = serde_json::json!({
"type": "activity",
"data": activity_msg
});
let clients_map = state_clone.clients.read().unwrap().clone();
for (id, client_tx) in clients_map.iter() {
if id != &session_id_clone {
let _ = client_tx.send(event.to_string()).await;
}
}
}
}
// Process MCP request
if let Some(response) = handler.handle_request(payload).await {
let res_str = serde_json::to_string(&response).unwrap();
let tx_opt = state_clone.clients.read().unwrap().get(&session_id_clone).cloned();
if let Some(client_tx) = tx_opt {
let _ = client_tx.send(res_str).await;
}
}
}
}
}
});
tokio::select! {
_ = (&mut send_task) => recv_task.abort(),
_ = (&mut recv_task) => send_task.abort(),
};
state.clients.write().unwrap().remove(&session_id);
}
async fn health_handler() -> &'static str {
"OK"
}
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 {
// Just spawn the daemon and exit. We no longer act as a proxy.
#[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())
.stderr(std::process::Stdio::null())
.creation_flags(0x08000000) // CREATE_NO_WINDOW
.spawn()
.expect("Failed to spawn daemon");
return Ok(());
}
}
#[cfg(not(target_os = "windows"))]
{
// Linux no longer executes server logic natively due to workspace split
return Ok(());
}
let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| {
dirs::home_dir()
.map(|mut h| {
h.push(".gemini/mcp_memory");
h.to_string_lossy().into_owned()
})
.unwrap_or_else(|| ".gemini/mcp_memory".into())
});
let base = PathBuf::from(base_dir);
fs::create_dir_all(&base).expect("Failed to create store dir");
let 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"),
];
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)
}
+8
View File
@@ -9,3 +9,11 @@ reqwest = { version = "0.12", default-features = false, features = ["stream", "r
tokio = { version = "1.53.1", features = ["full"] }
tokio-util = { version = "0.7.19", features = ["io"] }
futures-util = "0.3.34"
tokio-tungstenite = "0.21.0"
tracing-appender = "0.2.5"
tracing = "0.1.44"
tracing-subscriber = "0.3.23"
dirs = "7.0.0"
[dev-dependencies]
serde_json = "1.0.151"
+20
View File
@@ -0,0 +1,20 @@
use std::process::Command;
fn main() {
let git_hash = Command::new("git")
.args(&["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string());
let git_date = Command::new("git")
.args(&["log", "-1", "--format=%cd", "--date=format:%Y.%m.%d"])
.output()
.ok()
.and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string());
let version = format!("{} ({} {})", env!("CARGO_PKG_VERSION"), git_date.trim(), git_hash.trim());
println!("cargo:rustc-env=APP_VERSION={}", version);
}
+117 -103
View File
@@ -1,13 +1,11 @@
use clap::Parser;
use futures_util::StreamExt;
use std::sync::Arc;
use clap::Parser;
use futures_util::{SinkExt, StreamExt};
use tokio::io::AsyncBufReadExt;
use tokio::sync::RwLock;
use tokio::sync::mpsc;
use tokio_util::io::StreamReader;
#[derive(Parser)]
#[command(name = "mcp-memory-stub", author, version, about = "Antigravity MCP Memory Stub / Proxy", long_about = None)]
#[command(name = "mcp-memory-stub", author, version = env!("APP_VERSION"), about = "Antigravity MCP Memory Stub / Proxy", long_about = None)]
struct Cli {
/// Target URL for the stub to proxy messages to
#[arg(long, default_value = "http://localhost:3000")]
@@ -17,128 +15,146 @@ struct Cli {
wake_cmd: Option<String>,
}
async fn read_mcp_message(stdin: &mut tokio::io::BufReader<tokio::io::Stdin>) -> Option<String> {
use tokio::io::AsyncReadExt;
let mut length = 0;
loop {
let mut line = String::new();
let bytes_read = stdin.read_line(&mut line).await.unwrap_or(0);
if bytes_read == 0 {
tracing::info!("stdin EOF reached");
return None;
}
tracing::info!("Read {} bytes from stdin: {:?}", bytes_read, line);
let line = line.trim_end();
if line.is_empty() {
break;
}
let lower_line = line.to_lowercase();
if let Some(len_str) = lower_line.strip_prefix("content-length:") {
length = len_str.trim().parse().unwrap_or(0);
}
}
if length == 0 {
return None;
}
let mut buffer = vec![0; length];
if stdin.read_exact(&mut buffer).await.is_err() {
return None;
}
String::from_utf8(buffer).ok()
}
fn init_logging(app_name: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| {
dirs::home_dir()
.map(|mut h| {
h.push(".gemini/mcp_memory");
h.to_string_lossy().to_string()
})
.unwrap_or_else(|| ".gemini/mcp_memory".into())
});
let log_dir = std::path::PathBuf::from(base_dir).join("logs");
std::fs::create_dir_all(&log_dir).unwrap_or_default();
let file_appender = tracing_appender::rolling::daily(log_dir, format!("{}.log", app_name));
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let _ = tracing_subscriber::fmt()
.with_writer(non_blocking)
.with_ansi(false)
.with_max_level(tracing::Level::INFO)
.try_init();
Some(guard)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let _guard = init_logging("stub");
let cli = Cli::parse();
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let (msg_tx, mut msg_rx) = mpsc::channel::<String>(100);
let (msg_tx, msg_rx) = mpsc::channel::<String>(100);
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
tokio::spawn(async move {
let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
let mut buffer = String::new();
while let Ok(bytes) = stdin.read_line(&mut buffer).await {
if bytes == 0 {
break;
}
let _ = msg_tx.send(buffer.clone()).await;
buffer.clear();
while let Some(msg) = read_mcp_message(&mut stdin).await {
let _ = msg_tx.send(msg).await;
}
let _ = shutdown_tx.send(()).await;
});
let target_url = cli.target;
let post_url = Arc::new(RwLock::new(String::new()));
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 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...");
}
}
});
}
});
let ws_url = target_url.replace("http://", "ws://").replace("https://", "wss://");
let ws_url = format!("{}/ws?client=proxy", ws_url);
let msg_rx = Arc::new(tokio::sync::Mutex::new(msg_rx));
let wake_cmd = cli.wake_cmd;
loop {
if shutdown_rx.try_recv().is_ok() {
tracing::info!("Stub shutdown requested");
break;
}
let sse_url = format!("{}/sse", target_url);
let client = reqwest::Client::builder().build()?;
tracing::info!("Attempting to connect to {}", ws_url);
match tokio_tungstenite::connect_async(&ws_url).await {
Ok((ws_stream, _)) => {
tracing::info!("Successfully connected to target server");
let (mut write, mut read) = ws_stream.split();
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(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(()); // 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();
let msg_rx_clone = Arc::clone(&msg_rx);
let mut send_task = tokio::spawn(async move {
loop {
let mut rx = msg_rx_clone.lock().await;
match rx.recv().await {
Some(msg) => {
drop(rx);
tracing::info!("Forwarding message to target server");
if write.send(tokio_tungstenite::tungstenite::Message::Text(msg)).await.is_err() {
tracing::error!("Failed to write to websocket");
break;
}
Err(_) => break,
}
},
None => break,
}
}
});
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(msg)) = read.next().await {
if let tokio_tungstenite::tungstenite::Message::Text(text) = msg {
tracing::info!("Received message from target server, proxying to stdout");
let payload = format!("Content-Length: {}\r\n\r\n{}", text.len(), text);
use std::io::Write;
let mut stdout = std::io::stdout();
let _ = stdout.write_all(payload.as_bytes());
let _ = stdout.flush();
}
}
tracing::error!("Websocket read loop exited");
});
tokio::select! {
_ = shutdown_rx.recv() => {
tracing::info!("Shutdown received while connected");
return Ok(()); // Stdin closed, exit entirely
}
_ = &mut send_task => {
tracing::error!("Send task exited");
recv_task.abort();
}
_ = &mut recv_task => {
tracing::error!("Recv task exited");
send_task.abort();
}
}
*post_url.write().await = String::new();
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
}
Err(_) => {
Err(e) => {
tracing::error!("Failed to connect to target server: {}", e);
if let Some(ref cmd) = wake_cmd {
tracing::info!("Executing wake command: {}", cmd);
let parts: Vec<&str> = cmd.split_whitespace().collect();
if !parts.is_empty() {
let _ = std::process::Command::new(parts[0])
@@ -153,5 +169,3 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
})
}
+153
View File
@@ -0,0 +1,153 @@
use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Command, Stdio};
use std::time::Duration;
fn send_message(stdin: &mut std::process::ChildStdin, msg: Value) {
let s = serde_json::to_string(&msg).unwrap();
let payload = format!("Content-Length: {}\r\n\r\n{}", s.len(), s);
stdin.write_all(payload.as_bytes()).unwrap();
stdin.flush().unwrap();
}
fn read_message(stdout: &mut std::process::ChildStdout) -> Option<Value> {
let mut reader = BufReader::new(stdout);
let mut length = 0;
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
return None;
}
let line = line.trim_end();
if line.is_empty() {
break;
}
if let Some(len_str) = line.strip_prefix("Content-Length: ") {
length = len_str.parse().unwrap_or(0);
}
}
if length == 0 {
return None;
}
let mut buf = vec![0u8; length];
reader.read_exact(&mut buf).unwrap();
let body_str = String::from_utf8_lossy(&buf);
Some(serde_json::from_str(&body_str).unwrap())
}
#[tokio::test]
async fn test_full_system_e2e_performance() {
let temp_dir = std::env::temp_dir().join(format!("mcp_e2e_{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()));
std::fs::create_dir_all(&temp_dir).unwrap();
let test_port = "3031"; // Use a distinct port
let mut exe_dir = std::env::current_exe().unwrap();
exe_dir.pop(); // pop test executable name
exe_dir.pop(); // pop deps/
let mut server_exe = exe_dir.join("mcp-memory-server.exe");
if !server_exe.exists() {
let mut target_dir = std::env::current_dir().unwrap();
if target_dir.ends_with("stub") {
target_dir.pop();
}
server_exe = target_dir.join("target").join("debug").join("mcp-memory-server.exe");
}
let mut nvim_exe = exe_dir.join("mcp-memory-win-nvim.exe");
if !nvim_exe.exists() {
let mut target_dir = std::env::current_dir().unwrap();
if target_dir.ends_with("stub") {
target_dir.pop();
}
nvim_exe = target_dir.join("target").join("debug").join("mcp-memory-win-nvim.exe");
}
// 1. Start Server
let mut server = Command::new(&server_exe)
.env("MCP_PORT", test_port)
.env("MCP_MEMORY_STORE_DIR", temp_dir.to_str().unwrap())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("Failed to start server");
tokio::time::sleep(Duration::from_secs(2)).await;
// 2. Start Stub
let stub_exe = env!("CARGO_BIN_EXE_mcp-memory-stub");
let mut stub = Command::new(stub_exe)
.arg("--target")
.arg(format!("http://127.0.0.1:{}", test_port))
.env("MCP_MEMORY_STORE_DIR", temp_dir.to_str().unwrap())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("Failed to start stub");
let mut stub_stdin = stub.stdin.take().unwrap();
let mut stub_stdout = stub.stdout.take().unwrap();
// 3. Start Win-Nvim
let mut win_nvim = Command::new(&nvim_exe)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("Failed to start win-nvim");
let mut nvim_stdin = win_nvim.stdin.take().unwrap();
let mut nvim_stdout = win_nvim.stdout.take().unwrap();
// Send 100 concurrent-like sequential rapid requests to Stub
let start_time = std::time::Instant::now();
for i in 1..=100 {
let tools_req = json!({
"jsonrpc": "2.0",
"method": "tools/list",
"params": {},
"id": i
});
send_message(&mut stub_stdin, tools_req);
let resp = read_message(&mut stub_stdout).expect("Failed to read rapid response from stub");
assert_eq!(resp["id"], i);
}
let stub_duration = start_time.elapsed();
// Send 100 concurrent-like sequential rapid requests to Win-Nvim
let start_time_nvim = std::time::Instant::now();
for i in 1..=100 {
let tools_req = json!({
"jsonrpc": "2.0",
"method": "tools/list",
"params": {},
"id": i
});
send_message(&mut nvim_stdin, tools_req);
let resp = read_message(&mut nvim_stdout).expect("Failed to read rapid response from win-nvim");
assert_eq!(resp["id"], i);
}
let nvim_duration = start_time_nvim.elapsed();
println!("Stub 100 requests: {:?}", stub_duration);
println!("Win-Nvim 100 requests: {:?}", nvim_duration);
// Cleanup
let _ = stub.kill();
let _ = win_nvim.kill();
let _ = server.kill();
}
+187
View File
@@ -0,0 +1,187 @@
I0913 07:13:11.799197 45 server.go:1560] Starting language server process with pid 34896
I0913 07:13:11.805667 45 server.go:1613] Language server version: 1.2.2
I0913 07:13:11.805667 45 server.go:613] Language server will attempt to listen on host localhost
I0913 07:13:11.870861 45 server.go:628] Language server listening on random port at 58424 for HTTPS (gRPC)
I0913 07:13:11.872918 45 server.go:636] Language server listening on random port at 58425 for HTTP
W0913 07:13:12.228039 79 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.228601 79 errorreport.go:224] error getting token source: You are not logged into Antigravity.
E0913 07:13:12.232567 79 errorreport.go:224] Failed to poll ListExperiments: error getting token source: You are not logged into Antigravity.
I0913 07:13:12.232567 79 model_configs.go:65] Auth mode is unspecified, skipping fetchAvailableModels and returning empty response
I0913 07:13:12.241299 117 store_client.go:680] summary store: starting background reconciliation (trigger=startup)
I0913 07:13:12.242528 117 store_client.go:730] summary store: reconciliation complete, synced 6 records
W0913 07:13:12.243100 117 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.243100 117 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:13:12.243721 117 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.243721 117 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
W0913 07:13:12.243721 45 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.243721 45 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:13:12.243721 45 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.243721 45 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
W0913 07:13:12.247405 45 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.247405 45 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:13:12.247405 45 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.247405 45 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
W0913 07:13:12.248015 45 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.248015 45 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:13:12.248015 45 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.248015 45 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
W0913 07:13:12.248015 147 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.248567 147 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:13:12.249258 147 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.249258 147 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
W0913 07:13:12.254061 45 launchmanager.go:69] Entering local chrome mode! This is WRONG unless you are running tests or in eval mode on Linux.
W0913 07:13:12.260093 45 defaults.go:778] failed to get cs path: cs path /usr/bin/cs invalid
I0913 07:13:12.260093 45 manager.go:108] Creating trajectory store manager with proto store and SQLite store
W0913 07:13:12.263716 157 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.263716 157 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:13:12.267188 45 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.267892 45 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:13:12.268559 45 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.268559 45 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
I0913 07:13:12.275926 203 manager.go:88] Migration [MIGRATION_ID_SIDECAR_USER_CONFIG_BYPASS] is disabled, skipping entirely
I0913 07:13:12.275926 203 manager.go:91] Migration [MIGRATION_ID_SANITY_CHECK_PROJECT_URIS] is enabled
I0913 07:13:12.278668 203 manager.go:100] Migration [MIGRATION_ID_SANITY_CHECK_PROJECT_URIS] already has status MIGRATION_STATUS_COMPLETED, skipping
I0913 07:13:12.278668 203 manager.go:91] Migration [MIGRATION_ID_PLUGIN_ENABLEMENT] is enabled
I0913 07:13:12.280984 203 manager.go:100] Migration [MIGRATION_ID_PLUGIN_ENABLEMENT] already has status MIGRATION_STATUS_COMPLETED, skipping
I0913 07:13:12.280984 203 manager.go:91] Migration [MIGRATION_ID_BUILTIN_SIDECARS_TO_PLUGINS] is enabled
I0913 07:13:12.282861 203 manager.go:100] Migration [MIGRATION_ID_BUILTIN_SIDECARS_TO_PLUGINS] already has status MIGRATION_STATUS_COMPLETED, skipping
I0913 07:13:12.282861 203 manager.go:88] Migration [MIGRATION_ID_LIN_BUILTIN_TO_MARKETPLACE] is disabled, skipping entirely
I0913 07:13:12.286340 45 server.go:3003] Auth succeeded, refreshing features and managers
W0913 07:13:12.287121 45 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.287121 45 errorreport.go:224] error getting token source: You are not logged into Antigravity.
E0913 07:13:12.287121 45 errorreport.go:224] Failed to poll ListExperiments: error getting token source: You are not logged into Antigravity.
I0913 07:13:12.287121 45 server.go:3009] State refresh took 0ms
I0913 07:13:12.287121 45 server.go:3019] [RemoteControl] Subscription callback triggered.
I0913 07:13:12.289628 45 server.go:3022] [RemoteControl] RemoteControlEnabled value: false
I0913 07:13:12.289628 45 server.go:3637] [RemoteControl] Session toggle is off, staying disconnected
I0913 07:13:12.289628 45 server.go:3348] [RemoteControl] Resolved proxyServerURL: ""
I0913 07:13:12.290222 45 profiler.go:231] Continuous pprof profiling is disabled.
I0913 07:13:12.290222 209 manager.go:88] Migration [MIGRATION_ID_SIDECAR_USER_CONFIG_BYPASS] is disabled, skipping entirely
I0913 07:13:12.290222 209 manager.go:91] Migration [MIGRATION_ID_SANITY_CHECK_PROJECT_URIS] is enabled
I0913 07:13:12.290222 45 server.go:2505] initialized server successfully in 487.2507ms
W0913 07:13:12.290887 212 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.290887 212 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:13:12.290887 212 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.291441 212 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
I0913 07:13:12.292744 209 manager.go:100] Migration [MIGRATION_ID_SANITY_CHECK_PROJECT_URIS] already has status MIGRATION_STATUS_COMPLETED, skipping
I0913 07:13:12.292744 209 manager.go:91] Migration [MIGRATION_ID_PLUGIN_ENABLEMENT] is enabled
I0913 07:13:12.295037 209 manager.go:100] Migration [MIGRATION_ID_PLUGIN_ENABLEMENT] already has status MIGRATION_STATUS_COMPLETED, skipping
I0913 07:13:12.295037 209 manager.go:91] Migration [MIGRATION_ID_BUILTIN_SIDECARS_TO_PLUGINS] is enabled
I0913 07:13:12.295595 1 auto_updater.go:252] Last check was less than 15 minutes ago, skipping update (fast path)
I0913 07:13:12.295595 1 common.go:150] Launching CLI mode
E0913 07:13:12.295595 1 launchsteps.go:84] Failed to resolve GeminiDir ".gemini": .gemini must be an absolute path: path is not absolute, falling back to default
I0913 07:13:12.295595 1 common.go:172] CLI app data directory: C:\Users\reazul.ashraf\.gemini\antigravity-cli
I0913 07:13:12.295595 1 server.go:299] Creating CLI server backend: product=antigravity workspaceDirs=[C:\Users\reazul.ashraf\workspace\rust\mcp-memory] appDataDir=C:\Users\reazul.ashraf\.gemini\antigravity-cli cascadeManager=true codeAssist=true
I0913 07:13:12.296176 1 auth_provider.go:763] [AuthProvider] SetEnableBusinessLogin called with enable: true
I0913 07:13:12.297268 209 manager.go:100] Migration [MIGRATION_ID_BUILTIN_SIDECARS_TO_PLUGINS] already has status MIGRATION_STATUS_COMPLETED, skipping
I0913 07:13:12.297268 209 manager.go:88] Migration [MIGRATION_ID_LIN_BUILTIN_TO_MARKETPLACE] is disabled, skipping entirely
W0913 07:13:12.320261 182 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.320887 182 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:13:12.320887 182 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.320887 182 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
I0913 07:13:12.329342 1 project.go:77] project: created project "mcp-memory" (id=f8f5bcad-a3d5-45cd-9413-a145efef016d) at C:\Users\reazul.ashraf\.gemini\config\projects\f8f5bcad-a3d5-45cd-9413-a145efef016d.json
I0913 07:13:12.329342 1 server.go:2091] Backend project ID updated dynamically to: f8f5bcad-a3d5-45cd-9413-a145efef016d
I0913 07:13:12.384573 1 analytics.go:179] CLI startup completed (took 588.6426ms)
I0913 07:13:12.384573 1 printmode.go:174] Print mode: starting (promptLength=19, model="", conversationID="")
I0913 07:13:12.384573 1 manager.go:426] Initializing CLI store manager for workspace C:\Users\reazul.ashraf\workspace\rust\mcp-memory
W0913 07:13:12.389494 182 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
I0913 07:13:12.389494 1 cli_setting_manager.go:156] applyUserSettings: no shared config permissions from C:\Users\reazul.ashraf\.gemini\config\config.json
E0913 07:13:12.389494 182 errorreport.go:224] error getting token source: You are not logged into Antigravity.
I0913 07:13:12.389494 1 cli_setting_manager.go:92] CLI settings initialized: permissions=&{Allow:[command(wsl.exe) command(wsl.exe -d Ubuntu -e printenv | grep -i google)] Deny:[] Ask:[]}, toolPermission=always-proceed
W0913 07:13:12.390021 182 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.390021 182 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
W0913 07:13:12.390401 182 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.390401 182 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:13:12.390912 182 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.390912 182 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
W0913 07:13:12.390912 182 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.390912 182 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:13:12.390912 182 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:13:12.391628 182 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
I0913 07:13:12.395463 1 hooks_manager.go:53] loaded 0 named hooks from 0 hooks.json file(s)
I0913 07:13:12.395463 1 manager.go:600] CLI store manager initialized successfully
I0913 07:13:12.395463 1 printmode.go:365] Print mode: not authenticated, trying silent auth
I0913 07:13:12.396534 245 keyring.go:64] keyringAuth: loaded token, expiry=2026-09-13 07:17:47.0141665 +0100 BST expired=false
I0913 07:13:12.530611 245 browser.go:265] token refreshed, new expiry=2026-09-13 08:13:11.5306116 +0100 BST m=+3599.838249501
I0913 07:13:12.548508 1 auth.go:148] ChainedAuth: authenticated via keyring (effective: gcp)
I0913 07:13:12.548508 1 server_oauth.go:192] applyAuthResult: email=reazul.ashraf@inseinc.com, authMethod=gcp, quotaProject=inse-r-and-d
I0913 07:13:12.548508 1 server_oauth.go:197] OAuth: authenticated successfully as reazul.ashraf@inseinc.com
I0913 07:13:12.548508 1 server_oauth.go:203] b.codeAssistClient.AuthProvider (0xfe2a5053590) is same as b.cliAuth (0xfe2a5053590)
W0913 07:13:12.557760 295 cache.go:163] Failed to refresh cache in background: admin controls not applicable
W0913 07:13:12.557760 256 cache.go:163] Failed to refresh cache in background: admin controls not applicable
I0913 07:13:12.721157 252 http_helpers.go:299] URL: https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels Trace: 0x46b69e1d5153866c
I0913 07:13:12.727258 1 model_config_manager.go:327] Propagating selected model override to backend: label="Gemini 3.1 Pro (Low)"
I0913 07:13:12.727768 1 admin_controls_manager.go:42] Enabling admin controls refresh loop for business account
I0913 07:13:12.727768 1 printmode.go:367] Print mode: silent auth succeeded
I0913 07:13:12.727768 313 model_config_manager.go:327] Propagating selected model override to backend: label="Gemini 3.1 Pro (Low)"
I0913 07:13:12.727768 262 quota_manager.go:45] doRefreshQuota: starting reload (force=true)
I0913 07:13:12.727768 263 experiment_manager.go:56] Starting experiment refresh after login
I0913 07:13:12.763025 314 model_config_manager.go:327] Propagating selected model override to backend: label="Gemini 3.1 Pro (Low)"
I0913 07:13:12.763025 262 quota_manager.go:45] doRefreshQuota: starting reload (force=true)
I0913 07:13:12.910330 263 server.go:3637] [RemoteControl] Session toggle is off, staying disconnected
I0913 07:13:12.910330 263 server.go:3348] [RemoteControl] Resolved proxyServerURL: ""
I0913 07:13:12.910330 263 experiment_manager.go:60] Experiments refreshed after login
I0913 07:13:12.910330 263 experiment_manager.go:56] Starting experiment refresh after login
I0913 07:13:12.910881 324 manager.go:1281] Reloading system slash commands
I0913 07:13:12.910881 1 conversation_manager.go:451] Starting new conversation (agent=false)
I0913 07:13:12.910881 1 server.go:1142] Creating new cascade trajectory (agentScript=false)
I0913 07:13:12.910881 1 server.go:1145] Conversation using project ID: f8f5bcad-a3d5-45cd-9413-a145efef016d
I0913 07:13:12.974916 263 server.go:3637] [RemoteControl] Session toggle is off, staying disconnected
I0913 07:13:12.975499 263 server.go:3348] [RemoteControl] Resolved proxyServerURL: ""
I0913 07:13:12.975499 263 experiment_manager.go:60] Experiments refreshed after login
I0913 07:13:12.975499 327 manager.go:1281] Reloading system slash commands
I0913 07:13:12.994408 327 manager.go:1285] Slash commands unchanged, skipping update
I0913 07:13:13.015610 1 server.go:1177] Created conversation 2a0dd78e-ef6f-48a6-8958-9e847ba5ae44
I0913 07:13:13.016754 1 server.go:3088] GetConversationDetail: found conversation 2a0dd78e-ef6f-48a6-8958-9e847ba5ae44 (active=true)
I0913 07:13:13.018963 1 server.go:3088] GetConversationDetail: found conversation 2a0dd78e-ef6f-48a6-8958-9e847ba5ae44 (active=true)
I0913 07:13:13.019519 1 conversation_manager.go:498] project: switching to conversation belonging to project ID: f8f5bcad-a3d5-45cd-9413-a145efef016d
I0913 07:13:13.020642 1 server.go:2091] Backend project ID updated dynamically to: f8f5bcad-a3d5-45cd-9413-a145efef016d
I0913 07:13:13.020642 1 cli_setting_manager.go:218] ApplyProjectPermissionGrants: no grants for project "mcp-memory", cleared project permissions
I0913 07:13:13.020642 387 manager.go:1304] Reloading system slash commands and skills
I0913 07:13:13.020642 387 manager.go:1281] Reloading system slash commands
I0913 07:13:13.028049 1 hooks_manager.go:53] loaded 0 named hooks from 0 hooks.json file(s)
I0913 07:13:13.028049 1 conversation_manager.go:544] project: synced active project to "mcp-memory" (id=f8f5bcad-a3d5-45cd-9413-a145efef016d) from conversation switch
I0913 07:13:13.028049 1 conversation_manager.go:821] Streaming conversation 2a0dd78e-ef6f-48a6-8958-9e847ba5ae44
I0913 07:13:13.028635 402 manager.go:1304] Reloading system slash commands and skills
I0913 07:13:13.028635 402 manager.go:1281] Reloading system slash commands
I0913 07:13:13.028635 1 server.go:3088] GetConversationDetail: found conversation 2a0dd78e-ef6f-48a6-8958-9e847ba5ae44 (active=true)
I0913 07:13:13.033411 1 server.go:1186] Starting conversation update stream for 2a0dd78e-ef6f-48a6-8958-9e847ba5ae44
I0913 07:13:13.033411 1 session.go:168] Print mode: conversation=2a0dd78e-ef6f-48a6-8958-9e847ba5ae44, sending message
I0913 07:13:13.033411 1 manager.go:1281] Reloading system slash commands
I0913 07:13:13.040855 387 manager.go:1285] Slash commands unchanged, skipping update
I0913 07:13:13.055590 1 manager.go:1285] Slash commands unchanged, skipping update
I0913 07:13:13.056337 1 conversation_manager.go:633] Forwarding user message to conversation 2a0dd78e-ef6f-48a6-8958-9e847ba5ae44 (items=1, media=0)
I0913 07:13:13.056337 1 server.go:1745] Sending user message to conversation 2a0dd78e-ef6f-48a6-8958-9e847ba5ae44 (items=1, media=0)
I0913 07:13:13.056900 402 manager.go:1285] Slash commands unchanged, skipping update
I0913 07:13:13.063256 1 latency_breakdown.go:142] SEND_USER_CASCADE_MESSAGE_LATENCY latency breakdown: map[active_user_state_latency_ms:0 blocking:false cascade_id:2a0dd78e-ef6f-48a6-8958-9e847ba5ae44 cascade_send_latency_ms:4 hydrate_slash_commands_latency_ms:0 load_trajectory_latency_ms:0 media_count:0 media_summaries_latency_ms:0 message_origin:AGENT_MESSAGE_ORIGIN_UNSPECIFIED refresh_browser_state_latency_ms:0 resolve_agent_config_latency_ms:1 save_media_latency_ms:0 saved_media_count:0 status:OK total_latency_ms:6 trajectory_id:1e6aeef3-6a26-47de-b767-5f796ce64de6]
I0913 07:13:13.669038 89 http_helpers.go:299] URL: https://aiplatform.googleapis.com/v1/projects/inse-r-and-d/locations/global/publishers/google/models/gemini-3.5-flash-lite:streamGenerateContent?alt=sse ResponseID: -T6maszCGYXZ1PIPkoa30Ac
I0913 07:13:42.265416 181 mcp_manager.go:873] MCP: 1 server(s) still connecting after 30s: win-nvim
I0913 07:14:12.265868 181 mcp_manager.go:873] MCP: 1 server(s) still connecting after 1m0s: win-nvim
I0913 07:14:42.265823 181 mcp_manager.go:873] MCP: 1 server(s) still connecting after 1m30s: win-nvim
I0913 07:15:12.265858 181 mcp_manager.go:873] MCP: 1 server(s) still connecting after 2m0s: win-nvim
I0913 07:15:42.266182 181 mcp_manager.go:873] MCP: 1 server(s) still connecting after 2m30s: win-nvim
I0913 07:16:12.266424 181 mcp_manager.go:873] MCP: 1 server(s) still connecting after 3m0s: win-nvim
I0913 07:16:42.266567 181 mcp_manager.go:873] MCP: 1 server(s) still connecting after 3m30s: win-nvim
I0913 07:17:12.267264 181 mcp_manager.go:873] MCP: 1 server(s) still connecting after 4m0s: win-nvim
I0913 07:17:42.267157 181 mcp_manager.go:873] MCP: 1 server(s) still connecting after 4m30s: win-nvim
I0913 07:18:12.266905 181 mcp_manager.go:873] MCP: 1 server(s) still connecting after 5m0s: win-nvim
W0913 07:18:13.081605 1 poll.go:204] Print mode: print timeout after 5m0s with turn in progress after 11786 polls (printed=0)
I0913 07:18:13.081605 1 manager.go:780] CLI store manager shutting down
I0913 07:18:13.089097 1 conversation_manager.go:778] Stopping conversation stream
I0913 07:18:13.089097 863 server.go:2862] Language server shutting down
I0913 07:18:13.089097 863 server.go:2867] Waiting for migrations to complete to prevent partial migration state...
I0913 07:18:13.089711 403 server.go:1233] Stream goroutine exited for 2a0dd78e-ef6f-48a6-8958-9e847ba5ae44, sending completion signal
I0913 07:18:13.089711 403 conversation_manager.go:922] Stream completed for 2a0dd78e-ef6f-48a6-8958-9e847ba5ae44, clearing ResponsePending
E0913 07:18:13.122238 411 sharedfragment.go:187] Failed to append MCP servers from specs: context canceled
E0913 07:18:13.141515 411 rules.go:393] Invalid rule trigger: CORTEX_MEMORY_TRIGGER_UNSPECIFIED
E0913 07:18:13.142864 411 rules.go:393] Invalid rule trigger: CORTEX_MEMORY_TRIGGER_UNSPECIFIED
E0913 07:18:13.144699 411 rules.go:393] Invalid rule trigger: CORTEX_MEMORY_TRIGGER_UNSPECIFIED
E0913 07:18:13.146554 411 rules.go:393] Invalid rule trigger: CORTEX_MEMORY_TRIGGER_UNSPECIFIED
E0913 07:18:13.147674 411 rules.go:393] Invalid rule trigger: CORTEX_MEMORY_TRIGGER_UNSPECIFIED
E0913 07:18:13.148896 411 rules.go:393] Invalid rule trigger: CORTEX_MEMORY_TRIGGER_UNSPECIFIED
E0913 07:18:13.149997 411 rules.go:393] Invalid rule trigger: CORTEX_MEMORY_TRIGGER_UNSPECIFIED
E0913 07:18:13.151139 411 rules.go:393] Invalid rule trigger: CORTEX_MEMORY_TRIGGER_UNSPECIFIED
W0913 07:18:13.157805 411 declarative_config_loader.go:253] skipping component during resolution: empty component: prompt section "subagent_reminder"
W0913 07:18:13.157805 411 declarative_config_loader.go:253] skipping component during resolution: empty component: prompt section "terminal_sandbox"
I0913 07:18:13.222695 904 http_helpers.go:299] URL: https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels Trace: 0xf50151ad0d9158d9
E0913 07:18:13.333887 160 file_watcher.go:194] skipping empty or temp file:
+154
View File
@@ -0,0 +1,154 @@
I0913 07:26:28.955691 67 server.go:1560] Starting language server process with pid 36192
I0913 07:26:28.966088 67 server.go:1613] Language server version: 1.2.2
I0913 07:26:28.966088 67 server.go:613] Language server will attempt to listen on host localhost
I0913 07:26:29.009214 67 server.go:628] Language server listening on random port at 65520 for HTTPS (gRPC)
I0913 07:26:29.010778 67 server.go:636] Language server listening on random port at 65521 for HTTP
W0913 07:26:29.341265 27 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.341812 27 errorreport.go:224] error getting token source: You are not logged into Antigravity.
E0913 07:26:29.343343 27 errorreport.go:224] Failed to poll ListExperiments: error getting token source: You are not logged into Antigravity.
I0913 07:26:29.343889 27 model_configs.go:65] Auth mode is unspecified, skipping fetchAvailableModels and returning empty response
I0913 07:26:29.375237 33 store_client.go:680] summary store: starting background reconciliation (trigger=startup)
W0913 07:26:29.380738 67 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.381741 67 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:26:29.381741 67 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.381741 67 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
W0913 07:26:29.386594 116 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.387227 116 errorreport.go:224] error getting token source: You are not logged into Antigravity.
I0913 07:26:29.387227 33 store_client.go:730] summary store: reconciliation complete, synced 6 records
W0913 07:26:29.387227 116 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.388039 116 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
W0913 07:26:29.389671 67 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.389671 67 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:26:29.389671 67 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.389671 67 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
W0913 07:26:29.393413 67 launchmanager.go:69] Entering local chrome mode! This is WRONG unless you are running tests or in eval mode on Linux.
W0913 07:26:29.395116 67 defaults.go:778] failed to get cs path: cs path /usr/bin/cs invalid
I0913 07:26:29.396134 67 manager.go:108] Creating trajectory store manager with proto store and SQLite store
W0913 07:26:29.396642 130 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.396642 130 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:26:29.400940 67 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.401959 67 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:26:29.401959 67 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.402476 67 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
I0913 07:26:29.422534 93 manager.go:88] Migration [MIGRATION_ID_SIDECAR_USER_CONFIG_BYPASS] is disabled, skipping entirely
I0913 07:26:29.422534 93 manager.go:91] Migration [MIGRATION_ID_SANITY_CHECK_PROJECT_URIS] is enabled
I0913 07:26:29.427540 93 manager.go:100] Migration [MIGRATION_ID_SANITY_CHECK_PROJECT_URIS] already has status MIGRATION_STATUS_COMPLETED, skipping
I0913 07:26:29.427540 93 manager.go:91] Migration [MIGRATION_ID_PLUGIN_ENABLEMENT] is enabled
I0913 07:26:29.430393 93 manager.go:100] Migration [MIGRATION_ID_PLUGIN_ENABLEMENT] already has status MIGRATION_STATUS_COMPLETED, skipping
I0913 07:26:29.430393 93 manager.go:91] Migration [MIGRATION_ID_BUILTIN_SIDECARS_TO_PLUGINS] is enabled
I0913 07:26:29.432424 93 manager.go:100] Migration [MIGRATION_ID_BUILTIN_SIDECARS_TO_PLUGINS] already has status MIGRATION_STATUS_COMPLETED, skipping
I0913 07:26:29.432424 93 manager.go:88] Migration [MIGRATION_ID_LIN_BUILTIN_TO_MARKETPLACE] is disabled, skipping entirely
I0913 07:26:29.433310 67 server.go:3003] Auth succeeded, refreshing features and managers
W0913 07:26:29.433823 67 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.433823 67 errorreport.go:224] error getting token source: You are not logged into Antigravity.
E0913 07:26:29.433823 67 errorreport.go:224] Failed to poll ListExperiments: error getting token source: You are not logged into Antigravity.
I0913 07:26:29.433823 67 server.go:3009] State refresh took 0ms
I0913 07:26:29.433823 67 server.go:3019] [RemoteControl] Subscription callback triggered.
I0913 07:26:29.436732 67 server.go:3022] [RemoteControl] RemoteControlEnabled value: false
I0913 07:26:29.436732 67 server.go:3637] [RemoteControl] Session toggle is off, staying disconnected
I0913 07:26:29.436732 67 server.go:3348] [RemoteControl] Resolved proxyServerURL: ""
I0913 07:26:29.437754 195 manager.go:88] Migration [MIGRATION_ID_SIDECAR_USER_CONFIG_BYPASS] is disabled, skipping entirely
I0913 07:26:29.437754 195 manager.go:91] Migration [MIGRATION_ID_SANITY_CHECK_PROJECT_URIS] is enabled
I0913 07:26:29.437754 67 profiler.go:231] Continuous pprof profiling is disabled.
I0913 07:26:29.438267 67 server.go:2505] initialized server successfully in 477.0362ms
W0913 07:26:29.438820 128 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.438820 128 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:26:29.439414 128 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.439414 128 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
I0913 07:26:29.440442 195 manager.go:100] Migration [MIGRATION_ID_SANITY_CHECK_PROJECT_URIS] already has status MIGRATION_STATUS_COMPLETED, skipping
I0913 07:26:29.440442 195 manager.go:91] Migration [MIGRATION_ID_PLUGIN_ENABLEMENT] is enabled
I0913 07:26:29.441990 195 manager.go:100] Migration [MIGRATION_ID_PLUGIN_ENABLEMENT] already has status MIGRATION_STATUS_COMPLETED, skipping
I0913 07:26:29.441990 195 manager.go:91] Migration [MIGRATION_ID_BUILTIN_SIDECARS_TO_PLUGINS] is enabled
I0913 07:26:29.442649 1 auto_updater.go:252] Last check was less than 15 minutes ago, skipping update (fast path)
I0913 07:26:29.443288 1 common.go:150] Launching CLI mode
E0913 07:26:29.443288 1 launchsteps.go:84] Failed to resolve GeminiDir ".gemini": .gemini must be an absolute path: path is not absolute, falling back to default
I0913 07:26:29.443288 1 common.go:172] CLI app data directory: C:\Users\reazul.ashraf\.gemini\antigravity-cli
I0913 07:26:29.443288 1 server.go:299] Creating CLI server backend: product=antigravity workspaceDirs=[C:\Users\reazul.ashraf\workspace\rust\mcp-memory] appDataDir=C:\Users\reazul.ashraf\.gemini\antigravity-cli cascadeManager=true codeAssist=true
I0913 07:26:29.443835 1 auth_provider.go:763] [AuthProvider] SetEnableBusinessLogin called with enable: true
I0913 07:26:29.443835 195 manager.go:100] Migration [MIGRATION_ID_BUILTIN_SIDECARS_TO_PLUGINS] already has status MIGRATION_STATUS_COMPLETED, skipping
I0913 07:26:29.444414 195 manager.go:88] Migration [MIGRATION_ID_LIN_BUILTIN_TO_MARKETPLACE] is disabled, skipping entirely
W0913 07:26:29.463991 226 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.463991 226 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:26:29.465009 226 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.465009 226 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
I0913 07:26:29.477397 1 project.go:77] project: created project "mcp-memory" (id=266dd20a-ce12-4f25-829b-17b0981db6a6) at C:\Users\reazul.ashraf\.gemini\config\projects\266dd20a-ce12-4f25-829b-17b0981db6a6.json
I0913 07:26:29.477397 1 server.go:2091] Backend project ID updated dynamically to: 266dd20a-ce12-4f25-829b-17b0981db6a6
I0913 07:26:29.534151 1 analytics.go:179] CLI startup completed (took 581.6895ms)
I0913 07:26:29.534151 1 printmode.go:174] Print mode: starting (promptLength=4, model="", conversationID="")
I0913 07:26:29.534151 1 manager.go:426] Initializing CLI store manager for workspace C:\Users\reazul.ashraf\workspace\rust\mcp-memory
W0913 07:26:29.537427 226 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.537427 226 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:26:29.537427 226 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.537992 226 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
W0913 07:26:29.537992 226 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.537992 226 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:26:29.537992 226 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.537992 226 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
I0913 07:26:29.538447 1 cli_setting_manager.go:156] applyUserSettings: no shared config permissions from C:\Users\reazul.ashraf\.gemini\config\config.json
I0913 07:26:29.538447 1 cli_setting_manager.go:92] CLI settings initialized: permissions=&{Allow:[command(wsl.exe) command(wsl.exe -d Ubuntu -e printenv | grep -i google)] Deny:[] Ask:[]}, toolPermission=always-proceed
W0913 07:26:29.538969 226 cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.538969 226 errorreport.go:224] error getting token source: You are not logged into Antigravity.
W0913 07:26:29.538969 226 cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
E0913 07:26:29.538969 226 errorreport.go:224] failed to get load code assist response: error getting token source: You are not logged into Antigravity.
I0913 07:26:29.542414 1 hooks_manager.go:53] loaded 0 named hooks from 0 hooks.json file(s)
I0913 07:26:29.542414 1 manager.go:600] CLI store manager initialized successfully
I0913 07:26:29.542414 1 printmode.go:365] Print mode: not authenticated, trying silent auth
I0913 07:26:29.545585 264 keyring.go:64] keyringAuth: loaded token, expiry=2026-09-13 08:17:36.8104589 +0100 BST expired=false
I0913 07:26:29.546294 1 auth.go:148] ChainedAuth: authenticated via keyring (effective: gcp)
I0913 07:26:29.546294 1 server_oauth.go:192] applyAuthResult: email=reazul.ashraf@inseinc.com, authMethod=gcp, quotaProject=inse-r-and-d
I0913 07:26:29.546294 1 server_oauth.go:197] OAuth: authenticated successfully as reazul.ashraf@inseinc.com
I0913 07:26:29.546294 1 server_oauth.go:203] b.codeAssistClient.AuthProvider (0x8c2590420f0) is same as b.cliAuth (0x8c2590420f0)
W0913 07:26:29.554924 271 cache.go:163] Failed to refresh cache in background: admin controls not applicable
W0913 07:26:29.555575 238 cache.go:163] Failed to refresh cache in background: admin controls not applicable
I0913 07:26:29.766407 267 http_helpers.go:299] URL: https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels Trace: 0x929b277550fa25b7
I0913 07:26:29.781984 1 model_config_manager.go:327] Propagating selected model override to backend: label="Gemini 3.1 Pro (Low)"
I0913 07:26:29.781984 1 admin_controls_manager.go:42] Enabling admin controls refresh loop for business account
I0913 07:26:29.781984 1 printmode.go:367] Print mode: silent auth succeeded
I0913 07:26:29.781984 249 quota_manager.go:45] doRefreshQuota: starting reload (force=true)
I0913 07:26:29.781984 250 experiment_manager.go:56] Starting experiment refresh after login
I0913 07:26:29.781984 282 model_config_manager.go:327] Propagating selected model override to backend: label="Gemini 3.1 Pro (Low)"
I0913 07:26:29.782560 249 quota_manager.go:45] doRefreshQuota: starting reload (force=true)
I0913 07:26:29.782560 189 model_config_manager.go:327] Propagating selected model override to backend: label="Gemini 3.1 Pro (Low)"
I0913 07:26:29.782560 249 quota_manager.go:45] doRefreshQuota: starting reload (force=true)
I0913 07:26:29.875679 250 server.go:3637] [RemoteControl] Session toggle is off, staying disconnected
I0913 07:26:29.875679 250 server.go:3348] [RemoteControl] Resolved proxyServerURL: ""
I0913 07:26:29.875679 250 experiment_manager.go:60] Experiments refreshed after login
I0913 07:26:29.875679 250 experiment_manager.go:56] Starting experiment refresh after login
I0913 07:26:29.875679 1 conversation_manager.go:451] Starting new conversation (agent=false)
I0913 07:26:29.875679 1 server.go:1142] Creating new cascade trajectory (agentScript=false)
I0913 07:26:29.875679 1 server.go:1145] Conversation using project ID: 266dd20a-ce12-4f25-829b-17b0981db6a6
I0913 07:26:29.875679 310 manager.go:1281] Reloading system slash commands
I0913 07:26:29.916916 250 server.go:3637] [RemoteControl] Session toggle is off, staying disconnected
I0913 07:26:29.916916 250 server.go:3348] [RemoteControl] Resolved proxyServerURL: ""
I0913 07:26:29.916916 250 experiment_manager.go:60] Experiments refreshed after login
I0913 07:26:29.916916 140 manager.go:1281] Reloading system slash commands
I0913 07:26:29.926102 140 manager.go:1285] Slash commands unchanged, skipping update
I0913 07:26:29.953728 1 server.go:1177] Created conversation 6830bb37-81b9-4a73-98a9-6629670c6f8f
I0913 07:26:29.954794 1 server.go:3088] GetConversationDetail: found conversation 6830bb37-81b9-4a73-98a9-6629670c6f8f (active=true)
I0913 07:26:29.956348 1 server.go:3088] GetConversationDetail: found conversation 6830bb37-81b9-4a73-98a9-6629670c6f8f (active=true)
I0913 07:26:29.957361 1 conversation_manager.go:498] project: switching to conversation belonging to project ID: 266dd20a-ce12-4f25-829b-17b0981db6a6
I0913 07:26:29.958879 1 server.go:2091] Backend project ID updated dynamically to: 266dd20a-ce12-4f25-829b-17b0981db6a6
I0913 07:26:29.958879 1 cli_setting_manager.go:218] ApplyProjectPermissionGrants: no grants for project "mcp-memory", cleared project permissions
I0913 07:26:29.958879 168 manager.go:1304] Reloading system slash commands and skills
I0913 07:26:29.958879 168 manager.go:1281] Reloading system slash commands
I0913 07:26:29.966838 1 hooks_manager.go:53] loaded 0 named hooks from 0 hooks.json file(s)
I0913 07:26:29.967479 1 conversation_manager.go:544] project: synced active project to "mcp-memory" (id=266dd20a-ce12-4f25-829b-17b0981db6a6) from conversation switch
I0913 07:26:29.967479 1 conversation_manager.go:821] Streaming conversation 6830bb37-81b9-4a73-98a9-6629670c6f8f
I0913 07:26:29.967479 323 manager.go:1304] Reloading system slash commands and skills
I0913 07:26:29.967479 323 manager.go:1281] Reloading system slash commands
I0913 07:26:29.968014 1 server.go:3088] GetConversationDetail: found conversation 6830bb37-81b9-4a73-98a9-6629670c6f8f (active=true)
I0913 07:26:29.971692 1 server.go:1186] Starting conversation update stream for 6830bb37-81b9-4a73-98a9-6629670c6f8f
I0913 07:26:29.972274 1 session.go:168] Print mode: conversation=6830bb37-81b9-4a73-98a9-6629670c6f8f, sending message
I0913 07:26:29.972274 1 manager.go:1281] Reloading system slash commands
I0913 07:26:29.976922 168 manager.go:1285] Slash commands unchanged, skipping update
I0913 07:26:29.982877 323 manager.go:1285] Slash commands unchanged, skipping update
I0913 07:26:29.984585 1 manager.go:1285] Slash commands unchanged, skipping update
I0913 07:26:29.984585 1 conversation_manager.go:633] Forwarding user message to conversation 6830bb37-81b9-4a73-98a9-6629670c6f8f (items=1, media=0)
I0913 07:26:29.984585 1 server.go:1745] Sending user message to conversation 6830bb37-81b9-4a73-98a9-6629670c6f8f (items=1, media=0)
I0913 07:26:29.988180 1 latency_breakdown.go:142] SEND_USER_CASCADE_MESSAGE_LATENCY latency breakdown: map[active_user_state_latency_ms:0 blocking:false cascade_id:6830bb37-81b9-4a73-98a9-6629670c6f8f cascade_send_latency_ms:2 hydrate_slash_commands_latency_ms:0 load_trajectory_latency_ms:0 media_count:0 media_summaries_latency_ms:0 message_origin:AGENT_MESSAGE_ORIGIN_UNSPECIFIED refresh_browser_state_latency_ms:0 resolve_agent_config_latency_ms:1 save_media_latency_ms:0 saved_media_count:0 status:OK total_latency_ms:3 trajectory_id:41995427-45da-40eb-9a27-1362ca6cd08f]
I0913 07:26:30.663259 404 http_helpers.go:299] URL: https://aiplatform.googleapis.com/v1/projects/inse-r-and-d/locations/global/publishers/google/models/gemini-3.5-flash-lite:streamGenerateContent?alt=sse ResponseID: FkKmatyWFeXjz_IPhr7YyQ8
I0913 07:26:59.398063 125 mcp_manager.go:873] MCP: 1 server(s) still connecting after 30s: win-nvim
I0913 07:27:29.398110 125 mcp_manager.go:873] MCP: 1 server(s) still connecting after 1m0s: win-nvim
I0913 07:27:59.398255 125 mcp_manager.go:873] MCP: 1 server(s) still connecting after 1m30s: win-nvim
I0913 07:28:29.398626 125 mcp_manager.go:873] MCP: 1 server(s) still connecting after 2m0s: win-nvim
I0913 07:28:59.399114 125 mcp_manager.go:873] MCP: 1 server(s) still connecting after 2m30s: win-nvim
+4
View File
@@ -10,3 +10,7 @@ rmp-serde = "1.1"
rmpv = "1.0"
tokio = { version = "1.37", features = ["full"] }
schemars = "0.8"
tracing-appender = "0.2.5"
tracing = "0.1.44"
tracing-subscriber = "0.3.23"
dirs = "7.0.0"
+20
View File
@@ -0,0 +1,20 @@
use std::process::Command;
fn main() {
let git_hash = Command::new("git")
.args(&["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string());
let git_date = Command::new("git")
.args(&["log", "-1", "--format=%cd", "--date=format:%Y.%m.%d"])
.output()
.ok()
.and_then(|out| String::from_utf8(out.stdout).ok())
.unwrap_or_else(|| "unknown".to_string());
let version = format!("{} ({} {})", env!("CARGO_PKG_VERSION"), git_date.trim(), git_hash.trim());
println!("cargo:rustc-env=APP_VERSION={}", version);
}
+232 -21
View File
@@ -1,20 +1,59 @@
mod mcp;
use mcp::{read_message, send_response, send_error, JsonRpcResponse};
use serde_json::{json, Value};
use serde_json::json;
use tokio::net::windows::named_pipe::ClientOptions;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
fn init_logging(app_name: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| {
dirs::home_dir()
.map(|mut h| {
h.push(".gemini/mcp_memory");
h.to_string_lossy().to_string()
})
.unwrap_or_else(|| ".gemini/mcp_memory".into())
});
let log_dir = std::path::PathBuf::from(base_dir).join("logs");
std::fs::create_dir_all(&log_dir).unwrap_or_default();
let file_appender = tracing_appender::rolling::daily(log_dir, format!("{}.log", app_name));
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let _ = tracing_subscriber::fmt()
.with_writer(non_blocking)
.with_ansi(false)
.with_max_level(tracing::Level::INFO)
.try_init();
Some(guard)
}
#[tokio::main]
async fn main() {
if std::env::args().any(|a| a == "--version" || a == "-V") {
println!("mcp-memory-win-nvim {}", env!("APP_VERSION"));
return;
}
let _guard = init_logging("win-nvim");
tracing::info!("win-nvim MCP server started");
let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
loop {
let msg = match read_message().await {
Some(m) => m,
None => break,
let msg = match read_message(&mut stdin).await {
Some(m) => {
tracing::info!("Received message method: {}", m.method);
m
},
None => {
tracing::info!("Stdin closed, exiting loop");
break;
}
};
tokio::spawn(async move {
let id = msg.id.clone().unwrap_or(json!(null));
let _start_time = std::time::Instant::now();
match msg.method.as_str() {
"initialize" => {
@@ -22,7 +61,10 @@ async fn main() {
jsonrpc: "2.0".to_string(),
id,
result: Some(json!({
"capabilities": {},
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "mcp-memory-win-nvim",
"version": "0.1.0"
@@ -84,6 +126,33 @@ async fn main() {
},
"required": ["line", "message"]
}
},
{
"name": "nvim_execute_lua",
"description": "Execute arbitrary Lua code in Neovim and return the result (JSON serialized).",
"inputSchema": {
"type": "object",
"properties": {
"code": { "type": "string" }
},
"required": ["code"]
}
},
{
"name": "nvim_list_buffers",
"description": "Get a list of all loaded Neovim buffers and their IDs.",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "nvim_get_diagnostics",
"description": "Get all LSP diagnostics (errors, warnings) for the active buffer.",
"inputSchema": {
"type": "object",
"properties": {}
}
}
]
})),
@@ -198,6 +267,62 @@ async fn main() {
}
}
}
"nvim_execute_lua" => {
let code = args.get("code").and_then(|v| v.as_str()).unwrap_or("");
match execute_nvim_lua(code).await {
Ok(content) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(), id, result: Some(json!({ "content": [{ "type": "text", "text": content }] })), error: None,
}).await;
}
Err(e) => { send_error(id, -32603, &format!("Failed to execute lua: {}", e)).await; }
}
}
"nvim_list_buffers" => {
let code = r#"
local bufs = vim.api.nvim_list_bufs()
local loaded = {}
for _, b in ipairs(bufs) do
if vim.api.nvim_buf_is_loaded(b) then
local name = vim.api.nvim_buf_get_name(b)
table.insert(loaded, {id = b, name = name == "" and "[No Name]" or name})
end
end
return loaded
"#;
match execute_nvim_lua(code).await {
Ok(content) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(), id, result: Some(json!({ "content": [{ "type": "text", "text": content }] })), error: None,
}).await;
}
Err(e) => { send_error(id, -32603, &format!("Failed to list buffers: {}", e)).await; }
}
}
"nvim_get_diagnostics" => {
let code = r#"
local diags = vim.diagnostic.get(0)
local res = {}
for _, d in ipairs(diags) do
table.insert(res, {
line = d.lnum + 1,
col = d.col,
message = d.message,
severity = d.severity
})
end
return res
"#;
match execute_nvim_lua(code).await {
Ok(content) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(), id, result: Some(json!({ "content": [{ "type": "text", "text": content }] })), error: None,
}).await;
}
Err(e) => { send_error(id, -32603, &format!("Failed to get diagnostics: {}", e)).await; }
}
}
_ => {
send_error(id, -32601, "Tool not found").await;
}
@@ -212,7 +337,27 @@ async fn main() {
}
async fn get_socket_path() -> Result<String, String> {
// 1. Try auto-discovery in \\.\pipe\
// 1. Primary: Use the active_nvim.txt which is updated by Neovim's BufEnter telemetry
let profile = std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\reazul.ashraf".into());
let path = format!("{}\\.gemini\\active_nvim.txt", profile);
if let Ok(content) = std::fs::read_to_string(&path) {
let p = content.trim().to_string();
if !p.is_empty() {
// It might be a full pipe path or just the name. If it's just the name, prepend \\.\pipe\
if p.starts_with(r"\\.\pipe\") {
return Ok(p);
} else if p.starts_with("nvim.") {
return Ok(format!(r"\\.\pipe\{}", p));
} else {
// Some other servername format? Try it as is.
return Ok(p);
}
}
}
// 2. Fallback to auto-discovery in \\.\pipe\ (only if single instance is running)
tracing::warn!("active_nvim.txt missing or invalid, falling back to pipe discovery");
if let Ok(dir) = std::fs::read_dir(r"\\.\pipe\") {
for entry in dir.flatten() {
let name = entry.file_name();
@@ -222,17 +367,6 @@ async fn get_socket_path() -> Result<String, String> {
}
}
}
// 2. Fallback to active_nvim.txt
let profile = std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\reazul.ashraf".into());
let path = format!("{}\\.gemini\\active_nvim.txt", profile);
if let Ok(content) = std::fs::read_to_string(&path) {
let p = content.trim().to_string();
if !p.is_empty() {
return Ok(p);
}
}
Err("Could not find active Windows Neovim named pipe".to_string())
}
@@ -242,11 +376,13 @@ async fn call_nvim(req: rmpv::Value) -> Result<rmpv::Value, String> {
if arr.len() > 1 { arr[1].clone() } else { rmpv::Value::Nil }
} else { rmpv::Value::Nil };
tracing::info!("Connecting to neovim pipe");
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())?;
tracing::info!("Sending RPC request to neovim (msgid: {})", msgid);
client.write_all(&buf).await.map_err(|e| e.to_string())?;
let mut resp_buf = Vec::new();
@@ -261,17 +397,25 @@ async fn call_nvim(req: rmpv::Value) -> Result<rmpv::Value, String> {
if let rmpv::Value::Array(ref arr) = val {
if arr.len() >= 4 && arr[0] == rmpv::Value::Integer(1.into()) && arr[1] == msgid {
tracing::info!("Received RPC response from neovim (msgid: {})", 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());
let read_future = client.read(&mut chunk);
match tokio::time::timeout(tokio::time::Duration::from_secs(5), read_future).await {
Ok(Ok(n)) => {
if n == 0 { return Err("Connection closed".into()); }
resp_buf.extend_from_slice(&chunk[..n]);
}
Ok(Err(e)) => return Err(e.to_string()),
Err(_) => {
tracing::error!("Timeout waiting for Neovim response (msgid: {})", msgid);
return Err("Timeout waiting for Neovim response".into());
}
}
resp_buf.extend_from_slice(&chunk[..n]);
}
}
}
@@ -428,3 +572,70 @@ async fn set_nvim_diagnostics(line: i64, message: &str) -> Result<(), String> {
}
Err("Invalid response".to_string())
}
fn rmpv_to_json(val: &rmpv::Value) -> serde_json::Value {
match val {
rmpv::Value::Nil => serde_json::Value::Null,
rmpv::Value::Boolean(b) => serde_json::json!(b),
rmpv::Value::Integer(i) => {
if let Some(n) = i.as_i64() {
serde_json::json!(n)
} else if let Some(n) = i.as_u64() {
serde_json::json!(n)
} else {
serde_json::Value::Null
}
},
rmpv::Value::F32(f) => serde_json::json!(f),
rmpv::Value::F64(f) => serde_json::json!(f),
rmpv::Value::String(s) => {
if let Some(str_val) = s.as_str() {
serde_json::json!(str_val)
} else {
serde_json::Value::Null
}
},
rmpv::Value::Array(arr) => {
let vec: Vec<serde_json::Value> = arr.iter().map(rmpv_to_json).collect();
serde_json::Value::Array(vec)
},
rmpv::Value::Map(map) => {
let mut obj = serde_json::Map::new();
for (k, v) in map {
let key_str = if let rmpv::Value::String(s) = k {
s.as_str().unwrap_or("").to_string()
} else {
format!("{:?}", k)
};
obj.insert(key_str, rmpv_to_json(v));
}
serde_json::Value::Object(obj)
},
_ => serde_json::json!(format!("{:?}", val)),
}
}
async fn execute_nvim_lua(code: &str) -> Result<String, String> {
use rmpv::Value as RmpValue;
let req = RmpValue::Array(vec![
RmpValue::Integer(0.into()),
RmpValue::Integer(6.into()), // msgid
RmpValue::String("nvim_exec_lua".into()),
RmpValue::Array(vec![
RmpValue::String(code.into()),
RmpValue::Array(vec![]),
]),
]);
let resp = call_nvim(req).await?;
if let RmpValue::Array(arr) = resp {
if !arr[2].is_nil() {
return Err(format!("Neovim error: {:?}", arr[2]));
}
if arr.len() > 3 {
return Ok(serde_json::to_string_pretty(&rmpv_to_json(&arr[3])).unwrap_or_default());
}
return Ok("".to_string());
}
Err("Invalid response".to_string())
}
+15 -3
View File
@@ -20,8 +20,7 @@ pub struct JsonRpcResponse {
pub error: Option<Value>,
}
pub async fn read_message() -> Option<JsonRpcRequest> {
let mut stdin = BufReader::new(tokio::io::stdin());
pub async fn read_message(stdin: &mut BufReader<tokio::io::Stdin>) -> Option<JsonRpcRequest> {
let mut length = 0;
loop {
let mut line = String::new();
@@ -41,7 +40,20 @@ pub async fn read_message() -> Option<JsonRpcRequest> {
}
let mut buffer = vec![0; length];
stdin.read_exact(&mut buffer).await.unwrap_or(0);
serde_json::from_slice(&buffer).ok()
match serde_json::from_slice::<JsonRpcRequest>(&buffer) {
Ok(req) => Some(req),
Err(e) => {
let s = String::from_utf8_lossy(&buffer);
tracing::error!("Failed to parse JSON-RPC request: {}. Payload: {}", e, s);
Some(JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: None,
method: "unknown_parse_error".to_string(),
params: None,
})
}
}
}
pub async fn send_response(response: JsonRpcResponse) {
+112
View File
@@ -0,0 +1,112 @@
use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Command, Stdio};
fn send_message(stdin: &mut std::process::ChildStdin, msg: Value) {
let s = serde_json::to_string(&msg).unwrap();
let payload = format!("Content-Length: {}\r\n\r\n{}", s.len(), s);
stdin.write_all(payload.as_bytes()).unwrap();
stdin.flush().unwrap();
}
fn read_message(stdout: &mut std::process::ChildStdout) -> Option<Value> {
let mut reader = BufReader::new(stdout);
let mut length = 0;
// Read headers
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
return None; // EOF
}
let line = line.trim_end();
if line.is_empty() {
break;
}
if let Some(len_str) = line.strip_prefix("Content-Length: ") {
length = len_str.parse().unwrap_or(0);
}
}
if length == 0 {
return None;
}
// Read body
let mut buf = vec![0u8; length];
reader.read_exact(&mut buf).unwrap();
let body_str = String::from_utf8_lossy(&buf);
Some(serde_json::from_str(&body_str).unwrap())
}
#[test]
fn test_mcp_initialization_and_tools_list() {
// Determine the path to the built binary.
let mut nvim_exe = std::env::current_exe().unwrap();
nvim_exe.pop();
nvim_exe.pop();
nvim_exe.push("mcp-memory-win-nvim.exe");
let mut child = Command::new(&nvim_exe)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("Failed to start mcp-memory-win-nvim");
let mut stdin = child.stdin.take().expect("Failed to open stdin");
let mut stdout = child.stdout.take().expect("Failed to open stdout");
// 1. Test Initialize
let init_req = json!({
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0"
}
},
"id": 1
});
send_message(&mut stdin, init_req);
let init_resp = read_message(&mut stdout).expect("Failed to read initialize response");
assert_eq!(init_resp["jsonrpc"], "2.0");
assert_eq!(init_resp["id"], 1);
// Verify capabilities
let capabilities = &init_resp["result"]["capabilities"];
assert_eq!(capabilities["tools"], serde_json::json!({}));
// 2. Test tools/list
let tools_req = json!({
"jsonrpc": "2.0",
"method": "tools/list",
"params": {},
"id": 2
});
send_message(&mut stdin, tools_req);
let tools_resp = read_message(&mut stdout).expect("Failed to read tools/list response");
assert_eq!(tools_resp["jsonrpc"], "2.0");
assert_eq!(tools_resp["id"], 2);
let tools = tools_resp["result"]["tools"].as_array().expect("result.tools must be an array");
assert!(!tools.is_empty(), "Server must expose at least one tool");
// Verify a specific tool exists
let has_get_active_buffer = tools.iter().any(|t| t["name"] == "nvim_get_active_buffer");
assert!(has_get_active_buffer, "Missing nvim_get_active_buffer tool");
// Kill the child process cleanly
child.kill().expect("Failed to kill child");
child.wait().expect("Failed to wait on child");
}