docs: update architecture and design docs with proxy reconnect and deployment strategies
This commit is contained in:
1 parent
a9a861abe1
commit
1da413568e
18 files changed
+1934
-646
No files matched your search
@@ -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
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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())
|
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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| {
|
||||||
@@ -802,6 +811,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,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();
|
||||||
|
|||||||
Reference in new issue
Block a user