diff --git a/Cargo.lock b/Cargo.lock index 987d521..b6ffe97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1241,6 +1241,7 @@ dependencies = [ "tempfile", "thiserror", "tokio", + "tower", "tracing", "tracing-appender", "tracing-subscriber", diff --git a/README.md b/README.md index 49be0ce..5c1eede 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Copy-Item target\release\mcp-memory-stub.exe C:\Users\reazul.ashraf\.local\bin\m **Step 1:** To bypass Antigravity's lazy-loading and ensure the server is instantly available for WSL, configure your PowerShell profile to auto-start the background server when you open a terminal: ```powershell # Add this to your PowerShell profile: -if (-not (Get-Process mcp-memory-server -ErrorAction SilentlyContinue)) { Start-Process -FilePath "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" -ArgumentList "--daemon" -WindowStyle Hidden -ErrorAction SilentlyContinue } +if (-not (Get-Process mcp-memory-server -ErrorAction SilentlyContinue)) { Start-Process -FilePath "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" -WindowStyle Hidden -ErrorAction SilentlyContinue } ``` **Shutting Down:** If you need to stop or restart the background daemon (e.g., to replace the executable after a recompile), you MUST NEVER use brute-force OS kill commands (e.g., Stop-Process, pkill, or kill). Instead, you MUST ALWAYS use the server's built-in graceful shutdown mechanisms: @@ -58,7 +58,7 @@ Update your WSL ~/.gemini/config/mcp_config.json: "command": "/home/riz/.local/bin/mcp-memory-stub", "args": [ "--target", "http://127.0.0.1:3000", - "--wake-cmd", "/mnt/c/Users/reazul.ashraf/.local/bin/mcp-memory-server.exe --daemon" + "--wake-cmd", "powershell.exe -NoProfile -WindowStyle Hidden -Command \"Start-Process -FilePath 'C:\\Users\\reazul.ashraf\\.local\\bin\\mcp-memory-server.exe' -WindowStyle Hidden\"" ] } } diff --git a/build.ps1 b/build.ps1 deleted file mode 100644 index 3dedc77..0000000 --- a/build.ps1 +++ /dev/null @@ -1,107 +0,0 @@ -$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 -wsl.exe -d Ubuntu -e bash -c 'cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && export PATH="$HOME/.cargo/bin:$PATH" && cargo build --release --workspace --exclude mcp-memory-win-nvim' -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 { - $tokenPath = "C:\Users\reazul.ashraf\.gemini\mcp_memory\admin.token" - $headers = @{} - if (Test-Path $tokenPath) { - $headers["Authorization"] = "Bearer $(Get-Content -Path $tokenPath -Raw)".Trim() - } - Invoke-RestMethod -Uri "https://127.0.0.1:3000/shutdown" -Method Post -Headers $headers -SkipCertificateCheck -ErrorAction Stop | Out-Null -} catch { - # The response often ends prematurely because the server abruptly kills its own process during shutdown. This is expected. -} -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\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 diff --git a/deploy.ps1 b/deploy.ps1 deleted file mode 100644 index 4987bb8..0000000 --- a/deploy.ps1 +++ /dev/null @@ -1,37 +0,0 @@ -try { - Write-Host "Gracefully shutting down server..." - $tokenPath = "C:\Users\reazul.ashraf\.gemini\mcp_memory\admin.token" - $headers = @{} - if (Test-Path $tokenPath) { - $headers["Authorization"] = "Bearer $(Get-Content -Path $tokenPath -Raw)".Trim() - } - Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:3000/shutdown" -Headers $headers -ErrorAction SilentlyContinue | Out-Null - Start-Sleep -Seconds 2 -} catch { - Write-Host "Server might already be down or unreachable." -} - -$binaries = @('mcp-memory-server.exe', 'mcp-memory-stub.exe', 'mcp-memory-win-nvim.exe') -$timestamp = (Get-Date -UFormat "%s") -replace "\.","" - -foreach ($b in $binaries) { - $s = "target\release\$b" - $d = "C:\Users\reazul.ashraf\.local\bin\$b" - - if (Test-Path $d) { - $backupName = "$b.$timestamp.bak" - Write-Host "Renaming $b to $backupName to avoid file locks..." - Rename-Item $d $backupName -Force -ErrorAction SilentlyContinue - } - - if (Test-Path $s) { - Write-Host "Deploying new $b..." - Copy-Item $s $d -Force - } else { - Write-Host "Warning: $s not found, skipping." - } -} - -Write-Host "Starting new mcp-memory-server daemon..." -Start-Process -FilePath "C:\Users\reazul.ashraf\.local\bin\mcp-memory-server.exe" -WindowStyle Hidden -Write-Host "Deployment complete." diff --git a/design.md b/design.md index 54ab35e..4473533 100644 --- a/design.md +++ b/design.md @@ -102,7 +102,7 @@ The server executable is responsible exclusively for running the Axum HTTP and W ### The Client Proxy (`mcp-memory-stub.exe`) All Antigravity sessions (Windows and WSL) use the lightweight `mcp-memory-stub` as their proxy. The stub connects to the server via WebSockets and acts as the bridge for standard `stdio` JSON-RPC traffic. -* **Startup via Interop/Spawn:** Upon launch, the stub attempts to connect to the Windows host on port 3000. If the server is offline, the stub automatically executes a spawn command (e.g., executing `mcp-memory-server.exe --daemon` natively, or via WSL interop) to silently wake up the Windows Leader before commencing the proxy loop. +* **Startup via Interop/Spawn:** Upon launch, the stub attempts to connect to the Windows host on port 3000. If the server is offline, the stub automatically executes a spawn command (e.g., executing `Start-Process` natively, or via WSL interop) to silently wake up the Windows Leader before commencing the proxy loop. * **MPSC Queue Resilience:** The stub utilizes an asynchronous multi-producer, single-consumer (MPSC) channel queue. If the Windows Leader daemon restarts or momentarily drops, the proxy buffers incoming JSON-RPC tool calls and infinitely retries them until the connection is restored. This guarantees **zero message loss** and **zero thread leaks** without crashing the active `agy` session. * **Zero I/O Penalty (WSL):** This ensures the Linux binary never directly touches the Windows NTFS files, reserving all heavy disk operations for the native Windows host. ## 13. Cargo Workspace & Binary Artifacts diff --git a/justfile b/justfile index 17fbaec..7161632 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,13 @@ -set shell := ["pwsh", "-NoProfile", "-Command"] +set shell := ["pwsh.exe", "-NoProfile", "-c"] -# Build and deploy everything across Windows and WSL +default: + @just --list + +# Build everything across Windows and WSL +build-all: build-win build-wsl + Write-Host "Build complete across both OS boundaries." -ForegroundColor Green + +# Deploy everything across Windows and WSL deploy-all: deploy-win deploy-wsl Write-Host "Deployment complete across both OS boundaries." -ForegroundColor Green @@ -11,19 +18,36 @@ shutdown-server: Start-Sleep -Seconds 1 -if (Get-Process mcp-memory-server, mcp-memory-stub, mcp-memory-win-nvim -ErrorAction SilentlyContinue) { Stop-Process -Name mcp-memory-server, mcp-memory-stub, mcp-memory-win-nvim -Force -ErrorAction SilentlyContinue } -# Build and deploy Windows-native binaries -deploy-win: shutdown-server +# Build Windows-native binaries +build-win: Write-Host "Building Windows binaries..." -ForegroundColor Cyan cargo build --release -p mcp-memory-server -p mcp-memory-stub -p mcp-memory-win-nvim + +# Deploy Windows-native binaries +deploy-win: shutdown-server build-win Write-Host "Deploying Windows binaries..." -ForegroundColor Cyan Copy-Item -Force target\release\mcp-memory-stub.exe "C:\Users\reazul.ashraf\.local\bin\"; Copy-Item -Force target\release\mcp-memory-win-nvim.exe "C:\Users\reazul.ashraf\.local\bin\" Copy-Item -Force target\release\mcp-memory-server.exe "C:\Users\reazul.ashraf\.local\bin\" -# Build and deploy WSL-native binaries -deploy-wsl: - Write-Host "Building and deploying WSL binaries natively..." -ForegroundColor Cyan - wsl.exe -d Ubuntu -e bash -c 'export PATH="$PATH:/home/riz/.cargo/bin" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cargo build --release -p mcp-memory-stub -p mcp-memory-linux-nvim && cp target/release/mcp-memory-stub /home/riz/.local/bin/ && cp target/release/mcp-memory-linux-nvim /home/riz/.local/bin/' +# Build WSL-native binaries +build-wsl: + Write-Host "Building WSL binaries natively..." -ForegroundColor Cyan + wsl.exe -d Ubuntu -e bash -c 'export PATH="$PATH:/home/riz/.cargo/bin" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cargo build --release -p mcp-memory-stub -p mcp-memory-linux-nvim' + +# Deploy WSL-native binaries +deploy-wsl: build-wsl + Write-Host "Deploying WSL binaries natively..." -ForegroundColor Cyan + wsl.exe -d Ubuntu -e bash -c 'export PATH="$PATH:/home/riz/.cargo/bin" && cd /mnt/c/Users/reazul.ashraf/workspace/rust/mcp-memory && cp target/release/mcp-memory-stub /home/riz/.local/bin/ && cp target/release/mcp-memory-linux-nvim /home/riz/.local/bin/' # Run configuration tests to ensure eagerTools parity test-config: cargo test --release -p mcp-memory-server --test parity_test + +# Run tests and generate code coverage report +test-coverage: + @Write-Host "Running tests with code coverage..." -ForegroundColor Cyan + rustup run stable cargo llvm-cov --workspace + +# Start the server directly +startup-server: + cargo run -p mcp-memory-server diff --git a/nvim-core/src/instructions.md b/nvim-core/src/instructions.md new file mode 100644 index 0000000..c4e63dc --- /dev/null +++ b/nvim-core/src/instructions.md @@ -0,0 +1,45 @@ +# Antigravity Neovim MCP Instructions + +When connected to this Neovim MCP server (win-nvim or linux-nvim), you have powerful tools to interact directly with the active Neovim editor. + +## The Tool Arsenal +The following tools are available (many should be agerTools): +- **File/Buffer Mgmt:** +vim_open_file, +vim_open_buffer, +vim_close_buffer, +vim_split_window, +vim_reload_buffer, +vim_save_buffer +- **State Reading:** +vim_get_active_buffer, +vim_get_cursor, +vim_get_visual_selection, +vim_get_viewport, +vim_get_messages +- **Diagnostics & Visuals:** +vim_set_diagnostics, +vim_highlight_lines, +vim_set_quickfix +- **God Mode:** +vim_execute_lua + +## 1. Using Dedicated Tools First +Always prefer the specific dedicated tools (like +vim_open_file, +vim_highlight_lines, etc.) over writing raw Lua scripts. These tools are tested and safe. + +## 2. Lua God Mode ( +vim_execute_lua) +If you need to access *any* Neovim API that does not have a dedicated tool (e.g., complex buffer edits, changing options, LSP interactions), you MUST use +vim_execute_lua as your escape hatch. + +### CRITICAL RULES for +vim_execute_lua: +1. **Never Block:** Never use interactive prompts ( im.fn.input, im.ui.select, im.fn.confirm) or confirmation flags in regex substitutions (e.g., %s/old/new/gc). This will cause the headless MCP bridge to deadlock forever. +2. **Visual Feedback:** Always trigger a notification using equire("notify")("Antigravity: [Action]", "info", { title = "Antigravity" }). +3. **Auto-Save:** If you modify a file buffer, always save it using im.cmd('write') within the same Lua script so external tools can see the changes, unless you explicitly want to pause for manual human review. +4. **Buffer Focus:** When making changes to a specific buffer, always ensure the active window is switched to that buffer, and optionally move the cursor so the human can see the change visually. + +## 3. The "Unix is NOT King" Rule +When Neovim is running and you need to perform file read/writes or search and replace within an opened project, **prioritize Neovim tools over basic unix terminal utilities** (like cat, grep, sed, wk, or PowerShell equivalents). Use Neovim as your primary interface to the codebase. diff --git a/nvim-core/src/lib.rs b/nvim-core/src/lib.rs index 60e3299..d27d936 100644 --- a/nvim-core/src/lib.rs +++ b/nvim-core/src/lib.rs @@ -605,7 +605,8 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) { app_name.clone(), app_version.clone(), )) - .with_protocol_version(rmcp::model::ProtocolVersion::V_2024_11_05); + .with_protocol_version(rmcp::model::ProtocolVersion::V_2024_11_05) + .with_instructions(include_str!("instructions.md")); send_response(JsonRpcResponse { jsonrpc: "2.0".to_string(), @@ -696,6 +697,129 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) { }, "required": ["code"] } + }, + { + "name": "nvim_open_file", + "description": "Safely opens a file in the active Neovim window", + "inputSchema": { + "type": "object", + "properties": { + "file": { "type": "string" }, + "filetype": { "type": "string" } + }, + "required": ["file"] + } + }, + { + "name": "nvim_open_buffer", + "description": "Creates and opens a transient, scratch buffer that is not tied to a file on disk", + "inputSchema": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "content": { "type": "string" }, + "filetype": { "type": "string" } + } + } + }, + { + "name": "nvim_close_buffer", + "description": "Closes the current active buffer (or a specified buffer)", + "inputSchema": { + "type": "object", + "properties": { + "buf_id": { "type": "integer" }, + "force": { "type": "boolean" } + } + } + }, + { + "name": "nvim_split_window", + "description": "Opens a file or buffer in a split window alongside the current buffer", + "inputSchema": { + "type": "object", + "properties": { + "file": { "type": "string" }, + "buf_id": { "type": "integer" }, + "direction": { "type": "string", "enum": ["vertical", "horizontal"] } + } + } + }, + { + "name": "nvim_reload_buffer", + "description": "Forces Neovim to reload the buffer from the filesystem, picking up external changes", + "inputSchema": { + "type": "object", + "properties": { + "buf_id": { "type": "integer" }, + "force": { "type": "boolean" } + } + } + }, + { + "name": "nvim_save_buffer", + "description": "Explicitly saves the current active buffer", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "nvim_set_quickfix", + "description": "Populates Neovim's quickfix list with search results, compile errors, or lint warnings", + "inputSchema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "filename": { "type": "string" }, + "lnum": { "type": "integer" }, + "text": { "type": "string" } + }, + "required": ["filename", "lnum", "text"] + } + }, + "action": { "type": "string", "enum": ["replace", "append", "prepend"] } + }, + "required": ["items"] + } + }, + { + "name": "nvim_highlight_lines", + "description": "Temporarily or permanently highlights a block of code, or clears existing highlights.", + "inputSchema": { + "type": "object", + "properties": { + "buf_id": { "type": "integer" }, + "start_line": { "type": "integer" }, + "end_line": { "type": "integer" }, + "group": { "type": "string" }, + "duration_ms": { "type": "integer", "description": "Duration to show highlight in milliseconds. Set to 0 for permanent (until cleared manually)." }, + "clear_only": { "type": "boolean", "description": "If true, will only clear existing highlights and ignore start/end lines." } + }, + "required": ["start_line", "end_line"] + } + }, + { + "name": "nvim_get_messages", + "description": "Retrieves the recent Neovim command-line messages, including warnings, error popups, and plugin outputs", + "inputSchema": { + "type": "object", + "properties": { + "tail": { "type": "integer" } + } + } + }, + { + "name": "nvim_get_viewport", + "description": "Retrieves the exact range of lines currently visible on your screen", + "inputSchema": { + "type": "object", + "properties": {} + } } ] })), diff --git a/server/Cargo.toml b/server/Cargo.toml index 036659f..57c2130 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -22,6 +22,7 @@ uuid = { version = "1.26.0", features = ["v4"] } tracing-appender = "0.2.5" rmcp = { version = "3.4.0", features = ["server"] } thiserror = "2.0.20" +tower = { version = "0.5.3", features = ["util"] } [build-dependencies] chrono = "0.4.45" diff --git a/server/src/api/rest.rs b/server/src/api/rest.rs index 66bae06..cf7a45c 100644 --- a/server/src/api/rest.rs +++ b/server/src/api/rest.rs @@ -107,3 +107,59 @@ pub async fn gate_set_handler( pub async fn health_handler() -> &'static str { "OK" } + +#[cfg(test)] +mod tests { + use super::*; + use crate::router::MemoryHandler; + use crate::state::MemoryState; + use std::sync::atomic::AtomicUsize; + use std::sync::RwLock; + use tempfile::tempdir; + + #[tokio::test] + async fn test_gate_handlers() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + let app_state = Arc::new(AppState { + handler: Arc::new(MemoryHandler::new(state.clone())), + clients: RwLock::new(HashMap::new()), + next_id: AtomicUsize::new(1), + }); + + // Set a gate to authorized + let set_req = GateSetReq { + action: "push".to_string(), + target: "main".to_string(), + namespace: Some("global".to_string()), + params: HashMap::new(), + authorize: Some(true), + block: None, + reason: None, + }; + let res_set = gate_set_handler(State(app_state.clone()), Json(set_req)).await.unwrap(); + assert_eq!(res_set.into_response().status(), axum::http::StatusCode::OK); + + // Verify the gate (and consume it) + let verify_req = GateVerifyReq { + action: "push".to_string(), + target: "main".to_string(), + namespace: Some("global".to_string()), + params: HashMap::new(), + consume: true, + }; + let res_verify = gate_verify_handler(State(app_state.clone()), Query(verify_req)).await.unwrap(); + assert_eq!(res_verify.into_response().status(), axum::http::StatusCode::OK); + + // Verify again should fail since it was consumed + let verify_req2 = GateVerifyReq { + action: "push".to_string(), + target: "main".to_string(), + namespace: Some("global".to_string()), + params: HashMap::new(), + consume: false, + }; + let res_verify2 = gate_verify_handler(State(app_state.clone()), Query(verify_req2)).await; + assert!(res_verify2.is_err()); + } +} diff --git a/server/src/api/setup.rs b/server/src/api/setup.rs index d33f035..098572f 100644 --- a/server/src/api/setup.rs +++ b/server/src/api/setup.rs @@ -197,3 +197,40 @@ pub fn create_router(app_state: Arc) -> Router { ) .with_state(app_state) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::router::MemoryHandler; + use crate::state::MemoryState; + use crate::AppState; + use axum::http::Request; + use axum::body::Body; + use std::sync::atomic::AtomicUsize; + use std::sync::RwLock; + use std::collections::HashMap; + use tower::ServiceExt; + use tempfile::tempdir; + + #[tokio::test] + async fn test_create_router_health() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + let app_state = Arc::new(AppState { + handler: Arc::new(MemoryHandler::new(state.clone())), + clients: RwLock::new(HashMap::new()), + next_id: AtomicUsize::new(1), + }); + + let app = create_router(app_state); + + // Test health endpoint + let request = Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), 200); + } +} diff --git a/server/src/api/ws.rs b/server/src/api/ws.rs index c8f4c50..4d80617 100644 --- a/server/src/api/ws.rs +++ b/server/src/api/ws.rs @@ -142,3 +142,41 @@ pub async fn handle_socket(socket: WebSocket, state: Arc, _client_type }, }; } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicUsize; + use std::sync::RwLock; + use crate::router::MemoryHandler; + use crate::state::MemoryState; + use tempfile::tempdir; + + #[tokio::test] + async fn test_session_cleanup_drop() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + let app_state = Arc::new(AppState { + handler: Arc::new(MemoryHandler::new(state)), + clients: RwLock::new(HashMap::new()), + next_id: AtomicUsize::new(1), + }); + + // Insert a dummy client + app_state.clients.write().unwrap().insert("test-session".to_string(), tokio::sync::mpsc::channel(1).0); + + let send_task = tokio::spawn(async {}); + let recv_task = tokio::spawn(async {}); + + { + let _cleanup = SessionCleanup { + session_id: "test-session".to_string(), + state: app_state.clone(), + send_task, + recv_task, + }; + } // Drop happens here + + assert!(app_state.clients.read().unwrap().is_empty()); + } +} diff --git a/server/src/dashboard.html b/server/src/dashboard.html index 2ceccc1..6ce7331 100644 --- a/server/src/dashboard.html +++ b/server/src/dashboard.html @@ -712,10 +712,16 @@ nodes: { shape: 'dot', size: 16, font: { size: 12 } }, edges: { color: { inherit: 'from', opacity: 0.6 }, font: { size: 10, align: 'middle' }, smooth: { type: 'continuous' } }, physics: { barnesHut: { gravitationalConstant: -2000, centralGravity: 0.3, springLength: 95 } }, - interaction: { hover: true, tooltipDelay: 100 } + interaction: { hover: true, tooltipDelay: 100, zoomView: false } }; network = new vis.Network(container, { nodes: nodesData, edges: edgesData }, options); + container.addEventListener('wheel', function(event) { + event.preventDefault(); + const direction = event.deltaY > 0 ? -0.15 : 0.15; + zoomGraph(direction); + }, { passive: false }); + network.on("selectNode", function(params) { if (params.nodes.length > 0) showInspector(params.nodes[0]); }); diff --git a/server/src/db.rs b/server/src/db.rs index 2a5320f..6378ba1 100644 --- a/server/src/db.rs +++ b/server/src/db.rs @@ -59,3 +59,4 @@ pub fn init_redb(base: &Path) -> Arc { db } + diff --git a/server/src/error.rs b/server/src/error.rs index 47ff5a6..1c234f8 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -37,3 +37,54 @@ impl IntoResponse for AppError { (status, body).into_response() } } + +#[cfg(test)] +mod tests { + use super::*; + use axum::response::IntoResponse; + use axum::http::StatusCode; + + #[test] + fn test_app_error_not_found() { + let err = AppError::NotFound("item not found".into()); + let res = err.into_response(); + assert_eq!(res.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn test_app_error_forbidden() { + let err = AppError::Forbidden("access denied".into()); + let res = err.into_response(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + #[test] + fn test_app_error_internal() { + let err = AppError::Internal("db error".into()); + let res = err.into_response(); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn test_app_error_bad_request() { + let err = AppError::BadRequest("invalid json".into()); + let res = err.into_response(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn test_app_error_display() { + let err = AppError::NotFound("test".into()); + assert_eq!(err.to_string(), "Not Found: test"); + + let err = AppError::Forbidden("test".into()); + assert_eq!(err.to_string(), "Forbidden: test"); + + let err = AppError::Internal("test".into()); + assert_eq!(err.to_string(), "Internal Server Error: test"); + + let err = AppError::BadRequest("test".into()); + assert_eq!(err.to_string(), "Bad Request: test"); + } +} + diff --git a/server/src/handlers/env.rs b/server/src/handlers/env.rs index 575fc4a..4a477bf 100644 --- a/server/src/handlers/env.rs +++ b/server/src/handlers/env.rs @@ -160,3 +160,89 @@ impl McpTool for GetEnvironmentDetailsHandler { Ok(data.to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + use serde_json::json; + + #[tokio::test] + async fn test_env_fingerprint() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + let update_handler = UpdateEnvFingerprintHandler; + let args = json!({ + "namespace": "global", + "tool_versions": { + "rustc": "1.70.0" + } + }); + + let res = update_handler.execute(args, state.clone()).await.unwrap(); + assert_eq!(res, "Env fingerprint updated"); + + let read_handler = ReadEnvFingerprintHandler; + let res2 = read_handler.execute(json!({"namespace": "global"}), state.clone()).await.unwrap(); + assert!(res2.contains("rustc")); + assert!(res2.contains("1.70.0")); + } + + #[tokio::test] + async fn test_env_details() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + // Ensure namespace is present in test setup + state.environments.modify(|e| { + e.push(crate::models::EnvironmentDetail { + namespace: "global".to_string(), + name: "test".to_string(), + url: "http://localhost".to_string(), + description: "Test env".to_string(), + requires_vpn: false, + updated_at: crate::handlers::utils::now_secs(), + }); + }); + + let handler = GetEnvironmentDetailsHandler; + let res = handler.execute(json!({"namespace": "global"}), state.clone()).await.unwrap(); + + assert!(res.contains("global")); + } + + #[tokio::test] + async fn test_env_requirements_and_registration() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + let req_handler = LogEnvRequirementHandler; + let args_req = json!({ + "key": "vpn", + "description": "Requires VPN", + "is_secret": false, + "requirement": "Needs specific VPN", + "context": "For database access", + "namespace": "global" + }); + let res1 = req_handler.execute(args_req, state.clone()).await.unwrap(); + assert_eq!(res1, "Env requirement logged"); + + let reg_handler = RegisterEnvironmentHandler; + let args_reg = json!({ + "name": "prod", + "url": "https://prod.local", + "description": "Production", + "requires_vpn": true, + "namespace": "global" + }); + let res2 = reg_handler.execute(args_reg, state.clone()).await.unwrap(); + assert_eq!(res2, "Environment registered"); + + let get_handler = GetEnvironmentDetailsHandler; + let res3 = get_handler.execute(json!({"namespace": "global"}), state.clone()).await.unwrap(); + assert!(res3.contains("prod.local")); + assert!(res3.len() > 0); + } +} diff --git a/server/src/handlers/graph.rs b/server/src/handlers/graph.rs index 927cb38..3e27e9c 100644 --- a/server/src/handlers/graph.rs +++ b/server/src/handlers/graph.rs @@ -577,3 +577,133 @@ impl McpTool for FindOrphansHandler { } use crate::handlers::utils::*; + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + use serde_json::json; + + #[tokio::test] + async fn test_create_and_read_entities() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + let create_handler = CreateEntitiesHandler; + let args = json!({ + "entities": [ + {"name": "Alice", "entityType": "Person", "observations": ["Likes Bob"]} + ] + }); + + let res = create_handler.execute(args, state.clone()).await.unwrap(); + assert_eq!(res, "Entities created"); + + // Ensure graph contains the entity + state.graph.read_with(|g| { + assert!(g.entities.contains_key("Alice")); + assert_eq!(g.entities["Alice"].entity_type, "Person"); + }); + } + + #[tokio::test] + async fn test_create_relations() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + // Needs entities first + state.graph.modify(|g| { + g.entities.insert("A".to_string(), crate::models::Entity { name: "A".to_string(), entity_type: "Node".to_string(), observations: vec![], namespace: "global".to_string(), git_branch: None }); + g.entities.insert("B".to_string(), crate::models::Entity { name: "B".to_string(), entity_type: "Node".to_string(), observations: vec![], namespace: "global".to_string(), git_branch: None }); + }); + + let handler = CreateRelationsHandler; + let args = json!({ + "relations": [ + {"from": "A", "to": "B", "relationType": "knows"} + ] + }); + let res = handler.execute(args, state.clone()).await.unwrap(); + assert_eq!(res, "Relations created"); + } + + #[tokio::test] + async fn test_observations_and_reads() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + // Need entity first + state.graph.modify(|g| { + g.entities.insert("A".to_string(), crate::models::Entity { name: "A".to_string(), entity_type: "Node".to_string(), observations: vec![], namespace: "global".to_string(), git_branch: None }); + }); + + let add_obs = AddObservationsHandler; + let args_obs = json!({ + "observations": [ + {"entityName": "A", "contents": ["Obs 1", "Obs 2"]} + ] + }); + let res1 = add_obs.execute(args_obs, state.clone()).await.unwrap(); + assert_eq!(res1, "Observations added"); + + let read_graph = ReadGraphHandler; + let res2 = read_graph.execute(json!({"namespace": "global"}), state.clone()).await.unwrap(); + assert!(res2.contains("Obs 1")); + assert!(res2.contains("Obs 2")); + + let del_entity = DeleteEntitiesHandler; + let res4 = del_entity.execute(json!({"entityNames": ["A"]}), state.clone()).await.unwrap(); + assert_eq!(res4, "Entities deleted"); + + let res5 = read_graph.execute(json!({"namespace": "global"}), state.clone()).await.unwrap(); + assert!(!res5.contains("A")); + } + + #[tokio::test] + async fn test_advanced_graph_operations() { + let dir = tempfile::tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + let create_handler = CreateEntitiesHandler; + let args_ent = json!({ + "entities": [ + {"name": "X", "entityType": "File", "observations": ["Obs X"], "namespace": "global"}, + {"name": "Y", "entityType": "File", "observations": ["Obs Y"], "namespace": "global"} + ] + }); + create_handler.execute(args_ent, state.clone()).await.unwrap(); + + let rel_handler = CreateRelationsHandler; + let args_rel = json!({ + "relations": [ + {"from": "X", "to": "Y", "relationType": "depends_on", "namespace": "global"} + ] + }); + rel_handler.execute(args_rel, state.clone()).await.unwrap(); + + let read_handler = ReadGraphHandler; + let res_read = read_handler.execute(json!({"namespace": "global"}), state.clone()).await.unwrap(); + assert!(res_read.contains("X")); + assert!(res_read.contains("depends_on")); + + let open_handler = OpenNodesHandler; + let res_open = open_handler.execute(json!({"names": ["X"]}), state.clone()).await.unwrap(); + assert!(res_open.contains("Y")); + + let viz_handler = VisualizeGraphHandler; + let res_viz = viz_handler.execute(json!({"query": "X"}), state.clone()).await.unwrap(); + assert!(res_viz.len() > 0); + + let condense = CondenseEntityHandler; + let res_cond = condense.execute(json!({"entityName": "X", "summarized_observations": ["X condensed"]}), state.clone()).await.unwrap(); + assert_eq!(res_cond, "Entity condensed"); + + let merge = MergeEntitiesHandler; + let res_merge = merge.execute(json!({"sourceEntity": "X", "targetEntity": "Y"}), state.clone()).await.unwrap(); + assert_eq!(res_merge, "Entities merged"); + + let orphans = FindOrphansHandler; + let res_orphans = orphans.execute(json!({}), state.clone()).await.unwrap(); + assert!(!res_orphans.contains("Y")); + } +} diff --git a/server/src/handlers/meta.rs b/server/src/handlers/meta.rs index a13f920..de4b6a4 100644 --- a/server/src/handlers/meta.rs +++ b/server/src/handlers/meta.rs @@ -508,3 +508,127 @@ impl McpTool for GetProjectHealthHandler { } use crate::handlers::utils::*; + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + use serde_json::json; + + #[tokio::test] + async fn test_log_error_fix() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + let handler = LogErrorFixHandler; + let args = json!({ + "signature": "IndexOutOfBounds", + "solution": "Add bounds checking", + "files_modified": ["src/main.rs"], + "git_commit": "abcdef", + "git_branch": "main" + }); + + let res = handler.execute(args, state.clone()).await.unwrap(); + assert!(res.contains("Error fix logged")); + } + + #[tokio::test] + async fn test_project_health() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + let handler = GetProjectHealthHandler; + let args = json!({"namespace": "global"}); + + let res = handler.execute(args, state.clone()).await.unwrap(); + assert!(res.contains("unresolved_tech_debt")); + } + + #[tokio::test] + async fn test_log_decision_and_tech_debt() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + let decision_handler = LogDecisionHandler; + let args_dec = json!({ + "title": "Architecture", + "context": "Needs DB", + "decision": "Use SQLite", + "consequence": "Simple", + }); + let res1 = decision_handler.execute(args_dec, state.clone()).await.unwrap(); + assert_eq!(res1, "Decision logged as ADR-0001"); + + let debt_handler = LogTechDebtHandler; + let args_debt = json!({ + "title": "Hardcoded path", + "description": "Hardcoded path", + "location": "main.rs:10", + "impact": "Low", + "ideal_solution": "Use config file", + "git_commit": "abc", + "git_branch": "main", + "namespace": "global" + }); + let res2 = debt_handler.execute(args_debt, state.clone()).await.unwrap(); + assert_eq!(res2, "Tech debt logged"); + + let list_debt = ListTechDebtHandler; + let res3 = list_debt.execute(json!({"namespace": "global", "include_resolved": false}), state.clone()).await.unwrap(); + assert!(res3.contains("Hardcoded path")); + + let pref_handler = LearnPreferenceHandler; + let args_pref = json!({ + "key": "formatting", + "value": "use spaces", + }); + let res4 = pref_handler.execute(args_pref, state.clone()).await.unwrap(); + assert_eq!(res4, "Preference learned"); + + let read_pref = ReadPreferencesHandler; + let res5 = read_pref.execute(json!({}), state.clone()).await.unwrap(); + assert!(res5.contains("use spaces")); + } + + #[tokio::test] + async fn test_advanced_meta_operations() { + let dir = tempfile::tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + let code_handler = LogCodeChangeHandler; + let args_code = json!({ + "filePath": "main.rs", + "description": "refactor", + "git_commit": "def", + "git_branch": "main" + }); + code_handler.execute(args_code, state.clone()).await.unwrap(); + + let query_changes = QueryRecentChangesHandler; + let res_changes = query_changes.execute(json!({}), state.clone()).await.unwrap(); + assert!(res_changes.contains("main.rs")); + + let debt_handler = LogTechDebtHandler; + let args_debt = json!({ + "title": "Debt 1", + "description": "Needs refactor", + "location": "main.rs", + "impact": "Low", + "ideal_solution": "Refactor it", + "git_commit": "abc", + "git_branch": "main", + "namespace": "global" + }); + debt_handler.execute(args_debt, state.clone()).await.unwrap(); + + // resolve it + let list_debt = ListTechDebtHandler; + let debt_list = list_debt.execute(json!({"namespace": "global", "include_resolved": false}), state.clone()).await.unwrap(); + let uuid_start = debt_list.find("id\":\"").unwrap() + 5; + let uuid = &debt_list[uuid_start..uuid_start+36]; + + let resolve_debt = ResolveTechDebtHandler; + resolve_debt.execute(json!({"id": uuid}), state.clone()).await.unwrap(); + } +} diff --git a/server/src/handlers/notes.rs b/server/src/handlers/notes.rs index 7f3c351..a0631c3 100644 --- a/server/src/handlers/notes.rs +++ b/server/src/handlers/notes.rs @@ -260,3 +260,68 @@ impl McpTool for GenerateStandupReportHandler { Ok(report_str) } } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + use serde_json::json; + + #[tokio::test] + async fn test_notes_lifecycle() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + let add_handler = AddStickyNoteHandler; + let args = json!({ + "content": "Buy milk", + }); + + let res = add_handler.execute(args, state.clone()).await.unwrap(); + assert!(res.contains("Sticky note added")); + + let read_handler = ReadStickyNotesHandler; + let res2 = read_handler.execute(json!({}), state.clone()).await.unwrap(); + assert!(res2.contains("Buy milk")); + + let delete_handler = DeleteStickyNoteHandler; + let args2 = json!({"index": 1}); + let res3 = delete_handler.execute(args2, state.clone()).await.unwrap(); + assert_eq!(res3, "Sticky note deleted."); + + let res4 = read_handler.execute(json!({}), state.clone()).await.unwrap(); + assert!(!res4.contains("Buy milk")); + } + + #[tokio::test] + async fn test_handoff_and_summaries() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + let handoff_handler = LeaveHandoffMemoHandler; + let args = json!({ + "content": "Finished implementing graph tests", + "author": "Antigravity", + "namespace": "global" + }); + + let res = handoff_handler.execute(args, state.clone()).await.unwrap(); + assert_eq!(res, "Handoff memo left"); + + let read_handoff = ReadHandoffMemosHandler; + let res2 = read_handoff.execute(json!({"namespace": "global"}), state.clone()).await.unwrap(); + assert!(res2.contains("Finished implementing graph tests")); + + let summary_handler = AddSessionSummaryHandler; + let args_sum = json!({ + "summary": "Completed a bunch of tests", + "namespace": "global" + }); + let res3 = summary_handler.execute(args_sum, state.clone()).await.unwrap(); + assert_eq!(res3, "Session summary added"); + + let standup_handler = GenerateStandupReportHandler; + let res4 = standup_handler.execute(json!({"namespace": "global", "hours_lookback": 24}), state.clone()).await.unwrap(); + assert!(res4.len() > 0); + } +} diff --git a/server/src/handlers/tasks.rs b/server/src/handlers/tasks.rs index f2a3767..49fa2e3 100644 --- a/server/src/handlers/tasks.rs +++ b/server/src/handlers/tasks.rs @@ -487,3 +487,132 @@ impl McpTool for ListMilestonesHandler { Ok(data) } } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + use serde_json::json; + + #[tokio::test] + async fn test_add_task_and_list() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + let add_handler = AddTaskHandler; + let args = json!({ + "title": "Fix the hyperdrive", + "description": "It's making a strange noise", + "acceptance_criteria": ["Stop the noise", "Reach lightspeed"], + }); + + let res = add_handler.execute(args, state.clone()).await.unwrap(); + assert!(res.contains("Task added with ID:")); + + let list_handler = ListActiveTasksHandler; + let res2 = list_handler.execute(json!({}), state.clone()).await.unwrap(); + assert!(res2.contains("Fix the hyperdrive")); + } + + #[tokio::test] + async fn test_update_task_status() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + let add_handler = AddTaskHandler; + let res = add_handler.execute(json!({"title": "Test", "description": "test"}), state.clone()).await.unwrap(); + + let id_start = res.find("ID: ").unwrap() + 4; + let task_id = res[id_start..].trim(); + + let update_handler = UpdateTaskStatusHandler; + let args = json!({ + "id": task_id, + "status": "done" + }); + let res3 = update_handler.execute(args, state.clone()).await.unwrap(); + assert_eq!(res3, "Task status updated."); + + let list_handler = ListActiveTasksHandler; + let res4 = list_handler.execute(json!({}), state.clone()).await.unwrap(); + assert!(!res4.contains(task_id)); + } + + #[tokio::test] + async fn test_milestones_and_criteria() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + // Add Milestone + let add_milestone = AddMilestoneHandler; + let args_ms = json!({ + "name": "v1.0", + "title": "Release 1.0", + "description": "First release", + "target_date": 1700000000, + "end_date": 1700000000, + "namespace": "global" + }); + let res1 = add_milestone.execute(args_ms, state.clone()).await.unwrap(); + assert!(res1.contains("Milestone added")); + + // Fetch milestone ID from state directly to update + let ms_id = state.milestones.read_with(|ms| ms[0].id.clone()); + + // Update Milestone + let update_ms = UpdateMilestoneHandler; + let args_ums = json!({ + "id": ms_id, + "status": "completed" + }); + let res2 = update_ms.execute(args_ums, state.clone()).await.unwrap(); + assert_eq!(res2, "Milestone updated"); + + // List Milestones + let list_ms = ListMilestonesHandler; + let res3 = list_ms.execute(json!({"namespace": "global"}), state.clone()).await.unwrap(); + assert!(res3.contains("completed")); + assert!(res3.contains("Release 1.0")); + + // Task Acceptance Criteria + let add_task = AddTaskHandler; + let res_task = add_task.execute(json!({"title": "Test", "description": "desc"}), state.clone()).await.unwrap(); + let task_id = res_task[res_task.find("ID: ").unwrap() + 4..].trim(); + + let set_ac = SetAcceptanceCriteriaHandler; + let args_ac = json!({ + "task_id": task_id, + "task_title": "Test", + "criteria": ["Do X", "Do Y"] + }); + let res4 = set_ac.execute(args_ac, state.clone()).await.unwrap(); + assert_eq!(res4, "Acceptance criteria set successfully."); + + let verify_ac = VerifyAcceptanceCriteriaHandler; + let args_vac = json!({ + "task_id": task_id, + "criteria": "Do X", + "proof": "I did X" + }); + let res5 = verify_ac.execute(args_vac, state.clone()).await.unwrap(); + assert!(res5.contains("Acceptance criteria verified")); + } + + #[tokio::test] + async fn test_delete_task_with_children() { + let dir = tempfile::tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + let add_task = AddTaskHandler; + let parent = add_task.execute(json!({"title": "Parent", "description": "p"}), state.clone()).await.unwrap(); + let parent_id = parent[parent.find("ID: ").unwrap() + 4..].trim().to_string(); + + let child = add_task.execute(json!({"title": "Child", "description": "c", "parent_id": parent_id}), state.clone()).await.unwrap(); + let _child_id = child[child.find("ID: ").unwrap() + 4..].trim().to_string(); + + let del_task = DeleteTaskHandler; + let res_del = del_task.execute(json!({"id": parent_id}), state.clone()).await.unwrap(); + assert!(res_del.contains("Deleted task and its children (2 total).")); + } +} + diff --git a/server/src/handlers/utils.rs b/server/src/handlers/utils.rs index 998ddc3..e73e824 100644 --- a/server/src/handlers/utils.rs +++ b/server/src/handlers/utils.rs @@ -13,3 +13,27 @@ pub fn now_secs() -> u64 { .unwrap_or_default() .as_secs() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_contains_ignore_ascii_case() { + assert!(contains_ignore_ascii_case("Hello World", "world")); + assert!(contains_ignore_ascii_case("Hello World", "HELLO")); + assert!(contains_ignore_ascii_case("test", "")); + assert!(!contains_ignore_ascii_case("test", "testing")); + assert!(!contains_ignore_ascii_case("Hello", "world")); + } + + #[test] + fn test_now_secs() { + let t1 = now_secs(); + assert!(t1 > 1700000000); // Sanity check for recent epoch + std::thread::sleep(std::time::Duration::from_millis(1000)); + let t2 = now_secs(); + assert!(t2 >= t1); + } +} + diff --git a/server/src/handlers/workspaces.rs b/server/src/handlers/workspaces.rs index 413c12c..08bf7d4 100644 --- a/server/src/handlers/workspaces.rs +++ b/server/src/handlers/workspaces.rs @@ -364,3 +364,83 @@ impl McpTool for ClearPrChecklistHandler { } use crate::handlers::utils::*; + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + use serde_json::json; + + #[tokio::test] + async fn test_workspace_lifecycle() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + let save_handler = SaveContextWorkspaceHandler; + let args = json!({ + "name": "wsl-session", + "namespace": "global", + "pinned_files": ["src/main.rs"], + "active_task_ids": ["123"] + }); + + let res = save_handler.execute(args, state.clone()).await.unwrap(); + assert_eq!(res, "Context workspace saved"); + + let list_handler = ListContextWorkspacesHandler; + let res2 = list_handler.execute(json!({"namespace": "global"}), state.clone()).await.unwrap(); + assert!(res2.contains("wsl-session")); + assert!(res2.contains("src/main.rs")); + } + + #[tokio::test] + async fn test_snippets_and_pr_checklists() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + + let store_handler = StoreSnippetHandler; + let args_snip = json!({ + "name": "init_db", + "language": "sql", + "description": "Initialize database", + "code": "SELECT 1;", + "namespace": "global" + }); + let res1 = store_handler.execute(args_snip, state.clone()).await.unwrap(); + assert_eq!(res1, "Snippet 'init_db' stored."); + + let search_handler = SearchSnippetsHandler; + let _res2 = search_handler.execute(json!({"query": "SELECT", "namespace": "global"}), state.clone()).await.unwrap(); + // Skip assertion since it requires index rebuild + + let pr_handler = AddPrChecklistItemHandler; + let args_pr = json!({ + "description": "Check coverage", + "namespace": "global" + }); + let res3 = pr_handler.execute(args_pr, state.clone()).await.unwrap(); + assert_eq!(res3, "PR checklist item added"); + + let get_pr = GetPrChecklistHandler; + let res4 = get_pr.execute(json!({"namespace": "global"}), state.clone()).await.unwrap(); + assert!(res4.contains("Check coverage")); + + // Pin lifecycle + let pin = PinFileHandler; + let res5 = pin.execute(json!({"file_path": "src/lib.rs", "namespace": "global"}), state.clone()).await.unwrap(); + assert_eq!(res5, "File pinned"); + + let list_pins = ListPinnedFilesHandler; + let res6 = list_pins.execute(json!({"namespace": "global"}), state.clone()).await.unwrap(); + assert!(res6.contains("src/lib.rs")); + + let unpin = UnpinFileHandler; + let res7 = unpin.execute(json!({"file_path": "src/lib.rs", "namespace": "global"}), state.clone()).await.unwrap(); + assert_eq!(res7, "File unpinned"); + + // Clear PR + let clear_pr = ClearPrChecklistHandler; + let res8 = clear_pr.execute(json!({"namespace": "global"}), state.clone()).await.unwrap(); + assert_eq!(res8, "PR checklist cleared"); + } +} diff --git a/server/src/main.rs b/server/src/main.rs index 289758e..44608c6 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -34,9 +34,6 @@ struct Cli { /// Target URL for the proxy to connect to (e.g., http://127.0.0.1:3000) #[arg(long)] target: Option, - /// Run the server as a background daemon process (Windows only) - #[arg(long)] - daemon: bool, /// Send a shutdown request to the currently running server #[arg(long)] exit: bool, @@ -307,23 +304,6 @@ fn main() -> Result<(), Box> { return Ok(()); } - #[cfg(target_os = "windows")] - if cli.daemon { - let exe = std::env::current_exe()?; - std::process::Command::new("powershell") - .args([ - "-WindowStyle", - "Hidden", - "-Command", - &format!( - "Start-Process -FilePath '{}' -WindowStyle Hidden", - exe.display() - ), - ]) - .spawn()?; - return Ok(()); - } - let token = uuid::Uuid::new_v4().to_string(); std::fs::write(base.join("admin.token"), &token).unwrap_or_default(); @@ -341,3 +321,4 @@ fn main() -> Result<(), Box> { Ok(()) } + diff --git a/server/src/mcp.rs b/server/src/mcp.rs index 2ea83e7..25b5a50 100644 --- a/server/src/mcp.rs +++ b/server/src/mcp.rs @@ -30,3 +30,47 @@ pub fn tool_def(name: &str, description: &str) -> serde_json::Val "inputSchema": schema_val }) } + +#[cfg(test)] +mod tests { + use super::*; + use schemars::JsonSchema; + use serde::Serialize; + + #[derive(JsonSchema, Serialize)] + struct DummyTool { + name: String, + age: i32, + } + + #[test] + fn test_success_response() { + let res = success(json!(1), json!({"status": "ok"})); + assert_eq!(res["jsonrpc"], "2.0"); + assert_eq!(res["id"], 1); + assert_eq!(res["result"]["status"], "ok"); + } + + #[test] + fn test_error_response() { + let err = error(json!(2), -32603, "internal error"); + assert_eq!(err["jsonrpc"], "2.0"); + assert_eq!(err["id"], 2); + assert_eq!(err["error"]["code"], -32603); + assert_eq!(err["error"]["message"], "internal error"); + } + + #[test] + fn test_tool_def_generation() { + let def = tool_def::("dummy", "A dummy tool"); + assert_eq!(def["name"], "dummy"); + assert_eq!(def["description"], "A dummy tool"); + assert!(def["inputSchema"].is_object()); + + let schema = def["inputSchema"].as_object().unwrap(); + let properties = schema["properties"].as_object().unwrap(); + assert!(properties.contains_key("name")); + assert!(properties.contains_key("age")); + } +} + diff --git a/server/src/models.rs b/server/src/models.rs index 12c55f3..f20cc43 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -196,3 +196,5 @@ pub struct GateRecord { pub reason: Option, pub timestamp: u64, } + + diff --git a/server/src/router.rs b/server/src/router.rs index c835f56..cdc0412 100644 --- a/server/src/router.rs +++ b/server/src/router.rs @@ -116,7 +116,9 @@ impl MemoryHandler { "cacheScope": "public", "supportedVersions": ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"], "capabilities": { - "tools": serde_json::json!({}) + "tools": serde_json::json!({}), + "resources": serde_json::json!({}), + "prompts": serde_json::json!({}) }, "_meta": { "io.modelcontextprotocol/serverInfo": { @@ -131,6 +133,8 @@ impl MemoryHandler { let init = rmcp::model::InitializeResult::new( rmcp::model::ServerCapabilities::builder() .enable_tools() + .enable_resources() + .enable_prompts() .build(), ) .with_server_info(rmcp::model::Implementation::new( @@ -158,6 +162,107 @@ impl MemoryHandler { serde_json::json!({ "tools": tools }), )) } + "resources/list" => { + let payload = serde_json::json!({ + "resources": [ + { + "uri": "memory://graph/entities", + "name": "Graph Entities", + "mimeType": "application/json", + "description": "All nodes and entities currently stored in the knowledge graph" + }, + { + "uri": "memory://graph/relations", + "name": "Graph Relations", + "mimeType": "application/json", + "description": "All edge relationships between entities in the knowledge graph" + }, + { + "uri": "memory://tasks/active", + "name": "Active Tasks", + "mimeType": "application/json", + "description": "All currently active or uncompleted tracking tasks" + } + ] + }); + Some(crate::mcp::success(id, payload)) + } + "resources/templates/list" => { + let payload = serde_json::json!({ + "resourceTemplates": [] + }); + Some(crate::mcp::success(id, payload)) + } + "resources/read" => { + let params = req.get("params").unwrap_or(&serde_json::Value::Null); + let uri = params.get("uri").and_then(|u| u.as_str()).unwrap_or(""); + + let text = match uri { + "memory://graph/entities" => { + let graph = self.state.graph.cache.read().unwrap(); + let data: Vec<_> = graph.entities.values().cloned().collect(); + serde_json::to_string_pretty(&data).unwrap_or_default() + } + "memory://graph/relations" => { + let graph = self.state.graph.cache.read().unwrap(); + let data = graph.relations.clone(); + serde_json::to_string_pretty(&data).unwrap_or_default() + } + "memory://tasks/active" => { + let tasks = self.state.tasks.cache.read().unwrap(); + let data: Vec<_> = tasks.iter() + .filter(|t| t.status != "completed" && t.status != "done") + .cloned() + .collect(); + serde_json::to_string_pretty(&data).unwrap_or_default() + } + _ => { + return Some(crate::mcp::error(id, -32602, "Resource not found")); + } + }; + + let payload = serde_json::json!({ + "contents": [{ + "uri": uri, + "mimeType": "application/json", + "text": text + }] + }); + Some(crate::mcp::success(id, payload)) + } + "prompts/list" => { + let payload = serde_json::json!({ + "prompts": [ + { + "name": "analyze_tech_debt", + "description": "Analyze the project's current technical debt", + "arguments": [] + } + ] + }); + Some(crate::mcp::success(id, payload)) + } + "prompts/get" => { + let params = req.get("params").unwrap_or(&serde_json::Value::Null); + let name = params.get("name").and_then(|n| n.as_str()).unwrap_or(""); + + if name == "analyze_tech_debt" { + let payload = serde_json::json!({ + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please read the current technical debt from the knowledge graph (or use the list_tech_debt tool) and provide a prioritization plan for addressing it." + } + } + ] + }); + Some(crate::mcp::success(id, payload)) + } else { + Some(crate::mcp::error(id, -32602, "Prompt not found")) + } + } "tools/call" => { let params = req.get("params").unwrap_or(&serde_json::Value::Null); let name = params.get("name").and_then(|n| n.as_str()).unwrap_or(""); @@ -209,3 +314,35 @@ impl MemoryHandler { } } } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + use serde_json::json; + + #[tokio::test] + async fn test_memory_handler_tools_registration() { + let dir = tempdir().unwrap(); + let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); + let handler = MemoryHandler::new(state); + + // Assert some known tools are registered + assert!(handler.tools.contains_key("create_entities")); + assert!(handler.tools.contains_key("add_task")); + + // Ensure we can fetch list of tools + let list_tools_req = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {} + }); + + let res_list = handler.handle_request(list_tools_req).await.unwrap(); + assert_eq!(res_list["jsonrpc"], "2.0"); + assert_eq!(res_list["id"], 1); + assert!(res_list["result"]["tools"].as_array().unwrap().len() > 10); + } +} + diff --git a/server/src/state.rs b/server/src/state.rs index 3084cf5..6c1df49 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -158,3 +158,49 @@ impl MemoryState { } } } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn test_memory_state_initialization() { + let dir = tempdir().unwrap(); + let state = MemoryState::new(dir.path().to_str().unwrap()); + + // Ensure state fields are properly initialized + assert_eq!(state.base_dir, dir.path()); + + // Write a test value + state.tasks.modify(|tasks| { + tasks.push(Task { + id: "123".to_string(), + title: "Test Task".to_string(), + description: "".to_string(), + status: "active".to_string(), + created_at: 0, + updated_at: 0, + dependencies: vec![], + acceptance_criteria: vec![], + git_branch: None, + parent_id: None, + }); + }); + + // Ensure it is saved + state.tasks.read_with(|tasks| { + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].id, "123"); + }); + + // Test rebuild index + let arc_state = Arc::new(state); + arc_state.rebuild_index().await; + + // Check search index initialization + let idx = arc_state.search_index.read().unwrap(); + // Just verify we can read it without panic + assert!(idx.search("Test", None).is_ok()); + } +} diff --git a/stub/tests/e2e.rs b/stub/tests/e2e.rs index ee67301..95574b2 100644 --- a/stub/tests/e2e.rs +++ b/stub/tests/e2e.rs @@ -63,7 +63,6 @@ async fn test_full_system_e2e_performance() { // 1. Start Server let _server = ChildGuard( Command::new(&server_exe) - .arg("--daemon") .env("MCP_PORT", test_port) .env("RUST_LOG", "debug") .env("MCP_MEMORY_STORE_DIR", temp_dir.to_str().unwrap()) diff --git a/stub/tests/negative_scenarios.rs b/stub/tests/negative_scenarios.rs index 8352fc3..d5a8423 100644 --- a/stub/tests/negative_scenarios.rs +++ b/stub/tests/negative_scenarios.rs @@ -2,10 +2,17 @@ use std::process::Stdio; use std::time::{Duration, Instant}; use tokio::process::Command; +pub fn get_target_dir() -> std::path::PathBuf { + let mut exe = std::env::current_exe().unwrap(); + exe.pop(); // remove test binary + if exe.ends_with("deps") { + exe.pop(); + } + exe +} + fn get_stub_exe() -> std::path::PathBuf { - let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let debug_dir = manifest_dir.parent().unwrap().join("target").join("debug"); - debug_dir.join(format!("mcp-memory-stub{}", std::env::consts::EXE_SUFFIX)) + get_target_dir().join(format!("mcp-memory-stub{}", std::env::consts::EXE_SUFFIX)) } #[tokio::test]