- Remove the arbitrary 60-second read timeout on the Neovim socket that caused the read task to exit during idle periods, permanently locking the connection state. - Automatically clear the connection state variable when the read loop breaks (e.g. true EOF), allowing get_nvim_connection() to correctly auto-reconnect on the next call. - Increase the Neovim RPC call timeout from 10s to 30s to accommodate heavier operations (e.g., massive Lua regex substitutions) without falsely dropping the connection.
1415 lines
63 KiB
Rust
1415 lines
63 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::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)));
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct NvimState {
|
|
pub cursor: String,
|
|
pub active_buffer_id: u64,
|
|
}
|
|
|
|
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) = ¶ms[0] {
|
|
match event.as_str().unwrap_or("") {
|
|
"CursorMoved" => {
|
|
if params.len() > 1
|
|
&& let rmpv::Value::Array(pos) = ¶ms[1]
|
|
&& pos.len() >= 4
|
|
&& let (rmpv::Value::Integer(row), rmpv::Value::Integer(col)) = (&pos[1], &pos[2]) {
|
|
let mut state = NVIM_STATE.lock().unwrap_or_else(|e| e.into_inner());
|
|
state.cursor = format!("Line: {}, Column: {}", row, col);
|
|
}
|
|
},
|
|
"TextChanged" => {
|
|
tracing::debug!("Shadow buffer text changed event received");
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn get_nvim_connection() -> Result<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<dashmap::DashMap<u64, oneshot::Sender<Result<rmpv::Value, String>>>>;
|
|
let pending_requests: PendingRequestsMap = Arc::new(dashmap::DashMap::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.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 {
|
|
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_clone2.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().unwrap_or_else(|e| e.into_inner());
|
|
*conn = None;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Err(_) => {
|
|
tracing::error!("Failed to parse message from Neovim socket");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cleanup pending requests on disconnect
|
|
let keys: Vec<_> = pending_clone2.iter().map(|kv| *kv.key()).collect();
|
|
for k in keys {
|
|
if let Some((_, sender)) = pending_clone2.remove(&k) {
|
|
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.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());
|
|
drop(conn_lock);
|
|
|
|
let tx_clone = tx.clone();
|
|
tokio::spawn(async move {
|
|
let setup_code = r#"
|
|
local channel = vim.api.nvim_get_api_info()[1]
|
|
vim.api.nvim_create_augroup("MCP_Tracking", { clear = true })
|
|
vim.api.nvim_create_autocmd({"CursorMoved", "CursorMovedI"}, {
|
|
group = "MCP_Tracking",
|
|
callback = function()
|
|
pcall(vim.rpcnotify, channel, "mcp_event", "CursorMoved", vim.fn.getpos('.'))
|
|
end
|
|
})
|
|
vim.api.nvim_create_autocmd({"TextChanged", "TextChangedI", "BufEnter"}, {
|
|
group = "MCP_Tracking",
|
|
callback = function()
|
|
pcall(vim.rpcnotify, channel, "mcp_event", "TextChanged", vim.api.nvim_get_current_buf())
|
|
end
|
|
})
|
|
"#;
|
|
let msgid = NEXT_MSGID.fetch_add(1, Ordering::SeqCst);
|
|
let req = rmpv::Value::Array(vec![
|
|
rmpv::Value::Integer(0.into()),
|
|
rmpv::Value::Integer(msgid.into()),
|
|
rmpv::Value::String("nvim_exec_lua".into()),
|
|
rmpv::Value::Array(vec![
|
|
rmpv::Value::String(setup_code.into()),
|
|
rmpv::Value::Array(vec![]),
|
|
]),
|
|
]);
|
|
let (reply_tx, _reply_rx) = tokio::sync::oneshot::channel();
|
|
let _ = tx_clone.send(NvimRequest {
|
|
msgid,
|
|
req,
|
|
reply: reply_tx,
|
|
}).await;
|
|
tracing::info!("Injected bidirectional event tracking autocmds into Neovim");
|
|
});
|
|
|
|
Ok(tx)
|
|
}
|
|
|
|
async fn call_nvim(req: rmpv::Value) -> Result<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(30), 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 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 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(())
|
|
}
|
|
|
|
async fn set_nvim_extmark(line: i64, text: &str, hl_group: &str) -> Result<(), String> {
|
|
let escaped_text = text.replace('\\', "\\\\").replace('"', "\\\"");
|
|
let lua_script = format!(
|
|
r#"
|
|
local ns = vim.api.nvim_create_namespace("gemini_extmarks")
|
|
local opts = {{
|
|
virt_text = {{{{"{escaped_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![]),
|
|
],
|
|
)
|
|
.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_set_extmark",
|
|
"description": "Add virtual text (ghost text) to a specific line in the buffer to give the user rich UI feedback",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"line": { "type": "integer" },
|
|
"text": { "type": "string" },
|
|
"highlight_group": { "type": "string", "description": "Optional. E.g., 'DiagnosticVirtualTextInfo', 'Comment'" }
|
|
},
|
|
"required": ["line", "text"]
|
|
}
|
|
},
|
|
{
|
|
"name": "nvim_list_buffers",
|
|
"description": "List all open buffers in Neovim",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {}
|
|
}
|
|
},
|
|
{
|
|
"name": "nvim_list_windows",
|
|
"description": "List all open windows in Neovim",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {}
|
|
}
|
|
},
|
|
{
|
|
"name": "nvim_get_active_window",
|
|
"description": "Get the window ID of the currently active Neovim window",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {}
|
|
}
|
|
},
|
|
{
|
|
"name": "nvim_set_active_window",
|
|
"description": "Set the active window to the specified window ID",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"win_id": { "type": "integer" }
|
|
},
|
|
"required": ["win_id"]
|
|
}
|
|
},
|
|
{
|
|
"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_close_window",
|
|
"description": "Closes the current split window (or a specified window) without deleting the underlying buffer",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"win_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_set_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;
|
|
}
|
|
}
|
|
"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_list_windows" => {
|
|
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))
|
|
}
|
|
"nvim_get_active_window" => {
|
|
let lua_code = r"
|
|
local win = vim.api.nvim_get_current_win()
|
|
return tostring(win)
|
|
";
|
|
handle_lua_result!(id, execute_nvim_lua(lua_code))
|
|
}
|
|
"nvim_set_active_window" => {
|
|
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]))
|
|
}
|
|
"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_close_window" => {
|
|
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]))
|
|
}
|
|
"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());
|
|
}
|
|
}
|