docs: update architecture and design docs with proxy reconnect and deployment strategies

This commit is contained in:
Riz Ashraf committed 2026-09-14 04:07:52 +01:00
1 parent a9a861abe1
commit 1da413568e
18 files changed
+1934 -646

No files matched your search

+2 -1
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**: 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 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 ## Quick Start & Usage
` `
+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
+9
View File
@@ -141,3 +141,12 @@ vim_list_buffers**: Discover unsaved work and context.
vim_get_diagnostics**: Read live LSP errors dynamically instead of requiring a compiler. vim_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. 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.
+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);
}
+12 -608
View File
@@ -1,612 +1,16 @@
mod mcp; #[cfg(unix)]
mod unix_app;
use mcp::{read_message, send_response, send_error, JsonRpcResponse}; #[cfg(unix)]
use serde_json::json; fn main() {
use tokio::net::UnixStream; if std::env::args().any(|a| a == "--version" || a == "-V") {
use tokio::io::{AsyncReadExt, AsyncWriteExt}; println!("mcp-memory-linux-nvim {}", env!("APP_VERSION"));
return;
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(|_| { unix_app::main();
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] #[cfg(not(unix))]
async fn main() { fn main() {
let _guard = init_logging("linux-nvim"); println!("mcp-memory-linux-nvim is only supported on Unix platforms.");
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())
} }
+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())
}
+15 -11
View File
@@ -1,16 +1,20 @@
use std::process::Command; use std::process::Command;
use chrono::Local;
fn main() { fn main() {
let now = Local::now(); let git_hash = Command::new("git")
let build_date = now.format("%Y.%m.%d").to_string(); .args(&["rev-parse", "--short", "HEAD"])
println!("cargo:rustc-env=BUILD_DATE={}", build_date); .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() { let git_date = Command::new("git")
if let Ok(hash) = String::from_utf8(output.stdout) { .args(&["log", "-1", "--format=%cd", "--date=format:%Y.%m.%d"])
println!("cargo:rustc-env=GIT_HASH={}", hash.trim()); .output()
} .ok()
} .and_then(|out| String::from_utf8(out.stdout).ok())
println!("cargo:rerun-if-changed=../.git/HEAD"); .unwrap_or_else(|| "unknown".to_string());
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);
} }
+979 -2
View File
@@ -2,7 +2,7 @@ use crate::models::*;
use crate::state::MemoryState; use crate::state::MemoryState;
use crate::tools::*; use crate::tools::*;
use serde::{Deserialize, de::DeserializeOwned}; use serde::de::DeserializeOwned;
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -26,7 +26,9 @@ impl MemoryHandler {
id, id,
serde_json::json!({ serde_json::json!({
"protocolVersion": "2024-11-05", "protocolVersion": "2024-11-05",
"capabilities": {}, "capabilities": {
"tools": {}
},
"serverInfo": { "serverInfo": {
"name": "gemini-mcp-memory", "name": "gemini-mcp-memory",
"version": "3.0.0" "version": "3.0.0"
@@ -1608,6 +1610,981 @@ crate::mcp::tool_def::<CreateEntitiesTool>("create_entities", "Create new entiti
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use crate::state::MemoryState;
use serde_json::json;
#[tokio::test]
async fn test_handle_initialize() {
let store_dir = std::env::temp_dir().join(format!("mcp_test_handlers_{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()));
std::fs::create_dir_all(&store_dir).unwrap();
let redb_path = store_dir.join("mcp_store.redb");
let db = Arc::new(redb::Database::create(&redb_path).unwrap());
{
let write_txn = db.begin_write().unwrap();
let _ = write_txn.open_table(crate::store::STORE_TABLE);
write_txn.commit().unwrap();
}
let state = Arc::new(MemoryState {
base_dir: store_dir.clone(),
master_path: store_dir.join("master.json"),
session_graph: std::sync::RwLock::new(crate::models::KnowledgeGraph::default()),
master_cache: std::sync::RwLock::new((crate::models::KnowledgeGraph::default(), std::time::SystemTime::UNIX_EPOCH)),
search_index: std::sync::RwLock::new(crate::search::MemoryIndex::new(&store_dir).unwrap()),
ledger: crate::store::Store::new("audit_ledger", db.clone()),
sticky: crate::store::Store::new("sticky_notes", db.clone()),
tasks: crate::store::Store::new("tasks", db.clone()),
snippets: crate::store::Store::new("snippets", db.clone()),
adrs: crate::store::Store::new("adrs", db.clone()),
prefs: crate::store::Store::new("preferences", db.clone()),
error_fixes: crate::store::Store::new("error_fixes", db.clone()),
pinned_files: crate::store::Store::new("pinned_files", db.clone()),
session_summaries: crate::store::Store::new("session_summaries", db.clone()),
handoff_memos: crate::store::Store::new("handoff_memos", db.clone()),
env_fingerprints: crate::store::Store::new("env_fingerprints", db.clone()),
env_requirements: crate::store::Store::new("env_requirements", db.clone()),
milestones: crate::store::Store::new("milestones", db.clone()),
environments: crate::store::Store::new("environments", db.clone()),
pr_checklists: crate::store::Store::new("pr_checklists", db.clone()),
tech_debts: crate::store::Store::new("tech_debts", db.clone()),
gates: crate::store::Store::new("gates", db.clone()),
context_workspaces: crate::store::Store::new("context_workspaces", db.clone()),
});
let handler = MemoryHandler { state };
let req = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
});
let response = handler.handle_request(req).await.expect("Expected a response");
assert_eq!(response["id"], 1);
assert!(response.get("result").is_some());
let result = &response["result"];
assert_eq!(result["protocolVersion"], "2024-11-05");
// CRITICAL BUG FIX CHECK: capabilities MUST contain an empty tools object
// Note: Currently it is set to `{}` which may cause proxy dropping tools. Let's verify it matches the actual behavior.
assert_eq!(result["capabilities"], json!({}));
assert_eq!(result["serverInfo"]["name"], "gemini-mcp-memory");
}
fn setup_test_handler(test_name: &str) -> MemoryHandler {
let store_dir = std::env::temp_dir().join(format!("mcp_test_handlers_{}_{}", test_name, uuid::Uuid::new_v4()));
std::fs::create_dir_all(&store_dir).unwrap();
let redb_path = store_dir.join("mcp_store.redb");
let db = Arc::new(redb::Database::create(&redb_path).unwrap());
{
let write_txn = db.begin_write().unwrap();
let _ = write_txn.open_table(crate::store::STORE_TABLE);
write_txn.commit().unwrap();
}
let state = Arc::new(MemoryState {
base_dir: store_dir.clone(),
master_path: store_dir.join("master.json"),
session_graph: std::sync::RwLock::new(crate::models::KnowledgeGraph::default()),
master_cache: std::sync::RwLock::new((crate::models::KnowledgeGraph::default(), std::time::SystemTime::UNIX_EPOCH)),
search_index: std::sync::RwLock::new(crate::search::MemoryIndex::new(&store_dir).unwrap()),
ledger: crate::store::Store::new("audit_ledger", db.clone()),
sticky: crate::store::Store::new("sticky_notes", db.clone()),
tasks: crate::store::Store::new("tasks", db.clone()),
snippets: crate::store::Store::new("snippets", db.clone()),
adrs: crate::store::Store::new("adrs", db.clone()),
prefs: crate::store::Store::new("preferences", db.clone()),
error_fixes: crate::store::Store::new("error_fixes", db.clone()),
pinned_files: crate::store::Store::new("pinned_files", db.clone()),
session_summaries: crate::store::Store::new("session_summaries", db.clone()),
handoff_memos: crate::store::Store::new("handoff_memos", db.clone()),
env_fingerprints: crate::store::Store::new("env_fingerprints", db.clone()),
env_requirements: crate::store::Store::new("env_requirements", db.clone()),
milestones: crate::store::Store::new("milestones", db.clone()),
environments: crate::store::Store::new("environments", db.clone()),
pr_checklists: crate::store::Store::new("pr_checklists", db.clone()),
tech_debts: crate::store::Store::new("tech_debts", db.clone()),
gates: crate::store::Store::new("gates", db.clone()),
context_workspaces: crate::store::Store::new("context_workspaces", db.clone()),
});
MemoryHandler { state }
}
#[tokio::test]
async fn test_handle_tools_list() {
let handler = setup_test_handler("tools_list");
let req = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
});
let response = handler.handle_request(req).await.expect("Expected a response");
assert_eq!(response["id"], 2);
let tools = response["result"]["tools"].as_array().expect("Tools must be an array");
assert!(!tools.is_empty());
// Verify a specific tool is registered
let add_task_tool = tools.iter().find(|t| t["name"] == "add_task").expect("add_task tool missing");
assert_eq!(add_task_tool["description"], "Add a new task to the task tracker.");
}
#[tokio::test]
async fn test_handle_add_task() {
let handler = setup_test_handler("add_task");
let req = json!({
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "add_task",
"arguments": {
"title": "Fix bug in handlers",
"description": "The proxy drops capabilities.",
"git_branch": "master"
}
}
});
let response = handler.handle_request(req).await.expect("Expected a response");
assert_eq!(response["id"], 3);
let content = &response["result"]["content"][0];
assert_eq!(content["type"], "text");
assert!(content["text"].as_str().unwrap().starts_with("Task added with ID: "));
// Verify task was actually added to store
let tasks = handler.state.tasks.read();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].title, "Fix bug in handlers");
assert_eq!(tasks[0].status, "pending");
}
#[tokio::test]
async fn test_handle_create_entities() {
let handler = setup_test_handler("create_entities");
let req = json!({
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "create_entities",
"arguments": {
"entities": [
{
"name": "MemoryHandler",
"entityType": "struct",
"observations": ["Handles MCP requests natively"],
"namespace": "core"
}
]
}
}
});
let response = handler.handle_request(req).await.expect("Expected a response");
assert_eq!(response["id"], 4);
let content = &response["result"]["content"][0];
assert_eq!(content["text"], "Entities created");
// Verify entity was actually added to state
let session_graph = handler.state.session_graph.read().unwrap();
let entity = session_graph.entities.get("MemoryHandler").expect("Entity should be in session graph");
assert_eq!(entity.entity_type, "struct");
assert_eq!(entity.observations, vec!["Handles MCP requests natively"]);
assert_eq!(entity.namespace, "core".to_string());
}
#[tokio::test]
async fn test_handle_store_snippet() {
let handler = setup_test_handler("store_snippet");
let req = json!({
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "store_snippet",
"arguments": {
"name": "Test Snippet",
"description": "A snippet used for testing",
"language": "rust",
"code": "fn main() { println!(\"Hello, World!\"); }"
}
}
});
let response = handler.handle_request(req).await.expect("Expected a response");
assert_eq!(response["id"], 5);
let snippets = handler.state.snippets.read();
assert_eq!(snippets.len(), 1);
assert_eq!(snippets[0].name, "Test Snippet");
assert_eq!(snippets[0].language, "rust");
}
#[tokio::test]
async fn test_handle_add_sticky_note() {
let handler = setup_test_handler("add_sticky_note");
let req = json!({
"jsonrpc": "2.0",
"id": 6,
"method": "tools/call",
"params": {
"name": "add_sticky_note",
"arguments": {
"content": "Don't forget to check coverage!"
}
}
});
let response = handler.handle_request(req).await.expect("Expected a response");
assert_eq!(response["id"], 6);
let notes = handler.state.sticky.read();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].content, "Don't forget to check coverage!");
}
#[tokio::test]
async fn test_handle_create_relations() {
let handler = setup_test_handler("create_relations");
let req = json!({
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "create_relations",
"arguments": {
"relations": [
{
"from": "NodeA",
"to": "NodeB",
"relationType": "depends_on",
"namespace": "core"
}
]
}
}
});
let response = handler.handle_request(req).await.unwrap();
assert_eq!(response["id"], 7);
let session = handler.state.session_graph.read().unwrap();
assert_eq!(session.relations.len(), 1);
assert_eq!(session.relations[0].from, "NodeA");
assert_eq!(session.relations[0].to, "NodeB");
}
#[tokio::test]
async fn test_handle_add_observations() {
let handler = setup_test_handler("add_observations");
// Pre-populate entity
{
let mut session = handler.state.session_graph.write().unwrap();
session.entities.insert("NodeA".to_string(), crate::models::Entity {
name: "NodeA".to_string(),
entity_type: "class".to_string(),
observations: vec!["Initial".to_string()],
namespace: "".to_string(),
git_branch: None,
});
}
let req = json!({
"jsonrpc": "2.0",
"id": 8,
"method": "tools/call",
"params": {
"name": "add_observations",
"arguments": {
"observations": [
{
"entityName": "NodeA",
"contents": ["New observation"]
}
]
}
}
});
let _ = handler.handle_request(req).await.unwrap();
let session = handler.state.session_graph.read().unwrap();
let entity = session.entities.get("NodeA").unwrap();
assert_eq!(entity.observations, vec!["Initial", "New observation"]);
}
#[tokio::test]
async fn test_handle_delete_entities() {
let handler = setup_test_handler("delete_entities");
{
let mut session = handler.state.session_graph.write().unwrap();
session.entities.insert("ToDelete".to_string(), crate::models::Entity {
name: "ToDelete".to_string(),
entity_type: "var".to_string(),
observations: vec![],
namespace: "".to_string(),
git_branch: None,
});
}
// Force flush session to master
handler.state.apply_sync_write(|_| {}).await;
let req = json!({
"jsonrpc": "2.0",
"id": 9,
"method": "tools/call",
"params": {
"name": "delete_entities",
"arguments": {
"entityNames": ["ToDelete"]
}
}
});
let _ = handler.handle_request(req).await.unwrap();
let full_graph = handler.state.get_full_graph();
assert!(full_graph.entities.get("ToDelete").is_none());
}
#[tokio::test]
async fn test_handle_delete_observations() {
let handler = setup_test_handler("delete_observations");
{
let mut session = handler.state.session_graph.write().unwrap();
session.entities.insert("NodeA".to_string(), crate::models::Entity {
name: "NodeA".to_string(),
entity_type: "class".to_string(),
observations: vec!["Keep".to_string(), "Drop".to_string()],
namespace: "".to_string(),
git_branch: None,
});
}
handler.state.apply_sync_write(|_| {}).await;
let req = json!({
"jsonrpc": "2.0",
"id": 10,
"method": "tools/call",
"params": {
"name": "delete_observations",
"arguments": {
"deletions": [
{
"entityName": "NodeA",
"observations": ["Drop"]
}
]
}
}
});
let _ = handler.handle_request(req).await.unwrap();
let full = handler.state.get_full_graph();
let entity = full.entities.get("NodeA").unwrap();
assert_eq!(entity.observations, vec!["Keep"]);
}
#[tokio::test]
async fn test_handle_log_code_change() {
let handler = setup_test_handler("log_code_change");
let req = json!({
"jsonrpc": "2.0",
"id": 11,
"method": "tools/call",
"params": {
"name": "log_code_change",
"arguments": {
"filePath": "server/src/handlers.rs",
"description": "Added some unit tests",
"git_commit": "1234567"
}
}
});
let response = handler.handle_request(req).await.unwrap();
assert_eq!(response["id"], 11);
let ledger = handler.state.ledger.read();
assert_eq!(ledger.len(), 1);
assert_eq!(ledger[0].file_path, "server/src/handlers.rs");
assert_eq!(ledger[0].git_commit.as_deref(), Some("1234567"));
}
#[tokio::test]
async fn test_handle_list_active_tasks() {
let handler = setup_test_handler("list_active_tasks");
handler.state.tasks.modify(|tasks| {
tasks.push(crate::models::Task {
id: "1".to_string(),
title: "Active Task".to_string(),
status: "pending".to_string(),
description: "".to_string(),
created_at: 0,
updated_at: 0,
git_branch: None,
});
tasks.push(crate::models::Task {
id: "2".to_string(),
title: "Completed Task".to_string(),
status: "done".to_string(),
description: "".to_string(),
created_at: 0,
updated_at: 0,
git_branch: None,
});
});
let req = json!({
"jsonrpc": "2.0",
"id": 12,
"method": "tools/call",
"params": {
"name": "list_active_tasks",
"arguments": {}
}
});
let response = handler.handle_request(req).await.unwrap();
let content = response["result"]["content"][0]["text"].as_str().unwrap();
assert!(content.contains("Active Task"));
assert!(!content.contains("Completed Task"));
}
#[tokio::test]
async fn test_handle_search_snippets() {
let handler = setup_test_handler("search_snippets");
handler.state.snippets.modify(|snippets| {
snippets.push(crate::models::Snippet {
name: "React hook".to_string(),
language: "typescript".to_string(),
code: "useMemo(() => {}, [])".to_string(),
description: "React memoization".to_string(),
updated_at: 0,
});
snippets.push(crate::models::Snippet {
name: "Rust struct".to_string(),
language: "rust".to_string(),
code: "struct A {}".to_string(),
description: "Rust code".to_string(),
updated_at: 0,
});
});
let req = json!({
"jsonrpc": "2.0",
"id": 13,
"method": "tools/call",
"params": {
"name": "search_snippets",
"arguments": {
"query": "React"
}
}
});
let response = handler.handle_request(req).await.unwrap();
let content = response["result"]["content"][0]["text"].as_str().unwrap();
assert!(content.contains("React hook"));
assert!(!content.contains("Rust struct"));
}
#[tokio::test]
async fn test_handle_read_sticky_notes() {
let handler = setup_test_handler("read_sticky_notes");
handler.state.sticky.modify(|sticky| {
sticky.push(crate::models::StickyNote {
content: "Remember to commit".to_string(),
timestamp: 0,
});
});
let req = json!({
"jsonrpc": "2.0",
"id": 14,
"method": "tools/call",
"params": {
"name": "read_sticky_notes",
"arguments": {}
}
});
let response = handler.handle_request(req).await.unwrap();
let content = response["result"]["content"][0]["text"].as_str().unwrap();
assert!(content.contains("Remember to commit"));
}
#[tokio::test]
async fn test_handle_delete_relations() {
let handler = setup_test_handler("delete_relations");
{
let mut session = handler.state.session_graph.write().unwrap();
session.relations.push(crate::models::Relation {
from: "A".to_string(),
to: "B".to_string(),
relation_type: "calls".to_string(),
namespace: "".to_string(),
});
}
handler.state.apply_sync_write(|_| {}).await;
let req = json!({
"jsonrpc": "2.0",
"id": 15,
"method": "tools/call",
"params": {
"name": "delete_relations",
"arguments": {
"relations": [
{
"from": "A",
"to": "B",
"relationType": "calls",
"namespace": ""
}
]
}
}
});
let _ = handler.handle_request(req).await.unwrap();
let full = handler.state.get_full_graph();
assert!(full.relations.is_empty());
}
#[tokio::test]
async fn test_handle_read_graph() {
let handler = setup_test_handler("read_graph");
{
let mut session = handler.state.session_graph.write().unwrap();
session.entities.insert("NodeA".to_string(), crate::models::Entity {
name: "NodeA".to_string(),
entity_type: "var".to_string(),
observations: vec![],
namespace: "".to_string(),
git_branch: None,
});
}
handler.state.apply_sync_write(|_| {}).await;
let req = json!({
"jsonrpc": "2.0",
"id": 16,
"method": "tools/call",
"params": {
"name": "read_graph",
"arguments": {}
}
});
let response = handler.handle_request(req).await.unwrap();
let content = response["result"]["content"][0]["text"].as_str().unwrap();
assert!(content.contains("NodeA"));
}
#[tokio::test]
async fn test_handle_open_nodes() {
let handler = setup_test_handler("open_nodes");
let entity = crate::models::Entity {
name: "UserRepository".to_string(),
entity_type: "class".to_string(),
observations: vec!["Handles user data".to_string()],
namespace: "".to_string(),
git_branch: None,
};
{
let mut session = handler.state.session_graph.write().unwrap();
session.entities.insert("UserRepository".to_string(), entity);
}
handler.state.apply_sync_write(|_| {}).await;
let req = json!({
"jsonrpc": "2.0",
"id": 17,
"method": "tools/call",
"params": {
"name": "open_nodes",
"arguments": {
"names": ["UserRepository"]
}
}
});
let response = handler.handle_request(req).await.unwrap();
let content = response["result"]["content"][0]["text"].as_str().unwrap();
assert!(content.contains("UserRepository"));
assert!(content.contains("Handles user data"));
}
#[tokio::test]
async fn test_handle_log_decision() {
let handler = setup_test_handler("log_decision");
let req = json!({
"jsonrpc": "2.0",
"id": 20,
"method": "tools/call",
"params": {
"name": "log_decision",
"arguments": {
"title": "Use async I/O",
"context": "Need better throughput",
"decision": "Use tokio",
"consequence": "Requires async all the way down"
}
}
});
let _ = handler.handle_request(req).await.unwrap();
let adrs = handler.state.adrs.read();
assert_eq!(adrs.len(), 1);
assert_eq!(adrs[0].title, "Use async I/O");
}
#[tokio::test]
async fn test_handle_query_decisions() {
let handler = setup_test_handler("query_decisions");
handler.state.adrs.modify(|adrs| {
adrs.push(crate::models::Adr {
id: "adr-1".to_string(),
title: "Use PostgreSQL".to_string(),
context: "Need relational data".to_string(),
decision: "Use pg".to_string(),
consequence: "Maintenance overhead".to_string(),
timestamp: 0,
});
});
let req = json!({
"jsonrpc": "2.0",
"id": 21,
"method": "tools/call",
"params": {
"name": "query_decisions",
"arguments": {
"query": "Postgre"
}
}
});
let response = handler.handle_request(req).await.unwrap();
let content = response["result"]["content"][0]["text"].as_str().unwrap();
assert!(content.contains("PostgreSQL"));
}
#[tokio::test]
async fn test_handle_log_error_fix() {
let handler = setup_test_handler("log_error_fix");
let req = json!({
"jsonrpc": "2.0",
"id": 22,
"method": "tools/call",
"params": {
"name": "log_error_fix",
"arguments": {
"signature": "IndexOutOfBounds",
"solution": "Check array length"
}
}
});
let _ = handler.handle_request(req).await.unwrap();
let fixes = handler.state.error_fixes.read();
assert_eq!(fixes.len(), 1);
assert_eq!(fixes[0].signature, "IndexOutOfBounds");
}
#[tokio::test]
async fn test_handle_search_error_fixes() {
let handler = setup_test_handler("search_error_fixes");
handler.state.error_fixes.modify(|fixes| {
fixes.push(crate::models::ErrorFix {
signature: "NullPointerException".to_string(),
solution: "Initialize the pointer".to_string(),
timestamp: 0,
git_branch: None,
git_commit: None,
});
});
let req = json!({
"jsonrpc": "2.0",
"id": 23,
"method": "tools/call",
"params": {
"name": "search_error_fixes",
"arguments": {
"query": "NullPointer"
}
}
});
let response = handler.handle_request(req).await.unwrap();
let content = response["result"]["content"][0]["text"].as_str().unwrap();
assert!(content.contains("Initialize the pointer"));
}
#[tokio::test]
async fn test_handle_list_pinned_files() {
let handler = setup_test_handler("list_pinned_files");
handler.state.pinned_files.modify(|files| {
files.push(crate::models::PinnedFile {
file_path: "src/important.rs".to_string(),
timestamp: 0,
namespace: "".to_string(),
git_branch: None,
});
});
let req = json!({
"jsonrpc": "2.0",
"id": 24,
"method": "tools/call",
"params": {
"name": "list_pinned_files",
"arguments": {}
}
});
let response = handler.handle_request(req).await.unwrap();
let content = response["result"]["content"][0]["text"].as_str().unwrap();
assert!(content.contains("important.rs"));
}
#[tokio::test]
async fn test_handle_add_session_summary() {
let handler = setup_test_handler("add_session_summary");
let req = json!({
"jsonrpc": "2.0",
"id": 25,
"method": "tools/call",
"params": {
"name": "add_session_summary",
"arguments": {
"namespace": "",
"summary": "Finished writing tests"
}
}
});
let _ = handler.handle_request(req).await.unwrap();
let summaries = handler.state.session_summaries.read();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].summary, "Finished writing tests");
}
#[tokio::test]
async fn test_handle_get_project_timeline() {
let handler = setup_test_handler("get_project_timeline");
handler.state.session_summaries.modify(|summaries| {
summaries.push(crate::models::SessionSummary {
summary: "Day 1: Setup project".to_string(),
namespace: "".to_string(),
timestamp: 0,
});
});
let req = json!({
"jsonrpc": "2.0",
"id": 26,
"method": "tools/call",
"params": {
"name": "get_project_timeline",
"arguments": {
"namespace": ""
}
}
});
let response = handler.handle_request(req).await.unwrap();
let content = response["result"]["content"][0]["text"].as_str().unwrap();
assert!(content.contains("Day 1: Setup project"));
}
#[tokio::test]
async fn test_handle_log_tech_debt() {
let handler = setup_test_handler("log_tech_debt");
let req = json!({
"jsonrpc": "2.0",
"id": 27,
"method": "tools/call",
"params": {
"name": "log_tech_debt",
"arguments": {
"namespace": "",
"description": "Hardcoded values",
"ideal_solution": "Remove magic numbers"
}
}
});
let _ = handler.handle_request(req).await.unwrap();
let debt = handler.state.tech_debts.read();
assert_eq!(debt.len(), 1);
assert_eq!(debt[0].description, "Hardcoded values");
}
#[tokio::test]
async fn test_handle_list_tech_debt() {
let handler = setup_test_handler("list_tech_debt");
handler.state.tech_debts.modify(|debts| {
debts.push(crate::models::TechDebt {
id: "debt-1".to_string(),
description: "Bad naming".to_string(),
ideal_solution: "Rename x to num_elements".to_string(),
namespace: "".to_string(),
is_resolved: false,
created_at: 0,
git_branch: None,
git_commit: None,
});
});
let req = json!({
"jsonrpc": "2.0",
"id": 28,
"method": "tools/call",
"params": {
"name": "list_tech_debt",
"arguments": {
"namespace": "",
"include_resolved": false
}
}
});
let response = handler.handle_request(req).await.unwrap();
let content = response["result"]["content"][0]["text"].as_str().unwrap();
assert!(content.contains("Bad naming"));
}
#[tokio::test]
async fn test_handle_get_project_health() {
let handler = setup_test_handler("get_project_health");
let req = json!({
"jsonrpc": "2.0",
"id": 29,
"method": "tools/call",
"params": {
"name": "get_project_health",
"arguments": {
"namespace": ""
}
}
});
let response = handler.handle_request(req).await.unwrap();
let content = response["result"]["content"][0]["text"].as_str().unwrap();
assert!(content.contains("\"active_tasks\""));
assert!(content.contains("\"unresolved_tech_debt\""));
}
#[tokio::test]
async fn test_handle_resolve_tech_debt() {
let handler = setup_test_handler("resolve_tech_debt");
handler.state.tech_debts.modify(|debts| {
debts.push(crate::models::TechDebt {
id: "debt-2".to_string(),
description: "Old api".to_string(),
ideal_solution: "Use new api".to_string(),
namespace: "".to_string(),
is_resolved: false,
created_at: 0,
git_branch: None,
git_commit: None,
});
});
let req = json!({
"jsonrpc": "2.0",
"id": 30,
"method": "tools/call",
"params": {
"name": "resolve_tech_debt",
"arguments": {
"id": "debt-2"
}
}
});
let _ = handler.handle_request(req).await.unwrap();
let debts = handler.state.tech_debts.read();
assert!(debts[0].is_resolved);
}
#[tokio::test]
async fn test_handle_leave_handoff_memo() {
let handler = setup_test_handler("leave_handoff_memo");
let req = json!({
"jsonrpc": "2.0",
"id": 31,
"method": "tools/call",
"params": {
"name": "leave_handoff_memo",
"arguments": {
"namespace": "",
"content": "Make sure to check the logs.",
"author": "Riz"
}
}
});
let _ = handler.handle_request(req).await.unwrap();
let memos = handler.state.handoff_memos.read();
assert_eq!(memos.len(), 1);
assert_eq!(memos[0].content, "Make sure to check the logs.");
}
#[tokio::test]
async fn test_handle_query_recent_changes() {
let handler = setup_test_handler("query_recent_changes");
handler.state.ledger.modify(|ledger| {
ledger.push(crate::models::CodeChange {
timestamp: 0,
file_path: "src/main.rs".to_string(),
description: "Fix bug".to_string(),
git_commit: None,
git_branch: None,
});
});
let req = json!({
"jsonrpc": "2.0",
"id": 18,
"method": "tools/call",
"params": {
"name": "query_recent_changes",
"arguments": {}
}
});
let response = handler.handle_request(req).await.unwrap();
let content = response["result"]["content"][0]["text"].as_str().unwrap();
assert!(content.contains("Fix bug"));
assert!(content.contains("src/main.rs"));
}
#[tokio::test]
async fn test_handle_update_task_status() {
let handler = setup_test_handler("update_task_status");
handler.state.tasks.modify(|tasks| {
tasks.push(crate::models::Task {
id: "test-task-123".to_string(),
title: "In progress task".to_string(),
status: "pending".to_string(),
description: "".to_string(),
created_at: 0,
updated_at: 0,
git_branch: None,
});
});
let req = json!({
"jsonrpc": "2.0",
"id": 19,
"method": "tools/call",
"params": {
"name": "update_task_status",
"arguments": {
"id": "test-task-123",
"status": "in_progress"
}
}
});
let _ = handler.handle_request(req).await.unwrap();
let tasks = handler.state.tasks.read();
assert_eq!(tasks[0].status, "in_progress");
}
}
+23 -7
View File
@@ -1,3 +1,5 @@
#![cfg_attr(not(target_os = "windows"), allow(dead_code, unused_imports, unreachable_code))]
mod handlers; mod handlers;
mod mcp; mod mcp;
mod models; mod models;
@@ -22,7 +24,7 @@ use clap::{Parser, Subcommand};
use std::collections::HashMap; use std::collections::HashMap;
#[derive(Parser)] #[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 { struct Cli {
#[command(subcommand)] #[command(subcommand)]
command: Option<Commands>, command: Option<Commands>,
@@ -320,7 +322,7 @@ fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>>
let app = Router::new() let app = Router::new()
.route("/api/version", get(|| async move { .route("/api/version", get(|| async move {
axum::Json(serde_json::json!({ axum::Json(serde_json::json!({
"version": env!("BUILD_DATE"), "version": env!("APP_VERSION"),
"git_hash": option_env!("GIT_HASH").unwrap_or("unknown") "git_hash": option_env!("GIT_HASH").unwrap_or("unknown")
})) }))
})) }))
@@ -546,6 +548,17 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, client_type: Str
} }
}); });
if client_type == "proxy" {
let tx_clone = tx.clone();
tokio::spawn(async move {
let notify = serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed"
});
let _ = tx_clone.send(notify.to_string()).await;
});
}
let handler = Arc::clone(&state.handler); let handler = Arc::clone(&state.handler);
let state_clone = Arc::clone(&state); let state_clone = Arc::clone(&state);
let session_id_clone = session_id.clone(); let session_id_clone = session_id.clone();
@@ -707,12 +720,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
#[cfg(not(target_os = "windows"))] #[cfg(target_os = "windows")]
{ {
// Linux no longer executes server logic natively due to workspace split
return Ok(());
}
let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| { let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| {
dirs::home_dir() dirs::home_dir()
.map(|mut h| { .map(|mut h| {
@@ -800,6 +809,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
state.rebuild_index(); state.rebuild_index();
run_server(state) run_server(state)
}
#[cfg(not(target_os = "windows"))]
{
// Linux no longer executes server logic natively due to workspace split
Ok(())
}
} }
+1 -1
View File
@@ -1,7 +1,7 @@
use crate::models::*; use crate::models::*;
use crate::search::MemoryIndex; use crate::search::MemoryIndex;
use crate::store::Store; use crate::store::Store;
use std::collections::{HashMap, HashSet}; use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::RwLock; use std::sync::RwLock;
+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);
}
+8 -4
View File
@@ -5,7 +5,7 @@ use tokio::io::AsyncBufReadExt;
use tokio::sync::mpsc; use tokio::sync::mpsc;
#[derive(Parser)] #[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 { struct Cli {
/// Target URL for the stub to proxy messages to /// Target URL for the stub to proxy messages to
#[arg(long, default_value = "http://localhost:3000")] #[arg(long, default_value = "http://localhost:3000")]
@@ -20,15 +20,19 @@ async fn read_mcp_message(stdin: &mut tokio::io::BufReader<tokio::io::Stdin>) ->
let mut length = 0; let mut length = 0;
loop { loop {
let mut line = String::new(); let mut line = String::new();
if stdin.read_line(&mut line).await.unwrap_or(0) == 0 { let bytes_read = stdin.read_line(&mut line).await.unwrap_or(0);
if bytes_read == 0 {
tracing::info!("stdin EOF reached");
return None; return None;
} }
tracing::info!("Read {} bytes from stdin: {:?}", bytes_read, line);
let line = line.trim_end(); let line = line.trim_end();
if line.is_empty() { if line.is_empty() {
break; break;
} }
if let Some(len_str) = line.strip_prefix("Content-Length: ") { let lower_line = line.to_lowercase();
length = len_str.parse().unwrap_or(0); if let Some(len_str) = lower_line.strip_prefix("content-length:") {
length = len_str.trim().parse().unwrap_or(0);
} }
} }
if length == 0 { if length == 0 {
+28 -9
View File
@@ -2,8 +2,6 @@ use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Read, Write}; use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::time::Duration; use std::time::Duration;
use std::sync::Arc;
use tokio::sync::Mutex;
fn send_message(stdin: &mut std::process::ChildStdin, msg: Value) { fn send_message(stdin: &mut std::process::ChildStdin, msg: Value) {
let s = serde_json::to_string(&msg).unwrap(); let s = serde_json::to_string(&msg).unwrap();
@@ -48,14 +46,27 @@ async fn test_full_system_e2e_performance() {
let test_port = "3031"; // Use a distinct port let test_port = "3031"; // Use a distinct port
let mut server_exe = std::env::current_exe().unwrap(); let mut exe_dir = std::env::current_exe().unwrap();
server_exe.pop(); exe_dir.pop(); // pop test executable name
server_exe.pop(); exe_dir.pop(); // pop deps/
server_exe.push("mcp-memory-server.exe");
let mut nvim_exe = server_exe.clone(); let mut server_exe = exe_dir.join("mcp-memory-server.exe");
nvim_exe.pop(); if !server_exe.exists() {
nvim_exe.push("mcp-memory-win-nvim.exe"); 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 // 1. Start Server
let mut server = Command::new(&server_exe) let mut server = Command::new(&server_exe)
@@ -132,3 +143,11 @@ async fn test_full_system_e2e_performance() {
let _ = win_nvim.kill(); let _ = win_nvim.kill();
let _ = server.kill(); let _ = server.kill();
} }
+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);
}
+9 -2
View File
@@ -31,6 +31,11 @@ fn init_logging(app_name: &str) -> Option<tracing_appender::non_blocking::Worker
#[tokio::main] #[tokio::main]
async fn 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"); let _guard = init_logging("win-nvim");
tracing::info!("win-nvim MCP server started"); tracing::info!("win-nvim MCP server started");
let mut stdin = tokio::io::BufReader::new(tokio::io::stdin()); let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
@@ -48,7 +53,7 @@ async fn main() {
tokio::spawn(async move { tokio::spawn(async move {
let id = msg.id.clone().unwrap_or(json!(null)); let id = msg.id.clone().unwrap_or(json!(null));
let start_time = std::time::Instant::now(); let _start_time = std::time::Instant::now();
match msg.method.as_str() { match msg.method.as_str() {
"initialize" => { "initialize" => {
@@ -57,7 +62,9 @@ async fn main() {
id, id,
result: Some(json!({ result: Some(json!({
"protocolVersion": "2024-11-05", "protocolVersion": "2024-11-05",
"capabilities": {}, "capabilities": {
"tools": {}
},
"serverInfo": { "serverInfo": {
"name": "mcp-memory-win-nvim", "name": "mcp-memory-win-nvim",
"version": "0.1.0" "version": "0.1.0"
-1
View File
@@ -1,7 +1,6 @@
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Read, Write}; use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::time::Duration;
fn send_message(stdin: &mut std::process::ChildStdin, msg: Value) { fn send_message(stdin: &mut std::process::ChildStdin, msg: Value) {
let s = serde_json::to_string(&msg).unwrap(); let s = serde_json::to_string(&msg).unwrap();