mod headless; use headless::*; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct JsonRpcRequest { pub jsonrpc: String, pub id: Option, pub method: String, pub params: Option, } #[derive(Serialize, Debug, Clone)] pub struct JsonRpcResponse { pub jsonrpc: String, pub id: Value, #[serde(skip_serializing_if = "Option::is_none")] pub result: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } pub async fn send_response(response: JsonRpcResponse) { let msg = serde_json::to_string(&response).unwrap_or_else(|_| "{}".to_string()); tracing::info!( "Sending JSON-RPC response (id: {:?}): {}{}", response.id, &msg[..msg.floor_char_boundary(500)], if msg.len() > 500 { "..." } else { "" } ); // CRITICAL ARCHITECTURAL DECISION: // The MCP StdioTransport MUST use Newline-Delimited JSON (NDJSON). // Do NOT use LSP-style HTTP headers (e.g. Content-Length). // See MCP protocol spec (SEP-2575) and mcp-go-sdk bufio.Scanner implementation. let mut payload = Vec::with_capacity(msg.len() + 1); payload.extend_from_slice(msg.as_bytes()); payload.push(b'\n'); let mut stdout = tokio::io::stdout(); if let Err(e) = stdout.write_all(&payload).await { tracing::error!("Failed to write response payload to stdout: {}", e); } if let Err(e) = stdout.flush().await { tracing::error!("Failed to flush stdout: {}", e); } } macro_rules! handle_lua_result { ($id:expr, $result:expr) => { match $result.await { Ok(res) => send_text_result!($id.clone(), res), Err(e) => send_error($id, -32603, &e).await, } }; } pub async fn send_error(id: Value, code: i32, message: &str) { send_response(JsonRpcResponse { jsonrpc: "2.0".to_string(), id, result: None, error: Some(serde_json::json!({"code": code, "message": message})), }) .await; } async fn is_interactive(socket_path: &str) -> bool { // If it's our explicitly named headless fallback instance, skip the shell-out and reject it immediately if socket_path.contains("agy-headless-nvim") { return false; } let check_cmd = tokio::process::Command::new("nvim") .arg("--server") .arg(socket_path) .arg("--headless") .arg("--remote-expr") .arg("empty(nvim_list_uis())") .output(); if let Ok(Ok(output)) = tokio::time::timeout(std::time::Duration::from_millis(1000), check_cmd).await { let stdout = String::from_utf8_lossy(&output.stdout); // empty(nvim_list_uis()) returns 1 if it IS headless/empty, and 0 if it has a UI return stdout.trim() == "0"; } false } #[cfg(windows)] async fn get_socket_path() -> Result { let profile = dirs::home_dir() .map(|p| p.to_string_lossy().to_string()) .or_else(|| std::env::var("USERPROFILE").ok()) .ok_or_else(|| "Could not determine user home directory".to_string())?; let path = format!("{profile}\\.gemini\\active_nvim.txt"); if let Ok(content) = tokio::fs::read_to_string(&path).await { let p = content.trim().to_string(); if !p.is_empty() { let candidate = if p.starts_with(r"\\.\pipe\") { p } else if p.starts_with("nvim.") { format!(r"\\.\pipe\{p}") } else { p }; if is_interactive(&candidate).await { return Ok(candidate); } else { tracing::warn!("Stale active_nvim.txt detected on Windows. Deleting."); let _ = tokio::fs::remove_file(&path).await; } } } tracing::warn!("active_nvim.txt missing or invalid, falling back to pipe discovery"); if let Ok(mut dir) = tokio::fs::read_dir(r"\\.\pipe\").await { while let Ok(Some(entry)) = dir.next_entry().await { let name = entry.file_name(); let name_str = name.to_string_lossy(); if name_str.starts_with("nvim.") { let candidate = format!(r"\\.\pipe\{name_str}"); if is_interactive(&candidate).await { return Ok(candidate); } } } } Err("Could not find active Windows Neovim named pipe".to_string()) } #[cfg(unix)] async fn get_socket_path() -> Result { if let Some(home) = dirs::home_dir() { let path = home.join(".gemini/active_nvim.txt"); if let Ok(content) = tokio::fs::read_to_string(&path).await { let p = content.trim().to_string(); if !p.is_empty() { if is_interactive(&p).await { return Ok(p); } else { tracing::warn!("Stale active_nvim.txt detected on Unix. Deleting."); let _ = tokio::fs::remove_file(&path).await; } } } } if let Ok(mut entries) = tokio::fs::read_dir("/tmp").await { while let Ok(Some(entry)) = entries.next_entry().await { if let Ok(name) = entry.file_name().into_string() { if name.starts_with("nvim.") { let path = entry.path().join("0"); if tokio::fs::try_exists(&path).await.unwrap_or(false) { let candidate = path.to_string_lossy().to_string(); if is_interactive(&candidate).await { return Ok(candidate); } } } } } } Err("Could not find Neovim socket".to_string()) } #[cfg(windows)] async fn read_active_socket_file() -> Result { let profile = dirs::home_dir() .map(|p| p.to_string_lossy().to_string()) .or_else(|| std::env::var("USERPROFILE").ok()) .ok_or_else(|| "Could not determine user home directory".to_string())?; let path = format!("{profile}\\.gemini\\active_nvim.txt"); if let Ok(content) = tokio::fs::read_to_string(&path).await { let p = content.trim().to_string(); if !p.is_empty() { let candidate = if p.starts_with(r"\\.\pipe\") { p } else if p.starts_with("nvim.") { format!(r"\\.\pipe\{p}") } else { p }; return Ok(candidate); } } Err("Missing active_nvim.txt".to_string()) } #[cfg(unix)] async fn read_active_socket_file() -> Result { if let Some(home) = dirs::home_dir() { let path = home.join(".gemini/active_nvim.txt"); if let Ok(content) = tokio::fs::read_to_string(&path).await { let p = content.trim().to_string(); if !p.is_empty() { return Ok(p); } } } Err("Missing active_nvim.txt".to_string()) } use std::sync::Arc; use std::sync::LazyLock; use tokio::sync::{mpsc, oneshot}; pub struct NvimRequest { pub msgid: u64, pub req: rmpv::Value, pub reply: oneshot::Sender>, } use std::sync::atomic::{AtomicU64, Ordering}; static NEXT_MSGID: AtomicU64 = AtomicU64::new(1); static RPC_SEMAPHORE: LazyLock> = LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(100))); static NVIM_CONN: LazyLock>>>> = LazyLock::new(|| Arc::new(tokio::sync::Mutex::new(None))); static PENDING_REQUESTS: LazyLock< dashmap::DashMap>>, > = LazyLock::new(dashmap::DashMap::new); #[derive(Debug, Default, Clone)] pub struct NvimState { pub cursor: String, pub active_buffer_id: u64, } static NVIM_STATE: LazyLock>> = LazyLock::new(|| Arc::new(std::sync::Mutex::new(NvimState::default()))); fn handle_nvim_notification(params: &[rmpv::Value]) { if params.is_empty() { return; } if let rmpv::Value::String(event) = ¶ms[0] { match event.as_str().unwrap_or("") { "CursorMoved" => { if params.len() > 1 && let rmpv::Value::Array(pos) = ¶ms[1] && pos.len() >= 4 && let (rmpv::Value::Integer(row), rmpv::Value::Integer(col)) = (&pos[1], &pos[2]) { let mut state = NVIM_STATE.lock().unwrap_or_else(|e| e.into_inner()); state.cursor = format!("Line: {}, Column: {}", row, col); } } "TextChanged" => { tracing::debug!("Shadow buffer text changed event received"); } _ => {} } } } async fn get_nvim_connection() -> Result, String> { // Fast path: If NVIM_CONN is already active and healthy, check if active_nvim.txt matches // without spawning any external nvim.exe child process! { let conn_lock = NVIM_CONN.lock().await; if let Some(sender) = conn_lock.as_ref() { if !sender.is_closed() { let active_lock = ACTIVE_SOCKET.lock().await; if let Some(ref current_socket) = *active_lock { if let Ok(target) = read_active_socket_file().await { if !target.is_empty() && &target == current_socket { return Ok(sender.clone()); } } } } } } let interactive_socket = get_socket_path().await.ok(); let mut should_kill_headless = false; { let mut conn_lock = NVIM_CONN.lock().await; if let Some(sender) = conn_lock.as_ref() && !sender.is_closed() { let current_socket = { let active_lock = ACTIVE_SOCKET.lock().await; active_lock.clone() }; if let Some(ref interactive) = interactive_socket { if Some(interactive.clone()) != current_socket { tracing::info!( "Interactive Preemption: New Neovim instance detected at {}. Disconnecting from current.", interactive ); should_kill_headless = true; *conn_lock = None; { let mut active_lock2 = ACTIVE_SOCKET.lock().await; *active_lock2 = None; } } else { return Ok(sender.clone()); } } else { // Interactive socket is None. // If current connection was to an interactive socket, it is now dead! let is_headless = current_socket .as_ref() .map(|s| s.contains("agy-headless-nvim")) .unwrap_or(false); if !is_headless && current_socket.is_some() { tracing::info!( "Interactive Neovim window at {:?} closed. Proactively invalidating connection and falling back to headless.", current_socket ); *conn_lock = None; { let mut active_lock2 = ACTIVE_SOCKET.lock().await; *active_lock2 = None; } } else { return Ok(sender.clone()); } } } } if should_kill_headless { let child_to_kill = { let mut proc_lock = HEADLESS_PROC.lock().await; proc_lock.take() }; if let Some(mut child) = child_to_kill { let _ = child.kill().await; } } let socket_path = if let Some(p) = interactive_socket { p } else { tracing::info!("No interactive Neovim detected. Spawning headless instance."); spawn_headless_nvim().await? }; tracing::info!( "Establishing new persistent connection to Neovim at {}", socket_path ); #[cfg(windows)] let stream = { use tokio::net::windows::named_pipe::ClientOptions; ClientOptions::new() .open(&socket_path) .map_err(|e| e.to_string())? }; #[cfg(unix)] let stream = { use tokio::net::UnixStream; UnixStream::connect(&socket_path) .await .map_err(|e| e.to_string())? }; { let mut active_lock = ACTIVE_SOCKET.lock().await; *active_lock = Some(socket_path.clone()); } let (mut read_half, mut write_half) = tokio::io::split(stream); let (tx, mut rx) = mpsc::channel::(32); // Write task tokio::spawn(async move { while let Some(req) = rx.recv().await { let mut buf = Vec::new(); if let Err(e) = rmpv::encode::write_value(&mut buf, &req.req) { let _ = req.reply.send(Err(e.to_string())); continue; } PENDING_REQUESTS.insert(req.msgid, req.reply); if write_half.write_all(&buf).await.is_err() { tracing::error!("Failed to write to Neovim socket"); if let Some((_, sender)) = PENDING_REQUESTS.remove(&req.msgid) { let _ = sender.send(Err("Connection closed during write".to_string())); } let mut conn = NVIM_CONN.lock().await; *conn = None; let keys: Vec<_> = PENDING_REQUESTS.iter().map(|kv| *kv.key()).collect(); for k in keys { if let Some((_, sender)) = PENDING_REQUESTS.remove(&k) { let _ = sender.send(Err("Connection closed during write".to_string())); } } break; } } }); // Read task tokio::spawn(async move { use bytes::{Buf, BytesMut}; let mut resp_buf = BytesMut::with_capacity(65536); loop { let mut cursor = std::io::Cursor::new(&resp_buf[..]); match rmpv::decode::read_value(&mut cursor) { Ok(val) => { let parsed_len = cursor.position() as usize; resp_buf.advance(parsed_len); if let rmpv::Value::Array(ref arr) = val { if arr.len() >= 4 && arr[0] == rmpv::Value::Integer(1.into()) { let msgid = match &arr[1] { rmpv::Value::Integer(i) => i.as_u64().unwrap_or(0), _ => 0, }; if let Some((_, reply_sender)) = PENDING_REQUESTS.remove(&msgid) { let _ = reply_sender.send(Ok(val)); } } else if arr.len() >= 3 && arr[0] == rmpv::Value::Integer(2.into()) && let rmpv::Value::String(method) = &arr[1] && method.as_str().unwrap_or("") == "mcp_event" && let rmpv::Value::Array(params) = &arr[2] { handle_nvim_notification(params); } } continue; } Err(e) if match &e { rmpv::decode::Error::InvalidMarkerRead(io_err) => { io_err.kind() == std::io::ErrorKind::UnexpectedEof } rmpv::decode::Error::InvalidDataRead(io_err) => { io_err.kind() == std::io::ErrorKind::UnexpectedEof } _ => false, } => { match read_half.read_buf(&mut resp_buf).await { Ok(n) if n > 0 => {} _ => { tracing::error!("Neovim socket read loop closed or EOF"); // We need to clear the connection state so the next call reconnects let mut conn = NVIM_CONN.lock().await; *conn = None; break; } } } Err(_) => { tracing::error!("Failed to parse message from Neovim socket"); let mut conn = NVIM_CONN.lock().await; *conn = None; break; } } } // Cleanup pending requests on disconnect let keys: Vec<_> = PENDING_REQUESTS.iter().map(|kv| *kv.key()).collect(); for k in keys { if let Some((_, sender)) = PENDING_REQUESTS.remove(&k) { let _ = sender.send(Err("Connection closed".to_string())); } } }); let mut conn_lock = NVIM_CONN.lock().await; if let Some(existing_sender) = conn_lock.as_ref() && !existing_sender.is_closed() { // Another task established the connection while we were waiting return Ok(existing_sender.clone()); } *conn_lock = Some(tx.clone()); drop(conn_lock); let tx_clone = tx.clone(); tokio::spawn(async move { let setup_code = r#" local channel = vim.api.nvim_get_api_info()[1] vim.api.nvim_create_augroup("MCP_Tracking", { clear = true }) vim.api.nvim_create_autocmd({"CursorMoved", "CursorMovedI"}, { group = "MCP_Tracking", callback = function() pcall(vim.rpcnotify, channel, "mcp_event", "CursorMoved", vim.fn.getpos('.')) end }) vim.api.nvim_create_autocmd({"TextChanged", "TextChangedI", "BufEnter"}, { group = "MCP_Tracking", callback = function() pcall(vim.rpcnotify, channel, "mcp_event", "TextChanged", vim.api.nvim_get_current_buf()) end }) "#; let msgid = NEXT_MSGID.fetch_add(1, Ordering::SeqCst); let req = rmpv::Value::Array(vec![ rmpv::Value::Integer(0.into()), rmpv::Value::Integer(msgid.into()), rmpv::Value::String("nvim_exec_lua".into()), rmpv::Value::Array(vec![ rmpv::Value::String(setup_code.into()), rmpv::Value::Array(vec![]), ]), ]); let (reply_tx, _reply_rx) = tokio::sync::oneshot::channel(); let _ = tx_clone .send(NvimRequest { msgid, req, reply: reply_tx, }) .await; tracing::info!("Injected bidirectional event tracking autocmds into Neovim"); }); Ok(tx) } async fn call_nvim(req: rmpv::Value) -> Result { let msgid = if let rmpv::Value::Array(ref arr) = req { if arr.len() > 1 { match &arr[1] { rmpv::Value::Integer(i) => i.as_u64().unwrap_or(0), _ => 0, } } else { 0 } } else { 0 }; let tx = get_nvim_connection().await?; let (reply_tx, reply_rx) = oneshot::channel(); if let Err(_) = tx .send(NvimRequest { msgid, req, reply: reply_tx, }) .await { PENDING_REQUESTS.remove(&msgid); let mut conn = NVIM_CONN.lock().await; *conn = None; return Err( "Failed to send request to Neovim connection manager: connection closed".to_string(), ); } match tokio::time::timeout(tokio::time::Duration::from_secs(15), reply_rx).await { Ok(Ok(res)) => res, Ok(Err(_)) => { PENDING_REQUESTS.remove(&msgid); Err("Response channel dropped".to_string()) } Err(_) => { PENDING_REQUESTS.remove(&msgid); Err("Timeout waiting for Neovim response (15s)".to_string()) } } } async fn call_nvim_method(method: &str, args: Vec) -> Result { use rmpv::Value as RmpValue; let msgid = NEXT_MSGID.fetch_add(1, Ordering::SeqCst); let req = RmpValue::Array(vec![ RmpValue::Integer(0.into()), RmpValue::Integer(msgid.into()), RmpValue::String(method.into()), RmpValue::Array(args), ]); let resp = call_nvim(req).await?; if let RmpValue::Array(mut arr) = resp { if arr.len() < 4 { return Err("Invalid response length".to_string()); } if !arr[2].is_nil() { return Err(format!("Neovim error: {:?}", arr[2])); } return Ok(arr.swap_remove(3)); } Err("Invalid response format".to_string()) } pub async fn send_nvim_command(cmd: &str) -> Result<(), String> { call_nvim_method("nvim_command", vec![rmpv::Value::String(cmd.into())]).await?; Ok(()) } async fn get_nvim_active_buffer() -> Result { let result = call_nvim_method( "nvim_buf_get_lines", vec![ rmpv::Value::Integer(0.into()), rmpv::Value::Integer(0.into()), rmpv::Value::Integer((-1).into()), rmpv::Value::Boolean(true), ], ) .await?; if let rmpv::Value::Array(lines) = result { let mut text = String::new(); for line in lines { if let rmpv::Value::String(s) = line && let Some(s) = s.as_str() { text.push_str(s); text.push('\n'); } } return Ok(text); } Err("Invalid response format".to_string()) } async fn get_nvim_cursor() -> Result { { let state = NVIM_STATE.lock().unwrap_or_else(|e| e.into_inner()); if !state.cursor.is_empty() { return Ok(state.cursor.clone()); } } let result = call_nvim_method("nvim_win_get_cursor", vec![rmpv::Value::Integer(0.into())]).await?; if let rmpv::Value::Array(pos) = result && pos.len() == 2 && let (rmpv::Value::Integer(row), rmpv::Value::Integer(col)) = (&pos[0], &pos[1]) { return Ok(format!("Line: {row}, Column: {col}")); } Err("Invalid response format".to_string()) } async fn get_nvim_visual_selection() -> Result { 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 "#; let result = call_nvim_method( "nvim_exec_lua", vec![ rmpv::Value::String(lua_script.into()), rmpv::Value::Array(vec![]), ], ) .await?; if let rmpv::Value::String(s) = result && let Some(text) = s.as_str() { return Ok(text.to_string()); } Err("Invalid response format".to_string()) } async fn set_nvim_diagnostics(line: i64, message: &str) -> Result<(), String> { let lua_script = r#" local line, message = ... local ns = vim.api.nvim_create_namespace("gemini_diagnostics") local diagnostics = { { lnum = line - 1, col = 0, severity = vim.diagnostic.severity.WARN, message = message, } } vim.diagnostic.set(ns, 0, diagnostics, {}) "#; call_nvim_method( "nvim_exec_lua", vec![ rmpv::Value::String(lua_script.into()), rmpv::Value::Array(vec![ rmpv::Value::Integer(line.into()), rmpv::Value::String(message.into()), ]), ], ) .await?; Ok(()) } async fn set_nvim_extmark(line: i64, text: &str, hl_group: &str) -> Result<(), String> { let lua_script = r#" local line, text, hl_group = ... local ns = vim.api.nvim_create_namespace("gemini_extmarks") local opts = { virt_text = {{ text, hl_group }}, virt_text_pos = "eol", } vim.api.nvim_buf_set_extmark(0, ns, line - 1, 0, opts) "#; call_nvim_method( "nvim_exec_lua", vec![ rmpv::Value::String(lua_script.into()), rmpv::Value::Array(vec![ rmpv::Value::Integer(line.into()), rmpv::Value::String(text.into()), rmpv::Value::String(hl_group.into()), ]), ], ) .await?; Ok(()) } 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 = 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)), } } fn json_to_rmpv(val: &serde_json::Value) -> rmpv::Value { match val { serde_json::Value::Null => rmpv::Value::Nil, serde_json::Value::Bool(b) => rmpv::Value::Boolean(*b), serde_json::Value::Number(n) => { if let Some(i) = n.as_i64() { rmpv::Value::Integer(i.into()) } else if let Some(u) = n.as_u64() { rmpv::Value::Integer(u.into()) } else if let Some(f) = n.as_f64() { rmpv::Value::F64(f) } else { rmpv::Value::Nil } } serde_json::Value::String(s) => rmpv::Value::String(s.as_str().into()), serde_json::Value::Array(arr) => rmpv::Value::Array(arr.iter().map(json_to_rmpv).collect()), serde_json::Value::Object(obj) => rmpv::Value::Map( obj.iter() .map(|(k, v)| (rmpv::Value::String(k.as_str().into()), json_to_rmpv(v))) .collect(), ), } } async fn execute_nvim_lua_with_args(code: &str, args: Vec) -> Result { let result = call_nvim_method( "nvim_exec_lua", vec![rmpv::Value::String(code.into()), rmpv::Value::Array(args)], ) .await?; let json_res = rmpv_to_json(&result); // If it's a simple string, return it unquoted, otherwise format it pretty if let serde_json::Value::String(s) = json_res { Ok(s) } else { serde_json::to_string_pretty(&json_res).map_err(|e| e.to_string()) } } async fn execute_nvim_lua(code: &str) -> Result { execute_nvim_lua_with_args(code, vec![]).await } macro_rules! send_text_result { ($id:expr, $text:expr) => { send_response(JsonRpcResponse { jsonrpc: "2.0".to_string(), id: $id, result: Some(serde_json::json!({ "content": [{ "type": "text", "text": $text }] })), error: None, }).await }; } pub async fn run_mcp_loop(app_name: &str, app_version: &str) { if std::env::args().any(|arg| arg == "--version") { println!( "{} {} ({})", app_name, app_version, std::env::var("GIT_HASH").unwrap_or_else(|_| "unknown".to_string()) ); return; } let _guard = init_logging(app_name); tracing::info!("{} MCP server started", app_name); let mut stdin = tokio::io::BufReader::new(tokio::io::stdin()); loop { let raw_msg = if let Some(m) = mcp_stdio::read_mcp_message(&mut stdin).await { m } else { tracing::info!("Stdin closed, exiting loop"); headless::kill_headless_nvim().await; break; }; let msg = match serde_json::from_str::(&raw_msg) { Ok(m) => { tracing::info!("Received message method: {}", m.method); m } Err(e) => { tracing::error!( "Failed to parse JSON-RPC request from JSONL: {}. Payload: {}", e, raw_msg ); continue; } }; let app_name = app_name.to_string(); let app_version = app_version.to_string(); let permit = RPC_SEMAPHORE.clone().acquire_owned().await; if permit.is_err() { continue; } tokio::spawn(async move { let _permit = permit; let id = msg.id.unwrap_or(json!(null)); match msg.method.as_str() { "initialize" => { let init = rmcp::model::InitializeResult::new( rmcp::model::ServerCapabilities::builder() .enable_tools() .build(), ) .with_server_info(rmcp::model::Implementation::new( app_name.clone(), app_version.clone(), )) .with_protocol_version(rmcp::model::ProtocolVersion::V_2024_11_05) .with_instructions(include_str!("instructions.md")); send_response(JsonRpcResponse { jsonrpc: "2.0".to_string(), id, result: Some(serde_json::to_value(init).unwrap_or_default()), error: None, }) .await; } "notifications/initialized" => {} "resources/list" => { send_response(JsonRpcResponse { jsonrpc: "2.0".to_string(), id, result: Some(json!({"resources": []})), error: None, }) .await; } "resources/templates/list" => { send_response(JsonRpcResponse { jsonrpc: "2.0".to_string(), id, result: Some(json!({"resourceTemplates": []})), error: None, }) .await; } "prompts/list" => { send_response(JsonRpcResponse { jsonrpc: "2.0".to_string(), id, result: Some(json!({"prompts": []})), error: None, }) .await; } "tools/list" => { send_response(JsonRpcResponse { jsonrpc: "2.0".to_string(), id, result: Some(json!({ "tools": [ { "name": "nvim_buffer", "description": "Unified buffer management: inspect, open, read, search, save, reload, or close Neovim buffers.", "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["get_active", "read", "open", "create_scratch", "save", "reload", "close", "list", "search"], "description": "Action to perform on the buffer" }, "file": { "type": "string", "description": "Target file path (for open, read, search)" }, "buf_id": { "type": "integer", "description": "Buffer ID (for close, reload, or split)" }, "content": { "type": "string", "description": "Initial text content (for create_scratch)" }, "name": { "type": "string", "description": "Buffer display name (for create_scratch)" }, "filetype": { "type": "string", "description": "Filetype syntax (for open, create_scratch)" }, "start_line": { "type": "integer", "description": "1-indexed start line (for read)" }, "end_line": { "type": "integer", "description": "1-indexed end line (for read)" }, "pattern": { "type": "string", "description": "Regex or substring pattern to search for (for search)" }, "force": { "type": "boolean", "description": "Force reload or close (for reload, close)" } }, "required": ["action"] } }, { "name": "nvim_window", "description": "Manage Neovim windows and splits: list open windows, query or focus active window, create splits, or close windows.", "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["list", "get_active", "focus", "split", "close"], "description": "Window operation to perform" }, "win_id": { "type": "integer", "description": "Window ID to focus or close" }, "file": { "type": "string", "description": "File to open in split" }, "buf_id": { "type": "integer", "description": "Buffer ID to attach to split" }, "direction": { "type": "string", "enum": ["vertical", "horizontal"], "description": "Split orientation (default: vertical)" }, "force": { "type": "boolean", "description": "Force close window" } }, "required": ["action"] } }, { "name": "nvim_view", "description": "Editor navigation and viewport introspection: jump to line, query cursor position, get visible viewport lines, or get visual selection.", "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["goto_line", "get_cursor", "get_viewport", "get_selection"], "description": "Navigation/inspection action to perform" }, "file": { "type": "string", "description": "File path (for goto_line)" }, "line": { "type": "integer", "description": "Target line number (1-indexed, for goto_line)" } }, "required": ["action"] } }, { "name": "nvim_diagnostics", "description": "LSP diagnostics and quickfix integration: fetch current diagnostics, inject LSP diagnostic markers, or populate the quickfix list.", "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["get", "set", "set_quickfix"], "description": "Diagnostic action to perform" }, "line": { "type": "integer", "description": "Line number (1-indexed, for set)" }, "message": { "type": "string", "description": "Diagnostic warning/error message (for set)" }, "items": { "type": "array", "description": "Quickfix entries (for set_quickfix)", "items": { "type": "object", "properties": { "filename": { "type": "string" }, "lnum": { "type": "integer" }, "text": { "type": "string" } }, "required": ["filename", "lnum", "text"] } }, "qf_action": { "type": "string", "enum": ["replace", "append", "prepend"], "description": "Quickfix list modification action (for set_quickfix)" } }, "required": ["action"] } }, { "name": "nvim_visual", "description": "Visual feedback, syntax highlighting, ghost text extmarks, and diff preview overlays.", "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["preview", "extmark", "highlight", "clear_highlight"], "description": "Visual feedback action" }, "file_path": { "type": "string", "description": "File path (for preview)" }, "start_line": { "type": "integer", "description": "Start line (1-indexed, for preview, highlight)" }, "end_line": { "type": "integer", "description": "End line (1-indexed, for preview, highlight)" }, "preview_content": { "type": "string", "description": "Proposed replacement code (for preview)" }, "line": { "type": "integer", "description": "Line number (1-indexed, for extmark)" }, "text": { "type": "string", "description": "Virtual ghost text to display (for extmark)" }, "highlight_group": { "type": "string", "description": "Highlight group (for extmark, highlight, e.g. 'Comment', 'IncSearch')" }, "buf_id": { "type": "integer", "description": "Buffer ID (for highlight, clear_highlight)" }, "duration_ms": { "type": "integer", "description": "Auto-clear duration in ms (for highlight, default: 5000)" } }, "required": ["action"] } }, { "name": "nvim_execute_lua", "description": "Execute arbitrary Lua code directly in the active Neovim session. Primary tool for editing files via vim.api.nvim_buf_set_lines, querying editor state, and triggering notifications.", "inputSchema": { "type": "object", "properties": { "code": { "type": "string", "description": "The Lua code string to execute in Neovim" } }, "required": ["code"] } }, { "name": "nvim_system", "description": "System diagnostics, terminal interaction, and editor notification messages.", "inputSchema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["get_info", "get_messages", "send_to_terminal"], "description": "System action to perform" }, "command": { "type": "string", "description": "Shell command to send (for send_to_terminal)" }, "tail": { "type": "integer", "description": "Number of message lines to return (for get_messages)" } }, "required": ["action"] } } ] })), error: None, }).await; } "tools/call" => { let params = msg.params.unwrap_or(json!({})); let name = params.get("name").and_then(|n| n.as_str()).unwrap_or(""); let default_args = json!({}); let args = params.get("arguments").unwrap_or(&default_args); let action = args.get("action").and_then(|v| v.as_str()).unwrap_or(""); match name { "nvim_buffer" => match action { "get_active" => match get_nvim_active_buffer().await { Ok(content) => { send_text_result!(id.clone(), content); } Err(e) => send_error(id, -32603, &e).await, }, "read" => { let args_rmp = json_to_rmpv(args); let code = " local args = ... local file = args.file if not file or file == '' or file == vim.NIL then error(\"Missing 'file'\") end local start_line = tonumber(args.start_line) or 1 local end_line = tonumber(args.end_line) or -1 local start_idx = start_line - 1 local end_idx = end_line local bufnr = vim.fn.bufnr(file, true) if vim.fn.bufloaded(bufnr) == 0 then vim.fn.bufload(bufnr) end local lines = vim.api.nvim_buf_get_lines(bufnr, start_idx, end_idx, false) local res = {} for i, line in ipairs(lines) do local line_num = start_idx + i table.insert(res, tostring(line_num) .. ': ' .. line) end return table.concat(res, '\\n') "; handle_lua_result!( id, execute_nvim_lua_with_args(code, vec![args_rmp]) ) } "search" => { let args_rmp = json_to_rmpv(args); let code = " local args = ... local file = args.file local pattern = args.pattern if not file or file == '' or file == vim.NIL then error(\"Missing 'file'\") end if not pattern or pattern == '' or pattern == vim.NIL then error(\"Missing 'pattern'\") end local bufnr = vim.fn.bufnr(file, true) if vim.fn.bufloaded(bufnr) == 0 then vim.fn.bufload(bufnr) end local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local matches = {} local pcall_ok, _ = pcall(function() for i, line in ipairs(lines) do if vim.fn.match(line, '\\v' .. pattern) >= 0 then table.insert(matches, tostring(i) .. ': ' .. line) end end end) if not pcall_ok or #matches == 0 then matches = {} for i, line in ipairs(lines) do if string.find(line, pattern, 1, true) then table.insert(matches, tostring(i) .. ': ' .. line) end end if #matches > 0 then table.insert(matches, 1, 'Regex failed or no results, but found these literal matches:') else return 'No matches found.' end end return table.concat(matches, '\\n') "; handle_lua_result!( id, execute_nvim_lua_with_args(code, vec![args_rmp]) ) } "open" => { let args_rmp = json_to_rmpv(args); let code = " local args = ... local target_file = (args.file and args.file ~= '' and args.file ~= 'null') and args.file or (args.file_path and args.file_path ~= '' and args.file_path ~= 'null') and args.file_path or (args.path and args.path ~= '' and args.path ~= 'null') and args.path or (args.target and args.target ~= '' and args.target ~= 'null') and args.target if not target_file or target_file == vim.NIL then return 'Error: No valid file path provided' end local curr_buf = vim.api.nvim_get_current_buf() local is_empty_unnamed = vim.api.nvim_buf_get_name(curr_buf) == '' and vim.api.nvim_buf_get_option(curr_buf, 'modified') == false and vim.api.nvim_buf_line_count(curr_buf) <= 1 and (vim.api.nvim_buf_get_lines(curr_buf, 0, 1, false)[1] or '') == '' vim.cmd('edit ' .. vim.fn.fnameescape(target_file)) local new_buf = vim.api.nvim_get_current_buf() if is_empty_unnamed and curr_buf ~= new_buf and vim.api.nvim_buf_is_valid(curr_buf) then pcall(vim.api.nvim_buf_delete, curr_buf, { force = true }) end if args.filetype and args.filetype ~= '' then vim.bo.filetype = args.filetype end return 'Opened file ' .. target_file "; handle_lua_result!( id, execute_nvim_lua_with_args(code, vec![args_rmp]) ) } "create_scratch" | "open_buffer" => { let args_rmp = json_to_rmpv(args); let code = " local args = ... local name = (args.name and args.name ~= '') and args.name or '[Antigravity Scratch]' local listed = args.listed == true local buf = vim.api.nvim_create_buf(listed, true) pcall(vim.api.nvim_buf_set_name, buf, name) vim.bo[buf].buftype = 'nofile' vim.bo[buf].bufhidden = 'hide' if args.content then local lines = vim.split(args.content, '\\n') vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) end if args.filetype and args.filetype ~= '' then vim.bo[buf].filetype = args.filetype end vim.api.nvim_win_set_buf(0, buf) return 'Opened buffer ' .. tostring(buf) .. ' (' .. name .. ')' "; handle_lua_result!( id, execute_nvim_lua_with_args(code, vec![args_rmp]) ) } "close" => { let args_rmp = json_to_rmpv(args); let code = " local args = ... local buf = args.buf_id or vim.api.nvim_get_current_buf() local force = args.force or false vim.api.nvim_buf_delete(buf, { force = force }) return 'Closed buffer ' .. tostring(buf) "; handle_lua_result!( id, execute_nvim_lua_with_args(code, vec![args_rmp]) ) } "reload" => { let args_rmp = json_to_rmpv(args); let code = " local args = ... local buf = args.buf_id or vim.api.nvim_get_current_buf() vim.api.nvim_buf_call(buf, function() if args.force then vim.cmd('edit!') else vim.cmd('edit') end end) return 'Reloaded buffer ' .. tostring(buf) "; handle_lua_result!( id, execute_nvim_lua_with_args(code, vec![args_rmp]) ) } "save" => { let code = " vim.cmd('write') return 'Saved current buffer' "; handle_lua_result!(id, execute_nvim_lua(code)); } "list" => { let lua_code = r" local bufs = vim.api.nvim_list_bufs() local result = {} for _, buf in ipairs(bufs) do if vim.api.nvim_buf_is_loaded(buf) then local name = vim.api.nvim_buf_get_name(buf) table.insert(result, { id = buf, name = name }) end end return vim.fn.json_encode(result) "; handle_lua_result!(id, execute_nvim_lua(lua_code)) } _ => { send_error( id, -32602, &format!( "Invalid or missing action '{}' for nvim_buffer", action ), ) .await } }, "nvim_window" => match action { "list" => { let lua_code = r" local wins = vim.api.nvim_list_wins() local result = {} for _, win in ipairs(wins) do if vim.api.nvim_win_is_valid(win) then local buf = vim.api.nvim_win_get_buf(win) local name = vim.api.nvim_buf_get_name(buf) table.insert(result, { id = win, buf_id = buf, buf_name = name }) end end return vim.fn.json_encode(result) "; handle_lua_result!(id, execute_nvim_lua(lua_code)) } "get_active" => { let lua_code = r" local win = vim.api.nvim_get_current_win() return tostring(win) "; handle_lua_result!(id, execute_nvim_lua(lua_code)) } "focus" | "set_active" => { let args_rmp = json_to_rmpv(args); let code = " local args = ... if args.win_id then vim.api.nvim_set_current_win(args.win_id) return 'Active window set to ' .. tostring(args.win_id) else error('Missing win_id') end "; handle_lua_result!( id, execute_nvim_lua_with_args(code, vec![args_rmp]) ) } "split" => { let args_rmp = json_to_rmpv(args); let code = " local args = ... local cmd = args.direction == 'horizontal' and 'split' or 'vsplit' vim.cmd(cmd) if args.file and args.file ~= '' then vim.cmd('edit ' .. vim.fn.fnameescape(args.file)) elseif args.buf_id then vim.api.nvim_win_set_buf(0, args.buf_id) end return 'Split window created' "; handle_lua_result!( id, execute_nvim_lua_with_args(code, vec![args_rmp]) ) } "close" => { let args_rmp = json_to_rmpv(args); let code = " local args = ... local win = args.win_id or vim.api.nvim_get_current_win() local force = args.force or false vim.api.nvim_win_close(win, force) return 'Closed window ' .. tostring(win) "; handle_lua_result!( id, execute_nvim_lua_with_args(code, vec![args_rmp]) ) } _ => { send_error( id, -32602, &format!( "Invalid or missing action '{}' for nvim_window", action ), ) .await } }, "nvim_view" => match action { "goto_line" => { let args_rmp = json_to_rmpv(args); let code = " local args = ... local file = args.file local line = tonumber(args.line) or 1 if not file or file == '' or file == vim.NIL then error(\"Missing or empty 'file'\") end vim.cmd('edit ' .. vim.fn.fnameescape(file)) pcall(vim.api.nvim_win_set_cursor, 0, {line, 0}) vim.cmd('normal! zz') return string.format('Successfully jumped to %s line %d', file, line) "; handle_lua_result!( id, execute_nvim_lua_with_args(code, vec![args_rmp]) ) } "get_cursor" => match get_nvim_cursor().await { Ok(content) => { send_text_result!(id.clone(), content); } Err(e) => send_error(id, -32603, &e).await, }, "get_viewport" => { let code = " local first = vim.fn.line('w0') local last = vim.fn.line('w$') local total = vim.api.nvim_buf_line_count(0) local lines = vim.api.nvim_buf_get_lines(0, first - 1, last, false) local res = {} for i, line in ipairs(lines) do table.insert(res, tostring(first + i - 1) .. ': ' .. line) end local content = table.concat(res, '\\n') local pct = math.floor(((last - first + 1) / total) * 100) local result = { viewport_start = first, viewport_end = last, total_buffer_lines = total, warning = string.format('You are only seeing %d%% of this file. Do not assume variables are undefined just because they are not in the viewport.', pct), content = content } return result "; match execute_nvim_lua(code).await { Ok(res) => send_text_result!(id.clone(), res), Err(e) => send_error(id, -32603, &e).await, } } "get_selection" => match get_nvim_visual_selection().await { Ok(content) => { send_text_result!(id.clone(), content); } Err(e) => send_error(id, -32603, &e).await, }, _ => { send_error( id, -32602, &format!( "Invalid or missing action '{}' for nvim_view", action ), ) .await } }, "nvim_diagnostics" => match action { "get" => { let lua_code = r" local bufnr = vim.api.nvim_get_current_buf() local diagnostics = vim.diagnostic.get(bufnr) local result = {} for _, d in ipairs(diagnostics) do table.insert(result, { lnum = d.lnum, col = d.col, severity = d.severity, message = d.message, source = d.source }) end return vim.fn.json_encode(result) "; handle_lua_result!(id, execute_nvim_lua(lua_code)) } "set" => { if let (Some(line), Some(message)) = ( args.get("line").and_then(serde_json::Value::as_i64), args.get("message").and_then(|v| v.as_str()), ) { match set_nvim_diagnostics(line, message).await { Ok(()) => { send_text_result!( id.clone(), format!( "Successfully set diagnostic on line {}", line ) ); } Err(e) => send_error(id, -32603, &e).await, } } else { send_error(id, -32602, "Missing 'line' or 'message'").await; } } "set_quickfix" => { let args_rmp = json_to_rmpv(args); let code = " local args = ... local items = args.items or {} local action = ' ' if args.action == 'append' or args.qf_action == 'append' then action = 'a' end if args.action == 'prepend' or args.qf_action == 'prepend' then action = 'p' end if args.action == 'replace' or args.qf_action == 'replace' then action = 'r' end vim.fn.setqflist(items, action) vim.cmd('copen') return 'Populated quickfix with ' .. tostring(#items) .. ' items' "; handle_lua_result!( id, execute_nvim_lua_with_args(code, vec![args_rmp]) ) } _ => { send_error( id, -32602, &format!( "Invalid or missing action '{}' for nvim_diagnostics", action ), ) .await } }, "nvim_visual" => match action { "preview" => { if let ( Some(file), Some(start_line), Some(end_line), Some(preview_content), ) = ( args.get("file_path") .or_else(|| args.get("file")) .and_then(|v| v.as_str()), args.get("start_line").and_then(|v| v.as_i64()), args.get("end_line").and_then(|v| v.as_i64()), args.get("preview_content") .or_else(|| args.get("content")) .and_then(|v| v.as_str()), ) { use std::time::{SystemTime, UNIX_EPOCH}; let diff_id = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() .to_string(); let lines_arr: Vec = preview_content .lines() .map(|line| rmpv::Value::String(line.into())) .collect(); let lua_code = r#" local diff_id, file, start_line, end_line, lines = ... if _G.gemini and _G.gemini.preview_diff then local bufnr = vim.fn.bufnr(file) if bufnr == -1 then bufnr = 0 end _G.gemini.preview_diff(diff_id, bufnr, start_line, end_line, lines) return "Preview added" else error("gemini UI not loaded") end "#; handle_lua_result!( id, execute_nvim_lua_with_args( lua_code, vec![ rmpv::Value::String(diff_id.into()), rmpv::Value::String(file.into()), rmpv::Value::Integer(start_line.into()), rmpv::Value::Integer(end_line.into()), rmpv::Value::Array(lines_arr), ] ) ); } else { send_error(id, -32602, "Missing arguments: requires file_path, start_line, end_line, and preview_content").await; } } "extmark" => { if let (Some(line), Some(text)) = ( args.get("line").and_then(serde_json::Value::as_i64), args.get("text").and_then(|v| v.as_str()), ) { let hl_group = args .get("highlight_group") .and_then(|v| v.as_str()) .unwrap_or("Comment"); match set_nvim_extmark(line, text, hl_group).await { Ok(()) => { send_text_result!( id.clone(), format!( "Successfully set extmark on line {}", line ) ); } Err(e) => send_error(id, -32603, &e).await, } } else { send_error(id, -32602, "Missing 'line' or 'text'").await; } } "highlight" | "clear_highlight" => { let mut modified_args = args.clone(); if action == "clear_highlight" { if let Some(obj) = modified_args.as_object_mut() { obj.insert("clear_only".to_string(), json!(true)); } } let args_rmp = json_to_rmpv(&modified_args); let code = " local args = ... local buf = args.buf_id or vim.api.nvim_get_current_buf() local group = args.group or args.highlight_group or 'IncSearch' local ns = vim.api.nvim_create_namespace('antigravity_highlight') if args.clear_only then vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) return 'Cleared highlights' end vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) local start_line = tonumber(args.start_line) or 1 local end_line = tonumber(args.end_line) or start_line for i = start_line - 1, end_line - 1 do pcall(vim.api.nvim_buf_add_highlight, buf, ns, group, i, 0, -1) end local duration = args.duration_ms or 5000 if duration > 0 then vim.defer_fn(function() pcall(vim.api.nvim_buf_clear_namespace, buf, ns, 0, -1) end, duration) end return 'Highlighted lines ' .. tostring(start_line) .. ' to ' .. tostring(end_line) "; handle_lua_result!( id, execute_nvim_lua_with_args(code, vec![args_rmp]) ) } _ => { send_error( id, -32602, &format!( "Invalid or missing action '{}' for nvim_visual", action ), ) .await } }, "nvim_execute_lua" => { if let Some(code) = args.get("code").and_then(|v| v.as_str()) { // BAKE IN: Block interactive prompts that cause server deadlocks let lower_code = code.to_lowercase(); if lower_code.contains("vim.fn.input") || lower_code.contains("vim.ui.select") || lower_code.contains("vim.fn.confirm") || lower_code.contains("vim.ui.input") { send_error(id, -32600, "CRITICAL ERROR: Interactive Lua functions (input, select, confirm) are strictly forbidden via MCP as they will hang the headless server.").await; return; } // Block the 'c' confirm flag in vim.cmd substitutions if (code.contains("vim.cmd") || code.contains("vim.api.nvim_command")) && code.contains("%s") && (code.contains("gc'") || code.contains("gc\"") || code.contains("gc\n") || code.contains("c'") || code.contains("c\"")) { send_error(id, -32600, "CRITICAL ERROR: The 'c' (confirm) flag in Neovim substitutions is strictly forbidden via MCP as it triggers an interactive prompt that hangs the headless server. Use '/g' or '/ge' instead.").await; return; } handle_lua_result!(id, execute_nvim_lua(code)); } else { send_error(id, -32602, "Missing 'code'").await; } } "nvim_system" => match action { "get_info" => { let os = if cfg!(windows) { "Windows" } else { "Linux" }; let exe_path = std::env::current_exe() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| "unknown".to_string()); let socket = get_socket_path() .await .unwrap_or_else(|e| format!("Error: {}", e)); let info = format!( "Server: {}\nVersion: {}\nOS: {}\nExecutable: {}\nBackend Socket: {}", app_name, app_version, os, exe_path, socket ); send_text_result!(id, info); } "get_messages" => { let args_rmp = json_to_rmpv(args); let code = " local args = ... local msg = vim.fn.execute('messages') local lines = vim.split(msg, '\\n') if args.tail and args.tail > 0 and #lines > args.tail then local tail_lines = {} for i = #lines - args.tail + 1, #lines do table.insert(tail_lines, lines[i]) end return table.concat(tail_lines, '\\n') end return msg "; handle_lua_result!( id, execute_nvim_lua_with_args(code, vec![args_rmp]) ) } "send_to_terminal" => { if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) { let lua_code = r#" local cmd = ... if _G.gemini and _G.gemini.send_to_terminal then _G.gemini.send_to_terminal(cmd) return "Command sent to terminal" else error("gemini UI not loaded") end "#; handle_lua_result!( id, execute_nvim_lua_with_args( lua_code, vec![rmpv::Value::String(cmd.into())] ) ); } else { send_error(id, -32602, "Missing 'command'").await; } } _ => { send_error( id, -32602, &format!( "Invalid or missing action '{}' for nvim_system", action ), ) .await } }, _ => send_error(id, -32601, "Method not found").await, } } _ => { if id.is_null() { // Ignore notifications silently } else { send_error(id, -32601, "Method not found").await; } } } }); } } fn init_logging(app_name: &str) -> tracing_appender::non_blocking::WorkerGuard { let log_dir = dirs::home_dir() .unwrap_or_default() .join(".gemini/mcp_memory/logs"); std::fs::create_dir_all(&log_dir).unwrap_or_default(); let file_appender = tracing_appender::rolling::daily(log_dir, format!("{app_name}.log")); 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(); guard } #[cfg(test)] mod tests { use super::*; use tokio::io::BufReader; #[test] fn test_rmpv_to_json_primitives() { assert_eq!(rmpv_to_json(&rmpv::Value::Nil), serde_json::Value::Null); assert_eq!(rmpv_to_json(&rmpv::Value::Boolean(true)), json!(true)); assert_eq!(rmpv_to_json(&rmpv::Value::Integer(42.into())), json!(42)); assert_eq!( rmpv_to_json(&rmpv::Value::String("hello".into())), json!("hello") ); } #[test] fn test_rmpv_to_json_array() { let arr = rmpv::Value::Array(vec![ rmpv::Value::Integer(1.into()), rmpv::Value::String("test".into()), ]); assert_eq!(rmpv_to_json(&arr), json!([1, "test"])); } #[test] fn test_rmpv_to_json_map() { let map = vec![( rmpv::Value::String("key1".into()), rmpv::Value::Integer(100.into()), )]; let rmp_map = rmpv::Value::Map(map); let json_map = rmpv_to_json(&rmp_map); assert_eq!(json_map, json!({ "key1": 100 })); } #[tokio::test] async fn test_read_message_jsonl() { let input = "{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"test\"}\n"; let mut reader = BufReader::new(input.as_bytes()); let raw = mcp_stdio::read_mcp_message(&mut reader).await.unwrap(); let req: JsonRpcRequest = serde_json::from_str(&raw).unwrap(); assert_eq!(req.method, "test"); } #[tokio::test] async fn test_read_message_http_headers() { let payload = "{\"jsonrpc\": \"2.0\", \"id\": 2, \"method\": \"test2\"}"; let input = format!("Content-Length: {}\r\n\r\n{}", payload.len(), payload); let mut reader = BufReader::new(input.as_bytes()); let raw = mcp_stdio::read_mcp_message(&mut reader).await.unwrap(); let req: JsonRpcRequest = serde_json::from_str(&raw).unwrap(); assert_eq!(req.method, "test2"); } #[tokio::test] async fn test_read_message_malformed() { let input = "Content-Length: abc\r\n\r\n{}"; let mut reader = BufReader::new(input.as_bytes()); let req = mcp_stdio::read_mcp_message(&mut reader).await; assert!(req.is_none()); } #[test] fn test_nvim_state_and_notification() { let params = vec![ rmpv::Value::String("CursorMoved".into()), rmpv::Value::Array(vec![ rmpv::Value::Nil, rmpv::Value::Integer(15.into()), rmpv::Value::Integer(4.into()), rmpv::Value::Nil, ]), ]; handle_nvim_notification(¶ms); let state = NVIM_STATE.lock().unwrap(); assert_eq!(state.cursor, "Line: 15, Column: 4"); } #[tokio::test] async fn test_jsonrpc_structs_and_responses() { let req = JsonRpcRequest { jsonrpc: "2.0".to_string(), id: Some(json!(1)), method: "nvim_get_cursor".to_string(), params: None, }; let serialized = serde_json::to_string(&req).unwrap(); assert!(serialized.contains("nvim_get_cursor")); let resp = JsonRpcResponse { jsonrpc: "2.0".to_string(), id: json!(1), result: Some(json!([1, 0])), error: None, }; let resp_str = serde_json::to_string(&resp).unwrap(); assert!(resp_str.contains("result")); } #[tokio::test] async fn test_nvim_socket_and_interactive_helpers() { let is_int = is_interactive("agy-headless-nvim-1234").await; assert!(!is_int); // get_socket_path returns Ok if Neovim is running, Err otherwise - both are valid runtime states let _path = get_socket_path().await; } #[test] fn test_rmpv_to_json_conversions() { assert_eq!(rmpv_to_json(&rmpv::Value::Nil), serde_json::Value::Null); assert_eq!( rmpv_to_json(&rmpv::Value::Boolean(true)), serde_json::json!(true) ); assert_eq!( rmpv_to_json(&rmpv::Value::Integer(42.into())), serde_json::json!(42) ); assert_eq!( rmpv_to_json(&rmpv::Value::F64(3.14)), serde_json::json!(3.14) ); assert_eq!( rmpv_to_json(&rmpv::Value::String("hello".into())), serde_json::json!("hello") ); assert_eq!( rmpv_to_json(&rmpv::Value::Binary(vec![65, 66])), serde_json::json!("Binary([65, 66])") ); assert_eq!( rmpv_to_json(&rmpv::Value::Array(vec![rmpv::Value::Boolean(false)])), serde_json::json!([false]) ); let map_val = rmpv::Value::Map(vec![( rmpv::Value::String("key".into()), rmpv::Value::String("val".into()), )]); assert_eq!(rmpv_to_json(&map_val), serde_json::json!({"key": "val"})); let ext_val = rmpv::Value::Ext(1, vec![10, 20]); assert_eq!( rmpv_to_json(&ext_val), serde_json::json!("Ext(1, [10, 20])") ); } #[test] fn test_mock_nvim_get_api_info_response() { let channel_id = rmpv::Value::Integer(42.into()); let api_metadata = vec![ ( rmpv::Value::String("version".into()), rmpv::Value::Map(vec![ ( rmpv::Value::String("major".into()), rmpv::Value::Integer(0.into()), ), ( rmpv::Value::String("minor".into()), rmpv::Value::Integer(10.into()), ), ( rmpv::Value::String("patch".into()), rmpv::Value::Integer(0.into()), ), ]), ), ( rmpv::Value::String("functions".into()), rmpv::Value::Array(vec![]), ), ]; let api_info_res = rmpv::Value::Array(vec![channel_id, rmpv::Value::Map(api_metadata)]); if let rmpv::Value::Array(arr) = &api_info_res { assert_eq!(arr.len(), 2); let chan = arr[0].as_i64().unwrap(); assert_eq!(chan, 42); let json_res = rmpv_to_json(&api_info_res); assert_eq!(json_res[0], json!(42)); assert_eq!(json_res[1]["version"]["minor"], json!(10)); } else { panic!("Expected array response for nvim_get_api_info"); } } }