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 stdout = tokio::io::stdout(); let _ = stdout.write_all(msg.as_bytes()).await; let _ = stdout.write_all(b"\n").await; let _ = stdout.flush().await; } 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; } #[cfg(windows)] async fn get_socket_path() -> Result { let profile = std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\reazul.ashraf".into()); 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() { if p.starts_with(r"\\.\pipe\") { return Ok(p); } else if p.starts_with("nvim.") { return Ok(format!(r"\\.\pipe\{p}")); } return Ok(p); } } 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.") { return Ok(format!(r"\\.\pipe\{name_str}")); } } } Err("Could not find active Windows Neovim named pipe".to_string()) } #[cfg(unix)] async fn get_socket_path() -> Result { if let Ok(home) = std::env::var("HOME") { let path = format!("{}/.gemini/active_nvim.txt", home); if let Ok(content) = tokio::fs::read_to_string(&path).await { let p = content.trim().to_string(); if !p.is_empty() { return Ok(p); } } } 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) { return Ok(path.to_string_lossy().to_string()); } } } } } Err("Could not find Neovim socket".to_string()) } use std::collections::HashMap; 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 NVIM_CONN: LazyLock>>>> = LazyLock::new(|| Arc::new(std::sync::Mutex::new(None))); async fn get_nvim_connection() -> Result, String> { { let conn_lock = NVIM_CONN.lock().unwrap_or_else(|e| e.into_inner()); if let Some(sender) = conn_lock.as_ref() && !sender.is_closed() { return Ok(sender.clone()); } } tracing::info!("Establishing new persistent connection to Neovim"); let socket_path = get_socket_path().await?; #[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 read_half, mut write_half) = tokio::io::split(stream); let (tx, mut rx) = mpsc::channel::(32); type PendingRequestsMap = Arc>>>>; let pending_requests: PendingRequestsMap = Arc::new(std::sync::Mutex::new(HashMap::new())); // Write task let pending_clone = Arc::clone(&pending_requests); 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_clone .lock() .unwrap_or_else(|e| e.into_inner()) .insert(req.msgid, req.reply); if write_half.write_all(&buf).await.is_err() { tracing::error!("Failed to write to Neovim socket"); break; } } }); // Read task let pending_clone2 = Arc::clone(&pending_requests); tokio::spawn(async move { let mut resp_buf = Vec::new(); let mut chunk = vec![0u8; 65536]; let mut offset = 0; loop { let mut cursor = std::io::Cursor::new(&resp_buf[offset..]); match rmpv::decode::read_value(&mut cursor) { Ok(val) => { offset += cursor.position() as usize; if let rmpv::Value::Array(ref arr) = val && 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_clone2 .lock() .unwrap_or_else(|e| e.into_inner()) .remove(&msgid) { let _ = reply_sender.send(Ok(val)); } } if offset == resp_buf.len() { resp_buf.clear(); offset = 0; } else if offset > 1024 * 1024 { resp_buf.drain(..offset); offset = 0; } 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, } => { resp_buf.drain(..offset); offset = 0; let read_future = read_half.read(&mut chunk); match tokio::time::timeout(tokio::time::Duration::from_secs(60), read_future) .await { Ok(Ok(n)) if n > 0 => { resp_buf.extend_from_slice(&chunk[..n]); } _ => { tracing::error!("Neovim socket read loop closed or timeout"); break; } } } Err(_) => { tracing::error!("Failed to parse message from Neovim socket"); break; } } } // Cleanup pending requests on disconnect let mut pending = pending_clone2.lock().unwrap_or_else(|e| e.into_inner()); for (_, sender) in pending.drain() { let _ = sender.send(Err("Connection closed".to_string())); } }); // Cleanup task for timed-out requests let pending_clone3 = Arc::clone(&pending_requests); tokio::spawn(async move { let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(10)); loop { interval.tick().await; if Arc::strong_count(&pending_clone3) <= 1 { break; // Socket closed and other tasks finished, no need to keep cleaning up } pending_clone3 .lock() .unwrap_or_else(|e| e.into_inner()) .retain(|_, sender| !sender.is_closed()); } }); let mut conn_lock = NVIM_CONN.lock().unwrap_or_else(|e| e.into_inner()); 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()); 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(); tx.send(NvimRequest { msgid, req, reply: reply_tx, }) .await .map_err(|_| "Failed to send request to Neovim connection manager")?; match tokio::time::timeout(tokio::time::Duration::from_secs(10), reply_rx).await { Ok(Ok(res)) => res, Ok(Err(_)) => Err("Response channel dropped".to_string()), Err(_) => Err("Timeout waiting for Neovim response".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()) } 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 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 escaped_message = message.replace('\\', "\\\\").replace('"', "\\\""); let lua_script = format!( r#" local ns = vim.api.nvim_create_namespace("gemini_diagnostics") local diagnostics = {{{{ lnum = {line} - 1, col = 0, severity = vim.diagnostic.severity.WARN, message = "{escaped_message}", }}}} vim.diagnostic.set(ns, 0, diagnostics, {{}}) "# ); call_nvim_method( "nvim_exec_lua", vec![ rmpv::Value::String(lua_script.into()), rmpv::Value::Array(vec![]), ], ) .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 { Ok(serde_json::to_string_pretty(&json_res).unwrap_or_default()) } } 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"); 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(); tokio::spawn(async move { 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" => {} "tools/list" => { send_response(JsonRpcResponse { jsonrpc: "2.0".to_string(), id, result: Some(json!({ "tools": [ { "name": "nvim_goto_line", "description": "Open a file and jump to a specific line", "inputSchema": { "type": "object", "properties": { "file": { "type": "string" }, "line": { "type": "integer" } }, "required": ["file", "line"] } }, { "name": "nvim_get_active_buffer", "description": "Get the contents of the currently active Neovim buffer", "inputSchema": { "type": "object", "properties": {} } }, { "name": "nvim_get_cursor", "description": "Get the current cursor position (line and column) in the active Neovim buffer", "inputSchema": { "type": "object", "properties": {} } }, { "name": "nvim_get_visual_selection", "description": "Get the text that is currently highlighted or was last highlighted in Visual mode", "inputSchema": { "type": "object", "properties": {} } }, { "name": "nvim_set_diagnostics", "description": "Push a diagnostic message (like an LSP warning) to a specific line in the active buffer", "inputSchema": { "type": "object", "properties": { "line": { "type": "integer" }, "message": { "type": "string" } }, "required": ["line", "message"] } }, { "name": "nvim_list_buffers", "description": "List all open buffers in Neovim", "inputSchema": { "type": "object", "properties": {} } }, { "name": "nvim_get_diagnostics", "description": "Get all diagnostics for the current active buffer", "inputSchema": { "type": "object", "properties": {} } }, { "name": "nvim_execute_lua", "description": "Execute arbitrary Lua code in Neovim and return the result (JSON serialized). CRITICAL: This is the primary tool for editing files. Use `vim.api.nvim_buf_set_lines` to modify buffers. ALWAYS trigger a notification (`require('notify')(...)`). ALWAYS auto-save (`vim.cmd('write')`) after edits. NEVER use interactive commands or 'c' flags in substitutions (e.g. use `vim.cmd('%s/old/new/ge')`).", "inputSchema": { "type": "object", "properties": { "code": { "type": "string" } }, "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": {} } } ] })), 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); match name { "nvim_goto_line" => { if let (Some(file), Some(line)) = ( args.get("file").and_then(|v| v.as_str()), args.get("line").and_then(serde_json::Value::as_i64), ) { let escaped_file = file.replace('\\', "\\\\"); let cmd = format!("e {escaped_file} | {line} | normal! zz"); match send_nvim_command(&cmd).await { Ok(()) => { send_text_result!( id.clone(), format!( "Successfully jumped to {} line {}", file, line ) ); } Err(e) => send_error(id, -32603, &e).await, } } else { send_error(id, -32602, "Missing 'file' or 'line'").await; } } "nvim_get_active_buffer" => match get_nvim_active_buffer().await { Ok(content) => { send_text_result!(id.clone(), content); } Err(e) => send_error(id, -32603, &e).await, }, "nvim_get_cursor" => match get_nvim_cursor().await { Ok(content) => { send_text_result!(id.clone(), content); } Err(e) => send_error(id, -32603, &e).await, }, "nvim_get_visual_selection" => match get_nvim_visual_selection().await { Ok(content) => { send_text_result!(id.clone(), content); } Err(e) => send_error(id, -32603, &e).await, }, "nvim_set_diagnostics" => { 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; } } "nvim_list_buffers" => { 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)) } "nvim_get_diagnostics" => { 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)) } "nvim_open_file" => { let args_rmp = json_to_rmpv(args); let code = " local args = ... vim.cmd('edit ' .. vim.fn.fnameescape(args.file)) if args.filetype and args.filetype ~= '' then vim.bo.filetype = args.filetype end return 'Opened file ' .. args.file "; handle_lua_result!(id, execute_nvim_lua_with_args(code, vec![args_rmp])) } "nvim_open_buffer" => { let args_rmp = json_to_rmpv(args); let code = " local args = ... local buf = vim.api.nvim_create_buf(true, true) if args.name and args.name ~= '' then pcall(vim.api.nvim_buf_set_name, buf, args.name) end 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) "; handle_lua_result!(id, execute_nvim_lua_with_args(code, vec![args_rmp])) } "nvim_close_buffer" => { 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])) } "nvim_split_window" => { 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])) } "nvim_reload_buffer" => { 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])) } "nvim_save_buffer" => { let code = " vim.cmd('write') return 'Saved current buffer' "; handle_lua_result!(id, execute_nvim_lua(code)); } "nvim_set_quickfix" => { let args_rmp = json_to_rmpv(args); let code = " local args = ... local items = args.items or {} local action = ' ' if args.action == 'append' then action = 'a' end if args.action == 'prepend' then action = 'p' end if args.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])) } "nvim_highlight_lines" => { let args_rmp = json_to_rmpv(args); let code = " local args = ... local buf = args.buf_id or vim.api.nvim_get_current_buf() local group = args.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) for i = args.start_line - 1, args.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(args.start_line) .. ' to ' .. tostring(args.end_line) "; handle_lua_result!(id, execute_nvim_lua_with_args(code, vec![args_rmp])) } "nvim_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])) } "nvim_get_viewport" => { let code = " local first = vim.fn.line('w0') local last = vim.fn.line('w$') 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 return table.concat(res, '\\n') "; match execute_nvim_lua(code).await { Ok(res) => send_text_result!(id.clone(), res), Err(e) => send_error(id, -32603, &e).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; } } _ => 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()); } }