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

2628 lines
121 KiB
Rust

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<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 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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<Result<rmpv::Value, String>>,
}
use std::sync::atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CircuitState {
Closed = 0,
Open = 1,
HalfOpen = 2,
}
pub struct NvimCircuitBreaker {
state: AtomicU8,
consecutive_failures: AtomicU32,
last_state_change_millis: AtomicU64,
failure_threshold: u32,
cooldown_millis: u64,
}
impl NvimCircuitBreaker {
pub fn new(failure_threshold: u32, cooldown_millis: u64) -> Self {
Self {
state: AtomicU8::new(CircuitState::Closed as u8),
consecutive_failures: AtomicU32::new(0),
last_state_change_millis: AtomicU64::new(0),
failure_threshold,
cooldown_millis,
}
}
pub fn current_state(&self) -> CircuitState {
match self.state.load(Ordering::SeqCst) {
1 => CircuitState::Open,
2 => CircuitState::HalfOpen,
_ => CircuitState::Closed,
}
}
pub fn can_execute(&self) -> bool {
let state = self.current_state();
match state {
CircuitState::Closed => true,
CircuitState::HalfOpen => true,
CircuitState::Open => {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let last = self.last_state_change_millis.load(Ordering::SeqCst);
if now.saturating_sub(last) >= self.cooldown_millis {
self.state
.store(CircuitState::HalfOpen as u8, Ordering::SeqCst);
tracing::info!("Neovim RPC circuit breaker transitioned to HalfOpen");
true
} else {
false
}
}
}
}
pub fn record_success(&self) {
self.consecutive_failures.store(0, Ordering::SeqCst);
let prev = self
.state
.swap(CircuitState::Closed as u8, Ordering::SeqCst);
if prev != CircuitState::Closed as u8 {
tracing::info!("Neovim RPC circuit breaker transitioned to Closed (recovered)");
}
}
pub fn record_failure(&self) {
let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
if failures >= self.failure_threshold {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
self.last_state_change_millis.store(now, Ordering::SeqCst);
self.state.store(CircuitState::Open as u8, Ordering::SeqCst);
tracing::warn!(
"Neovim RPC circuit breaker tripped to Open (consecutive failures: {})",
failures
);
}
}
pub fn reset(&self) {
self.consecutive_failures.store(0, Ordering::SeqCst);
self.state
.store(CircuitState::Closed as u8, Ordering::SeqCst);
}
}
pub static CIRCUIT_BREAKER: LazyLock<NvimCircuitBreaker> =
LazyLock::new(|| NvimCircuitBreaker::new(2, 5000));
static NEXT_MSGID: AtomicU64 = AtomicU64::new(1);
static RPC_SEMAPHORE: LazyLock<Arc<tokio::sync::Semaphore>> =
LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(100)));
static NVIM_CONN: LazyLock<Arc<tokio::sync::Mutex<Option<mpsc::Sender<NvimRequest>>>>> =
LazyLock::new(|| Arc::new(tokio::sync::Mutex::new(None)));
static PENDING_REQUESTS: LazyLock<
dashmap::DashMap<u64, oneshot::Sender<Result<rmpv::Value, String>>>,
> = LazyLock::new(dashmap::DashMap::new);
#[derive(Debug, Default, Clone)]
pub struct NvimState {
pub cursor: String,
pub active_buffer_id: u64,
pub active_buffer_name: String,
}
static NVIM_STATE: LazyLock<Arc<std::sync::Mutex<NvimState>>> =
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) = &params[0] {
match event.as_str().unwrap_or("") {
"CursorMoved" => {
if params.len() > 1
&& let rmpv::Value::Array(pos) = &params[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" | "BufEnter" => {
if params.len() > 1 {
let buf_id = match &params[1] {
rmpv::Value::Integer(i) => i.as_u64().unwrap_or(0),
_ => 0,
};
let buf_name = if params.len() > 2 {
match &params[2] {
rmpv::Value::String(s) => s.as_str().unwrap_or("").to_string(),
_ => String::new(),
}
} else {
String::new()
};
let mut state = NVIM_STATE.lock().unwrap_or_else(|e| e.into_inner());
state.active_buffer_id = buf_id;
if !buf_name.is_empty() {
state.active_buffer_name = buf_name;
}
}
tracing::debug!("Neovim buffer changed/entered event received");
}
_ => {}
}
}
}
async fn get_nvim_connection() -> Result<mpsc::Sender<NvimRequest>, 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()
&& !sender.is_closed()
{
let active_lock = ACTIVE_SOCKET.lock().await;
if let Some(ref current_socket) = *active_lock
&& let Ok(target) = read_active_socket_file().await
&& !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::<NvimRequest>(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()
local b = vim.api.nvim_get_current_buf()
local n = vim.api.nvim_buf_get_name(b)
pcall(vim.rpcnotify, channel, "mcp_event", "TextChanged", b, n)
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<rmpv::Value, String> {
if !CIRCUIT_BREAKER.can_execute() {
return Err(
"Neovim RPC circuit breaker is OPEN (consecutive failures detected). Failing fast."
.to_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 = match get_nvim_connection().await {
Ok(t) => t,
Err(e) => {
CIRCUIT_BREAKER.record_failure();
return Err(e);
}
};
let (reply_tx, reply_rx) = oneshot::channel();
if tx
.send(NvimRequest {
msgid,
req,
reply: reply_tx,
})
.await
.is_err()
{
PENDING_REQUESTS.remove(&msgid);
let mut conn = NVIM_CONN.lock().await;
*conn = None;
CIRCUIT_BREAKER.record_failure();
return Err(
"Failed to send request to Neovim connection manager: connection closed".to_string(),
);
}
match tokio::time::timeout(tokio::time::Duration::from_secs(5), reply_rx).await {
Ok(Ok(res)) => {
CIRCUIT_BREAKER.record_success();
res
}
Ok(Err(_)) => {
PENDING_REQUESTS.remove(&msgid);
let mut conn = NVIM_CONN.lock().await;
*conn = None;
CIRCUIT_BREAKER.record_failure();
Err("Response channel dropped".to_string())
}
Err(_) => {
PENDING_REQUESTS.remove(&msgid);
let mut conn = NVIM_CONN.lock().await;
*conn = None;
CIRCUIT_BREAKER.record_failure();
Err("Timeout waiting for Neovim response (5s)".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())
}
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<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 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<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 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<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 {
serde_json::to_string_pretty(&json_res).map_err(|e| e.to_string())
}
}
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");
headless::kill_headless_nvim().await;
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();
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": "Core Text Editing: read, replace, save, and manipulate Neovim buffers in memory.",
"inputSchema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["read", "replace", "save", "undo", "redo", "create_scratch"],
"description": "Action to perform on the buffer"
},
"file": { "type": "string", "description": "Target file path (for read, replace, save)" },
"start_line": { "type": "integer", "description": "1-indexed start line (for read)" },
"end_line": { "type": "integer", "description": "1-indexed end line (for read)" },
"content": { "type": "string", "description": "Initial text content (for create_scratch)" },
"name": { "type": "string", "description": "Buffer display name (for create_scratch)" },
"steps": { "type": "integer", "description": "Number of undo/redo steps to apply (default: 1)" },
"edits": {
"type": "array",
"description": "Array of edits to apply sequentially (for replace). Grouped by file, applied in descending order.",
"items": {
"type": "object",
"properties": {
"file": { "type": "string" },
"start_line": { "type": "integer" },
"end_line": { "type": "integer" },
"replacement_content": { "type": "string" },
"expected_content": { "type": "string" }
},
"required": ["file", "start_line", "end_line", "replacement_content"]
}
}
},
"required": ["action"]
}
},
{
"name": "nvim_workspace",
"description": "Window & Editor State: list buffers, windows, focus splits, and manage cwd.",
"inputSchema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["list_buffers", "list_windows", "focus", "split", "cwd"],
"description": "Workspace operation to perform"
},
"win_id": { "type": "integer", "description": "Window ID to focus" },
"file": { "type": "string", "description": "File to open in split" },
"direction": { "type": "string", "enum": ["vertical", "horizontal"], "description": "Split orientation (default: vertical)" },
"path": { "type": "string", "description": "Target directory (for cwd action)" }
},
"required": ["action"]
}
},
{
"name": "nvim_intelligence",
"description": "Code Semantics: LSP queries (hover, definition, references, code_action, rename, diagnostics) and AST outlining/queries.",
"inputSchema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["hover", "definition", "references", "outline", "query", "diagnostics", "rename", "code_action"],
"description": "Intelligence action to perform"
},
"file": { "type": "string", "description": "Target file path" },
"line": { "type": "integer", "description": "1-indexed line number (for LSP)" },
"col": { "type": "integer", "description": "0-indexed column number (for LSP)" },
"new_name": { "type": "string", "description": "New name (for rename action)" },
"query": { "type": "string", "description": "Tree-sitter query string (for AST query)" },
"preset": { "type": "string", "description": "Query preset (e.g., 'functions', 'classes' for AST query)" }
},
"required": ["action"]
}
},
{
"name": "nvim_ui",
"description": "Visual Feedback: communicating visually with the human user via highlights and ghost text.",
"inputSchema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["highlight", "ghost_text", "clear"],
"description": "UI action"
},
"buf_id": { "type": "integer", "description": "Buffer ID to apply to" },
"start_line": { "type": "integer", "description": "1-indexed start line (for highlight)" },
"end_line": { "type": "integer", "description": "1-indexed end line (for highlight)" },
"line": { "type": "integer", "description": "1-indexed line number (for ghost_text)" },
"text": { "type": "string", "description": "Virtual text to display (for ghost_text)" },
"highlight_group": { "type": "string", "description": "Highlight group (e.g. 'Comment', 'IncSearch')" },
"duration_ms": { "type": "integer", "description": "Auto-clear duration in ms (for highlight, default: 5000)" }
},
"required": ["action"]
}
},
{
"name": "nvim_exec",
"description": "The Escape Hatch: execute lua read-only queries, run vimscript commands, or send commands to the terminal.",
"inputSchema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["lua", "vimscript", "terminal"],
"description": "Execution action"
},
"code": { "type": "string", "description": "Lua code or Vimscript command to execute" },
"command": { "type": "string", "description": "Shell command to send (for terminal)" }
},
"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("");
let (mapped_name, mapped_action) = match (name, action) {
("nvim_buffer", "read") => ("nvim_buffer", "read"),
("nvim_buffer", "replace") => ("nvim_buffer", "edit"),
("nvim_buffer", "save") => ("nvim_buffer", "save"),
("nvim_buffer", "undo") => ("nvim_buffer", "undo"),
("nvim_buffer", "redo") => ("nvim_buffer", "redo"),
("nvim_buffer", "create_scratch") => ("nvim_buffer", "create_scratch"),
("nvim_workspace", "list_buffers") => ("nvim_buffer", "list"),
("nvim_workspace", "list_windows") => ("nvim_window", "list"),
("nvim_workspace", "focus") => ("nvim_window", "focus"),
("nvim_workspace", "split") => ("nvim_window", "split"),
("nvim_workspace", "cwd") => ("nvim_system", "cwd"),
("nvim_intelligence", "hover") => ("nvim_lsp", "hover"),
("nvim_intelligence", "definition") => ("nvim_lsp", "definition"),
("nvim_intelligence", "references") => ("nvim_lsp", "references"),
("nvim_intelligence", "rename") => ("nvim_lsp", "rename"),
("nvim_intelligence", "code_action") => ("nvim_lsp", "code_action"),
("nvim_intelligence", "outline") => ("nvim_ast", "outline"),
("nvim_intelligence", "query") => ("nvim_ast", "query"),
("nvim_intelligence", "diagnostics") => ("nvim_diagnostics", "get"),
("nvim_ui", "highlight") => ("nvim_visual", "highlight"),
("nvim_ui", "ghost_text") => ("nvim_visual", "extmark"),
("nvim_ui", "clear") => ("nvim_visual", "clear_highlight"),
("nvim_exec", "lua") => ("nvim_execute_lua", ""),
("nvim_exec", "vimscript") => ("nvim_system", "vimscript"),
("nvim_exec", "terminal") => ("nvim_system", "send_to_terminal"),
(n, a) => (n, a),
};
match mapped_name {
"nvim_buffer" => match mapped_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])
)
}
"edit" => {
let mut modified_args = args.clone();
if let Some(edits) = modified_args
.get_mut("edits")
.and_then(|v| v.as_array_mut())
{
for edit in edits.iter_mut() {
if let Some(content) = edit.get_mut("replacement_content")
&& let Some(s) = content.as_str()
{
let lines: Vec<String> = s
.split('\n')
.map(|l| l.trim_end_matches('\r').to_string())
.collect();
*content = serde_json::json!(lines);
}
}
} else if let Some(content) =
modified_args.get_mut("replacement_content")
&& let Some(s) = content.as_str()
{
let lines: Vec<String> = s
.split('\n')
.map(|l| l.trim_end_matches('\r').to_string())
.collect();
*content = serde_json::json!(lines);
}
let args_rmp = json_to_rmpv(&modified_args);
let code = "
local args = ...
local edits = args.edits
if not edits or type(edits) ~= 'table' then
if args.file and args.start_line and args.end_line and args.replacement_content then
edits = {
{
file = args.file,
start_line = tonumber(args.start_line),
end_line = tonumber(args.end_line),
replacement_content = args.replacement_content,
expected_content = args.expected_content
}
}
else
error(\"Missing 'edits' array or valid single edit parameters\")
end
end
local grouped_edits = {}
for i, edit in ipairs(edits) do
local file = edit.file
if not file or file == '' or file == vim.NIL then
error(\"Edit missing 'file'\")
end
if not grouped_edits[file] then
grouped_edits[file] = {}
end
table.insert(grouped_edits[file], {
start_line = tonumber(edit.start_line),
end_line = tonumber(edit.end_line),
replacement_content = edit.replacement_content,
expected_content = edit.expected_content,
original_index = i
})
end
local results = {}
for file, file_edits in pairs(grouped_edits) do
local bufnr = vim.fn.bufnr(file, true)
if vim.fn.bufloaded(bufnr) == 0 then
vim.fn.bufload(bufnr)
end
table.sort(file_edits, function(a, b)
return a.start_line > b.start_line
end)
for _, edit in ipairs(file_edits) do
local sl = edit.start_line
local el = edit.end_line
local rc = edit.replacement_content
local ec = edit.expected_content
if not sl then error(\"Edit missing 'start_line'\") end
if not el then error(\"Edit missing 'end_line'\") end
if not rc or rc == vim.NIL then error(\"Edit missing 'replacement_content'\") end
if ec and type(ec) == 'string' and ec ~= vim.NIL and ec ~= '' then
local current_lines = vim.api.nvim_buf_get_lines(bufnr, sl - 1, el, false)
local current_text = table.concat(current_lines, '\\n')
local ec_clean = string.gsub(ec, '\\r', '')
if current_text ~= ec_clean then
error(\"Safety check failed in \" .. file .. \": Expected content at lines \" .. tostring(sl) .. \"-\" .. tostring(el) .. \" does not match actual content.\\nExpected:\\n\" .. ec_clean .. \"\\nActual:\\n\" .. current_text)
end
end
vim.api.nvim_buf_set_lines(bufnr, sl - 1, el, false, rc)
end
local win = vim.fn.bufwinid(bufnr)
if win ~= -1 and #file_edits > 0 then
pcall(vim.api.nvim_win_set_cursor, win, {file_edits[#file_edits].start_line, 0})
end
table.insert(results, \"Edited \" .. file .. \" (\" .. tostring(#file_edits) .. \" blocks)\")
end
return table.concat(results, ', ')
";
handle_lua_result!(
id,
execute_nvim_lua_with_args(code, vec![args_rmp])
)
}
"undo" | "redo" => {
let is_undo = action == "undo";
let steps = args.get("steps").and_then(|v| v.as_i64()).unwrap_or(1);
let cmd = if is_undo {
format!("{}u", steps)
} else {
format!("{}<C-r>", steps)
};
let code = format!(
"
local pcall_ok, err = pcall(function()
vim.cmd('normal! {}')
end)
if not pcall_ok then
error('Failed to {}: ' .. tostring(err))
end
return '{} applied {} step(s)'
",
cmd, action, action, steps
);
handle_lua_result!(id, execute_nvim_lua(&code));
}
"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 mut modified_args = args.clone();
if let Some(content) = modified_args.get_mut("content")
&& let Some(s) = content.as_str()
{
let lines: Vec<String> = s
.split('\n')
.map(|l| l.trim_end_matches('\r').to_string())
.collect();
*content = serde_json::json!(lines);
}
let args_rmp = json_to_rmpv(&modified_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
vim.api.nvim_buf_set_lines(buf, 0, -1, false, args.content)
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<rmpv::Value> = 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 { status = "Preview added", diff_id = diff_id }
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;
}
}
"prompt" | "prompt_user" => {
let prompt_text = args
.get("prompt")
.or_else(|| args.get("prompt_text"))
.and_then(|v| v.as_str());
if let Some(prompt) = prompt_text {
use std::time::{SystemTime, UNIX_EPOCH};
let request_id = args
.get("request_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.to_string()
});
let options_arr =
match args.get("options").and_then(|v| v.as_array()) {
Some(arr) => {
let rmp_arr: Vec<rmpv::Value> = arr
.iter()
.filter_map(|item| item.as_str())
.map(|s| rmpv::Value::String(s.into()))
.collect();
rmpv::Value::Array(rmp_arr)
}
None => rmpv::Value::Nil,
};
let lua_code = r#"
local prompt_text, options, request_id = ...
if _G.gemini and _G.gemini.prompt_user then
_G.gemini.prompt_user(prompt_text, options, request_id)
return { status = "Prompt displayed", request_id = request_id }
else
error("gemini UI not loaded")
end
"#;
handle_lua_result!(
id,
execute_nvim_lua_with_args(
lua_code,
vec![
rmpv::Value::String(prompt.into()),
options_arr,
rmpv::Value::String(request_id.into()),
]
)
);
} else {
send_error(id, -32602, "Missing 'prompt' or 'prompt_text'")
.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"
&& 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()) {
// READ-ONLY ENFORCEMENT: Block any Lua code that attempts to mutate state.
let lower_code = code.to_lowercase();
if lower_code.contains("vim.cmd")
|| lower_code.contains("nvim_buf_set_lines")
|| lower_code.contains("nvim_buf_set_text")
|| lower_code.contains("nvim_command")
|| lower_code.contains("nvim_set_current")
|| lower_code.contains("nvim_win_set")
|| lower_code.contains("nvim_buf_set_name")
{
send_error(id, -32600, "CRITICAL ERROR: nvim_execute_lua is restricted to READ-ONLY queries to prevent agents from bypassing the specialized visual tools (nvim_buffer, nvim_visual). Use the designated tools to mutate editor state.").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;
}
}
"cwd" => {
let lua_code =
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
format!(
"vim.cmd('cd {}'); return vim.fn.getcwd()",
path.replace("\\", "\\\\").replace("'", "\\'")
)
} else {
"return vim.fn.getcwd()".to_string()
};
handle_lua_result!(id, execute_nvim_lua(&lua_code));
}
"vimscript" => {
if let Some(code) = args.get("code").and_then(|v| v.as_str()) {
let lua_code =
format!("vim.cmd([=[{}]=]); return 'Success'", code);
handle_lua_result!(id, execute_nvim_lua(&lua_code));
} else {
send_error(id, -32602, "Missing 'code'").await;
}
}
_ => {
send_error(
id,
-32602,
&format!(
"Invalid or missing action '{}' for nvim_system",
action
),
)
.await
}
},
"nvim_lsp" => {
let args_rmp = json_to_rmpv(args);
let lua_code = match action {
"hover" => {
r#"
local args = ...
local bufnr = vim.fn.bufnr(args.file)
if bufnr == -1 then return "Buffer not found" end
local params = vim.lsp.util.make_position_params(0)
params.position.line = (tonumber(args.line) or 1) - 1
params.position.character = tonumber(args.col) or 0
params.textDocument.uri = vim.uri_from_bufnr(bufnr)
local res, err = vim.lsp.buf_request_sync(bufnr, 'textDocument/hover', params, 2000)
if err or not res then return "Error or timeout" end
for client_id, response in pairs(res) do
if response.result and response.result.contents then
local contents = response.result.contents
if type(contents) == 'table' and contents.value then return contents.value end
if type(contents) == 'string' then return contents end
end
end
return "No hover info"
"#
}
"definition" => {
r#"
local args = ...
local bufnr = vim.fn.bufnr(args.file)
if bufnr == -1 then return "Buffer not found" end
local params = vim.lsp.util.make_position_params(0)
params.position.line = (tonumber(args.line) or 1) - 1
params.position.character = tonumber(args.col) or 0
params.textDocument.uri = vim.uri_from_bufnr(bufnr)
local res, err = vim.lsp.buf_request_sync(bufnr, 'textDocument/definition', params, 2000)
if err or not res then return "Error or timeout" end
local locations = {}
for client_id, response in pairs(res) do
if response.result then
local res_locs = vim.tbl_islist(response.result) and response.result or {response.result}
for _, loc in ipairs(res_locs) do
local uri = loc.uri or loc.targetUri
local range = loc.range or loc.targetSelectionRange
table.insert(locations, vim.uri_to_fname(uri) .. ":" .. (range.start.line + 1))
end
end
end
if #locations == 0 then return "No definition found" end
return table.concat(locations, "\n")
"#
}
"references" => {
r#"
local args = ...
local bufnr = vim.fn.bufnr(args.file)
if bufnr == -1 then return "Buffer not found" end
local params = vim.lsp.util.make_position_params(0)
params.position.line = (tonumber(args.line) or 1) - 1
params.position.character = tonumber(args.col) or 0
params.context = { includeDeclaration = true }
params.textDocument.uri = vim.uri_from_bufnr(bufnr)
local res, err = vim.lsp.buf_request_sync(bufnr, 'textDocument/references', params, 5000)
if err or not res then return "Error or timeout" end
local locations = {}
for client_id, response in pairs(res) do
if response.result then
for _, loc in ipairs(response.result) do
table.insert(locations, vim.uri_to_fname(loc.uri) .. ":" .. (loc.range.start.line + 1))
end
end
end
if #locations == 0 then return "No references found" end
return table.concat(locations, "\n")
"#
}
"rename" => {
r#"
local args = ...
local bufnr = vim.fn.bufnr(args.file)
if bufnr == -1 then return "Buffer not found" end
local new_name = args.new_name
if not new_name then return "Missing new_name" end
local params = vim.lsp.util.make_position_params(0)
params.position.line = (tonumber(args.line) or 1) - 1
params.position.character = tonumber(args.col) or 0
params.newName = new_name
params.textDocument.uri = vim.uri_from_bufnr(bufnr)
local res, err = vim.lsp.buf_request_sync(bufnr, 'textDocument/rename', params, 2000)
if err or not res then return "Error or timeout" end
local applied = false
for client_id, response in pairs(res) do
if response.result then
vim.lsp.util.apply_workspace_edit(response.result, vim.lsp.get_client_by_id(client_id).offset_encoding)
applied = true
end
end
return applied and "Rename applied" or "No rename edits"
"#
}
"code_action" => {
r#"
local args = ...
local bufnr = vim.fn.bufnr(args.file)
if bufnr == -1 then return "Buffer not found" end
local params = vim.lsp.util.make_range_params(0)
params.position = nil
params.range.start.line = (tonumber(args.line) or 1) - 1
params.range.start.character = tonumber(args.col) or 0
params.range['end'] = params.range.start
params.context = { diagnostics = vim.lsp.diagnostic.get_line_diagnostics(bufnr, params.range.start.line) }
params.textDocument.uri = vim.uri_from_bufnr(bufnr)
local res, err = vim.lsp.buf_request_sync(bufnr, 'textDocument/codeAction', params, 2000)
if err or not res then return "Error or timeout" end
local actions = {}
for client_id, response in pairs(res) do
if response.result then
for _, action in ipairs(response.result) do
if action.edit then
vim.lsp.util.apply_workspace_edit(action.edit, vim.lsp.get_client_by_id(client_id).offset_encoding)
table.insert(actions, "Applied: " .. (action.title or "unnamed"))
elseif action.command then
table.insert(actions, "Found command (not auto-applied): " .. (action.title or "unnamed"))
end
end
end
end
if #actions == 0 then return "No code actions available or applied" end
return table.concat(actions, "\n")
"#
}
_ => "",
};
if lua_code.is_empty() {
send_error(
id,
-32602,
&format!("Invalid nvim_lsp action: {}", action),
)
.await;
} else {
handle_lua_result!(
id,
execute_nvim_lua_with_args(lua_code, vec![args_rmp])
);
}
}
"nvim_ast" => {
let args_rmp = json_to_rmpv(args);
let lua_code = match action {
"query" => {
r#"
local args = ...
local bufnr = vim.fn.bufnr(args.file)
if bufnr == -1 then return "Buffer not found" end
local query_str = args.query
if not query_str then return "Missing query string" end
local parser = vim.treesitter.get_parser(bufnr)
if not parser then return "No TS parser for buffer" end
local tree = parser:parse()[1]
local root = tree:root()
local query = vim.treesitter.query.parse(parser:lang(), query_str)
local results = {}
for id, node, metadata in query:iter_captures(root, bufnr, 0, -1) do
local name = query.captures[id]
local row1, col1, row2, col2 = node:range()
local text = vim.treesitter.get_node_text(node, bufnr)
table.insert(results, name .. " at line " .. tostring(row1 + 1) .. ":\n" .. text)
end
return table.concat(results, "\n\n")
"#
}
"outline" => {
r#"
local args = ...
local bufnr = vim.fn.bufnr(args.file)
if bufnr == -1 then return "Buffer not found" end
local parser = vim.treesitter.get_parser(bufnr)
if not parser then return "No TS parser for buffer" end
local tree = parser:parse()[1]
local root = tree:root()
local lang = parser:lang()
local q_str = ""
if lang == "rust" then
q_str = "(function_item name: (identifier) @name) (impl_item type: (type_identifier) @name) (struct_item name: (type_identifier) @name) (trait_item name: (type_identifier) @name)"
elseif lang == "python" then
q_str = "(function_definition name: (identifier) @name) (class_definition name: (identifier) @name)"
elseif lang == "javascript" or lang == "typescript" then
q_str = "(function_declaration name: (identifier) @name) (class_declaration name: (identifier) @name) (method_definition name: (property_identifier) @name)"
else
return "Outline preset not supported for language: " .. lang
end
local query = vim.treesitter.query.parse(lang, q_str)
local results = {}
for id, node, metadata in query:iter_captures(root, bufnr, 0, -1) do
local name_text = vim.treesitter.get_node_text(node, bufnr)
local parent = node:parent()
local row1, col1, row2, col2 = parent:range()
table.insert(results, name_text .. " (Line " .. tostring(row1 + 1) .. "-" .. tostring(row2 + 1) .. ")")
end
return table.concat(results, "\n")
"#
}
_ => "",
};
if lua_code.is_empty() {
send_error(
id,
-32602,
&format!("Invalid nvim_ast action: {}", action),
)
.await;
} else {
handle_lua_result!(
id,
execute_nvim_lua_with_args(lua_code, vec![args_rmp])
);
}
}
_ => 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(&params);
{
let state = NVIM_STATE.lock().unwrap();
assert_eq!(state.cursor, "Line: 15, Column: 4");
}
let text_params = vec![
rmpv::Value::String("TextChanged".into()),
rmpv::Value::Integer(157.into()),
rmpv::Value::String("server/src/dashboard.ts".into()),
];
handle_nvim_notification(&text_params);
{
let state = NVIM_STATE.lock().unwrap();
assert_eq!(state.active_buffer_id, 157);
assert_eq!(state.active_buffer_name, "server/src/dashboard.ts");
}
}
#[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.5)), serde_json::json!(3.5));
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");
}
}
#[test]
fn test_nvim_circuit_breaker_transitions() {
let cb = NvimCircuitBreaker::new(2, 50); // 2 failures, 50ms cooldown
assert_eq!(cb.current_state(), CircuitState::Closed);
assert!(cb.can_execute());
// 1st failure - remains Closed
cb.record_failure();
assert_eq!(cb.current_state(), CircuitState::Closed);
assert!(cb.can_execute());
// 2nd failure - trips to Open
cb.record_failure();
assert_eq!(cb.current_state(), CircuitState::Open);
assert!(
!cb.can_execute(),
"Circuit breaker should fail fast when Open"
);
// Wait for cooldown
std::thread::sleep(std::time::Duration::from_millis(60));
// After cooldown, can_execute transitions to HalfOpen
assert!(
cb.can_execute(),
"After cooldown, should allow HalfOpen probe"
);
assert_eq!(cb.current_state(), CircuitState::HalfOpen);
// Success in HalfOpen recovers back to Closed
cb.record_success();
assert_eq!(cb.current_state(), CircuitState::Closed);
assert!(cb.can_execute());
}
}