Files
mcp-memory/nvim-core/src/lib.rs
T

1214 lines
53 KiB
Rust

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<Value>,
pub method: String,
pub params: Option<Value>,
}
#[derive(Serialize, Debug, Clone)]
pub struct JsonRpcResponse {
pub jsonrpc: String,
pub id: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<Value>,
}
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<String, String> {
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<String, String> {
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<Result<rmpv::Value, String>>,
}
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_MSGID: AtomicU64 = AtomicU64::new(1);
static NVIM_CONN: LazyLock<Arc<std::sync::Mutex<Option<mpsc::Sender<NvimRequest>>>>> =
LazyLock::new(|| Arc::new(std::sync::Mutex::new(None)));
async fn get_nvim_connection() -> Result<mpsc::Sender<NvimRequest>, 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::<NvimRequest>(32);
type PendingRequestsMap =
Arc<std::sync::Mutex<HashMap<u64, oneshot::Sender<Result<rmpv::Value, String>>>>>;
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<rmpv::Value, String> {
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<rmpv::Value>) -> Result<rmpv::Value, String> {
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<String, String> {
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<String, String> {
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<String, String> {
let lua_script = r#"
local _, csrow, cscol, _ = unpack(vim.fn.getpos("'<"))
local _, cerow, cecol, _ = unpack(vim.fn.getpos("'>"))
local lines = vim.fn.getline(csrow, cerow)
if type(lines) == "table" then
return table.concat(lines, "\n")
else
return lines
end
"#;
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<serde_json::Value> = arr.iter().map(rmpv_to_json).collect();
serde_json::Value::Array(vec)
}
rmpv::Value::Map(map) => {
let mut obj = serde_json::Map::new();
for (k, v) in map {
let key_str = if let rmpv::Value::String(s) = k {
s.as_str().unwrap_or("").to_string()
} else {
format!("{k:?}")
};
obj.insert(key_str, rmpv_to_json(v));
}
serde_json::Value::Object(obj)
}
_ => serde_json::json!(format!("{:?}", val)),
}
}
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<rmpv::Value>) -> Result<String, String> {
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<String, String> {
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::<JsonRpcRequest>(&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());
}
}