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
+2008
-720
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**:
|
||||
* **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
|
||||
`
|
||||
|
||||
@@ -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_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};
|
||||
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)
|
||||
#[cfg(unix)]
|
||||
fn main() {
|
||||
if std::env::args().any(|a| a == "--version" || a == "-V") {
|
||||
println!("mcp-memory-linux-nvim {}", env!("APP_VERSION"));
|
||||
return;
|
||||
}
|
||||
unix_app::main();
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
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())
|
||||
#[cfg(not(unix))]
|
||||
fn main() {
|
||||
println!("mcp-memory-linux-nvim is only supported on Unix platforms.");
|
||||
}
|
||||
@@ -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 chrono::Local;
|
||||
|
||||
fn main() {
|
||||
let now = Local::now();
|
||||
let build_date = now.format("%Y.%m.%d").to_string();
|
||||
println!("cargo:rustc-env=BUILD_DATE={}", build_date);
|
||||
let git_hash = Command::new("git")
|
||||
.args(&["rev-parse", "--short", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
if let Ok(output) = Command::new("git").args(&["rev-parse", "--short", "HEAD"]).output() {
|
||||
if let Ok(hash) = String::from_utf8(output.stdout) {
|
||||
println!("cargo:rustc-env=GIT_HASH={}", hash.trim());
|
||||
}
|
||||
}
|
||||
println!("cargo:rerun-if-changed=../.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=../.git/index");
|
||||
let 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);
|
||||
}
|
||||
+979
-2
@@ -2,7 +2,7 @@ use crate::models::*;
|
||||
use crate::state::MemoryState;
|
||||
use crate::tools::*;
|
||||
|
||||
use serde::{Deserialize, de::DeserializeOwned};
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -26,7 +26,9 @@ impl MemoryHandler {
|
||||
id,
|
||||
serde_json::json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "gemini-mcp-memory",
|
||||
"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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
+97
-81
@@ -1,3 +1,5 @@
|
||||
#![cfg_attr(not(target_os = "windows"), allow(dead_code, unused_imports, unreachable_code))]
|
||||
|
||||
mod handlers;
|
||||
mod mcp;
|
||||
mod models;
|
||||
@@ -22,7 +24,7 @@ use clap::{Parser, Subcommand};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(author, version, about = "Antigravity MCP Memory Server", long_about = None)]
|
||||
#[command(author, version = env!("APP_VERSION"), about = "Antigravity MCP Memory Server", long_about = None)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
@@ -320,7 +322,7 @@ fn run_server(state: Arc<MemoryState>) -> Result<(), Box<dyn std::error::Error>>
|
||||
let app = Router::new()
|
||||
.route("/api/version", get(|| async move {
|
||||
axum::Json(serde_json::json!({
|
||||
"version": env!("BUILD_DATE"),
|
||||
"version": env!("APP_VERSION"),
|
||||
"git_hash": option_env!("GIT_HASH").unwrap_or("unknown")
|
||||
}))
|
||||
}))
|
||||
@@ -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 state_clone = Arc::clone(&state);
|
||||
let session_id_clone = session_id.clone();
|
||||
@@ -707,99 +720,102 @@ 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(|_| {
|
||||
dirs::home_dir()
|
||||
.map(|mut h| {
|
||||
h.push(".gemini/mcp_memory");
|
||||
h.to_string_lossy().into_owned()
|
||||
})
|
||||
.unwrap_or_else(|| ".gemini/mcp_memory".into())
|
||||
});
|
||||
let base = PathBuf::from(base_dir);
|
||||
fs::create_dir_all(&base).expect("Failed to create store dir");
|
||||
|
||||
let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| {
|
||||
dirs::home_dir()
|
||||
.map(|mut h| {
|
||||
h.push(".gemini/mcp_memory");
|
||||
h.to_string_lossy().into_owned()
|
||||
})
|
||||
.unwrap_or_else(|| ".gemini/mcp_memory".into())
|
||||
});
|
||||
let base = PathBuf::from(base_dir);
|
||||
fs::create_dir_all(&base).expect("Failed to create store dir");
|
||||
let redb_path = base.join("mcp_store.redb");
|
||||
let db = Arc::new(redb::Database::create(&redb_path).unwrap());
|
||||
|
||||
let redb_path = base.join("mcp_store.redb");
|
||||
let db = Arc::new(redb::Database::create(&redb_path).unwrap());
|
||||
|
||||
// Ensure table exists and migrate old JSON files
|
||||
{
|
||||
let write_txn = db.begin_write().unwrap();
|
||||
// Ensure table exists and migrate old JSON files
|
||||
{
|
||||
let mut table = write_txn.open_table(crate::store::STORE_TABLE).unwrap();
|
||||
let write_txn = db.begin_write().unwrap();
|
||||
{
|
||||
let mut table = write_txn.open_table(crate::store::STORE_TABLE).unwrap();
|
||||
|
||||
let stores = [
|
||||
("audit_ledger", "audit_ledger.json"),
|
||||
("sticky_notes", "sticky_notes.json"),
|
||||
("tasks", "tasks.json"),
|
||||
("snippets", "snippets.json"),
|
||||
("adrs", "adrs.json"),
|
||||
("preferences", "preferences.json"),
|
||||
("error_fixes", "error_fixes.json"),
|
||||
("pinned_files", "pinned_files.json"),
|
||||
("session_summaries", "session_summaries.json"),
|
||||
("handoff_memos", "handoff_memos.json"),
|
||||
("env_fingerprints", "env_fingerprints.json"),
|
||||
("env_requirements", "env_requirements.json"),
|
||||
("milestones", "milestones.json"),
|
||||
("environments", "environments.json"),
|
||||
("pr_checklists", "pr_checklists.json"),
|
||||
("tech_debts", "tech_debts.json"),
|
||||
("gates", "gates.json"),
|
||||
("context_workspaces", "context_workspaces.json"),
|
||||
];
|
||||
let stores = [
|
||||
("audit_ledger", "audit_ledger.json"),
|
||||
("sticky_notes", "sticky_notes.json"),
|
||||
("tasks", "tasks.json"),
|
||||
("snippets", "snippets.json"),
|
||||
("adrs", "adrs.json"),
|
||||
("preferences", "preferences.json"),
|
||||
("error_fixes", "error_fixes.json"),
|
||||
("pinned_files", "pinned_files.json"),
|
||||
("session_summaries", "session_summaries.json"),
|
||||
("handoff_memos", "handoff_memos.json"),
|
||||
("env_fingerprints", "env_fingerprints.json"),
|
||||
("env_requirements", "env_requirements.json"),
|
||||
("milestones", "milestones.json"),
|
||||
("environments", "environments.json"),
|
||||
("pr_checklists", "pr_checklists.json"),
|
||||
("tech_debts", "tech_debts.json"),
|
||||
("gates", "gates.json"),
|
||||
("context_workspaces", "context_workspaces.json"),
|
||||
];
|
||||
|
||||
for (key, file_name) in stores.iter() {
|
||||
if table.get(*key).unwrap().is_none() {
|
||||
let json_path = base.join(file_name);
|
||||
if json_path.exists() {
|
||||
if let Ok(data) = fs::read(&json_path) {
|
||||
if serde_json::from_slice::<serde_json::Value>(&data).is_ok() {
|
||||
table.insert(*key, data.as_slice()).unwrap();
|
||||
for (key, file_name) in stores.iter() {
|
||||
if table.get(*key).unwrap().is_none() {
|
||||
let json_path = base.join(file_name);
|
||||
if json_path.exists() {
|
||||
if let Ok(data) = fs::read(&json_path) {
|
||||
if serde_json::from_slice::<serde_json::Value>(&data).is_ok() {
|
||||
table.insert(*key, data.as_slice()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
write_txn.commit().unwrap();
|
||||
}
|
||||
write_txn.commit().unwrap();
|
||||
|
||||
let state = Arc::new(MemoryState {
|
||||
master_path: base.join("knowledge_graph_master.json"),
|
||||
session_graph: RwLock::new(KnowledgeGraph::default()),
|
||||
base_dir: base.clone(),
|
||||
master_cache: RwLock::new((KnowledgeGraph::default(), SystemTime::UNIX_EPOCH)),
|
||||
search_index: RwLock::new(crate::search::MemoryIndex::new(&base).unwrap()),
|
||||
ledger: Store::new("audit_ledger", db.clone()),
|
||||
sticky: Store::new("sticky_notes", db.clone()),
|
||||
tasks: Store::new("tasks", db.clone()),
|
||||
snippets: Store::new("snippets", db.clone()),
|
||||
adrs: Store::new("adrs", db.clone()),
|
||||
prefs: Store::new("preferences", db.clone()),
|
||||
error_fixes: Store::new("error_fixes", db.clone()),
|
||||
pinned_files: Store::new("pinned_files", db.clone()),
|
||||
session_summaries: Store::new("session_summaries", db.clone()),
|
||||
handoff_memos: Store::new("handoff_memos", db.clone()),
|
||||
env_fingerprints: Store::new("env_fingerprints", db.clone()),
|
||||
env_requirements: Store::new("env_requirements", db.clone()),
|
||||
milestones: Store::new("milestones", db.clone()),
|
||||
environments: Store::new("environments", db.clone()),
|
||||
pr_checklists: Store::new("pr_checklists", db.clone()),
|
||||
tech_debts: Store::new("tech_debts", db.clone()),
|
||||
gates: Store::new("gates", db.clone()),
|
||||
context_workspaces: Store::new("context_workspaces", db.clone()),
|
||||
});
|
||||
|
||||
state.recover_wal();
|
||||
state.rebuild_index();
|
||||
|
||||
run_server(state)
|
||||
}
|
||||
|
||||
let state = Arc::new(MemoryState {
|
||||
master_path: base.join("knowledge_graph_master.json"),
|
||||
session_graph: RwLock::new(KnowledgeGraph::default()),
|
||||
base_dir: base.clone(),
|
||||
master_cache: RwLock::new((KnowledgeGraph::default(), SystemTime::UNIX_EPOCH)),
|
||||
search_index: RwLock::new(crate::search::MemoryIndex::new(&base).unwrap()),
|
||||
ledger: Store::new("audit_ledger", db.clone()),
|
||||
sticky: Store::new("sticky_notes", db.clone()),
|
||||
tasks: Store::new("tasks", db.clone()),
|
||||
snippets: Store::new("snippets", db.clone()),
|
||||
adrs: Store::new("adrs", db.clone()),
|
||||
prefs: Store::new("preferences", db.clone()),
|
||||
error_fixes: Store::new("error_fixes", db.clone()),
|
||||
pinned_files: Store::new("pinned_files", db.clone()),
|
||||
session_summaries: Store::new("session_summaries", db.clone()),
|
||||
handoff_memos: Store::new("handoff_memos", db.clone()),
|
||||
env_fingerprints: Store::new("env_fingerprints", db.clone()),
|
||||
env_requirements: Store::new("env_requirements", db.clone()),
|
||||
milestones: Store::new("milestones", db.clone()),
|
||||
environments: Store::new("environments", db.clone()),
|
||||
pr_checklists: Store::new("pr_checklists", db.clone()),
|
||||
tech_debts: Store::new("tech_debts", db.clone()),
|
||||
gates: Store::new("gates", db.clone()),
|
||||
context_workspaces: Store::new("context_workspaces", db.clone()),
|
||||
});
|
||||
|
||||
state.recover_wal();
|
||||
state.rebuild_index();
|
||||
|
||||
run_server(state)
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Linux no longer executes server logic natively due to workspace split
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
use crate::models::*;
|
||||
use crate::search::MemoryIndex;
|
||||
use crate::store::Store;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::RwLock;
|
||||
|
||||
@@ -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;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "mcp-memory-stub", author, version, about = "Antigravity MCP Memory Stub / Proxy", long_about = None)]
|
||||
#[command(name = "mcp-memory-stub", author, version = env!("APP_VERSION"), about = "Antigravity MCP Memory Stub / Proxy", long_about = None)]
|
||||
struct Cli {
|
||||
/// Target URL for the stub to proxy messages to
|
||||
#[arg(long, default_value = "http://localhost:3000")]
|
||||
@@ -20,15 +20,19 @@ async fn read_mcp_message(stdin: &mut tokio::io::BufReader<tokio::io::Stdin>) ->
|
||||
let mut length = 0;
|
||||
loop {
|
||||
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;
|
||||
}
|
||||
tracing::info!("Read {} bytes from stdin: {:?}", bytes_read, line);
|
||||
let line = line.trim_end();
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some(len_str) = line.strip_prefix("Content-Length: ") {
|
||||
length = len_str.parse().unwrap_or(0);
|
||||
let lower_line = line.to_lowercase();
|
||||
if let Some(len_str) = lower_line.strip_prefix("content-length:") {
|
||||
length = len_str.trim().parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
if length == 0 {
|
||||
|
||||
+28
-9
@@ -2,8 +2,6 @@ use serde_json::{json, Value};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
fn send_message(stdin: &mut std::process::ChildStdin, msg: Value) {
|
||||
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 mut server_exe = std::env::current_exe().unwrap();
|
||||
server_exe.pop();
|
||||
server_exe.pop();
|
||||
server_exe.push("mcp-memory-server.exe");
|
||||
let mut exe_dir = std::env::current_exe().unwrap();
|
||||
exe_dir.pop(); // pop test executable name
|
||||
exe_dir.pop(); // pop deps/
|
||||
|
||||
let mut nvim_exe = server_exe.clone();
|
||||
nvim_exe.pop();
|
||||
nvim_exe.push("mcp-memory-win-nvim.exe");
|
||||
let mut server_exe = exe_dir.join("mcp-memory-server.exe");
|
||||
if !server_exe.exists() {
|
||||
let mut target_dir = std::env::current_dir().unwrap();
|
||||
if target_dir.ends_with("stub") {
|
||||
target_dir.pop();
|
||||
}
|
||||
server_exe = target_dir.join("target").join("debug").join("mcp-memory-server.exe");
|
||||
}
|
||||
|
||||
let mut nvim_exe = exe_dir.join("mcp-memory-win-nvim.exe");
|
||||
if !nvim_exe.exists() {
|
||||
let mut target_dir = std::env::current_dir().unwrap();
|
||||
if target_dir.ends_with("stub") {
|
||||
target_dir.pop();
|
||||
}
|
||||
nvim_exe = target_dir.join("target").join("debug").join("mcp-memory-win-nvim.exe");
|
||||
}
|
||||
|
||||
// 1. Start Server
|
||||
let mut server = Command::new(&server_exe)
|
||||
@@ -132,3 +143,11 @@ async fn test_full_system_e2e_performance() {
|
||||
let _ = win_nvim.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]
|
||||
async fn main() {
|
||||
if std::env::args().any(|a| a == "--version" || a == "-V") {
|
||||
println!("mcp-memory-win-nvim {}", env!("APP_VERSION"));
|
||||
return;
|
||||
}
|
||||
|
||||
let _guard = init_logging("win-nvim");
|
||||
tracing::info!("win-nvim MCP server started");
|
||||
let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
|
||||
@@ -48,7 +53,7 @@ async fn main() {
|
||||
|
||||
tokio::spawn(async move {
|
||||
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() {
|
||||
"initialize" => {
|
||||
@@ -57,7 +62,9 @@ async fn main() {
|
||||
id,
|
||||
result: Some(json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "mcp-memory-win-nvim",
|
||||
"version": "0.1.0"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
fn send_message(stdin: &mut std::process::ChildStdin, msg: Value) {
|
||||
let s = serde_json::to_string(&msg).unwrap();
|
||||
|
||||
Reference in new issue
Block a user