refactor(mcp): strip sse fallback architecture in favor of pure websockets and fix nvim NDJSON bug

This commit is contained in:
Riz Ashraf committed 2026-09-17 15:26:22 +01:00
1 parent 0e29b12ac8
commit 3716c3e698
33 files changed
+2082 -1756

No files matched your search

+3
View File
@@ -14,3 +14,6 @@ tracing-appender = "0.2.5"
tracing = "0.1.44"
tracing-subscriber = "0.3.23"
dirs = "7.0.0"
rustls = "0.22.4"
rustls-pki-types = "1"
nvim-core = { path = "../nvim-core" }
+4 -8
View File
@@ -1,13 +1,9 @@
#[cfg(unix)]
mod unix_app;
#[cfg(unix)]
fn main() {
if std::env::args().any(|a| a == "--version" || a == "-V") {
println!("mcp-memory-linux-nvim {}", env!("APP_VERSION"));
return;
}
unix_app::main();
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
nvim_core::run_mcp_loop("mcp-memory-linux-nvim", env!("APP_VERSION")).await;
});
}
#[cfg(not(unix))]
-61
View File
@@ -1,61 +0,0 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
#[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 read_message(stdin: &mut BufReader<tokio::io::Stdin>) -> Option<JsonRpcRequest> {
let mut length = 0;
loop {
let mut line = String::new();
if stdin.read_line(&mut line).await.unwrap_or(0) == 0 {
return None;
}
let line = line.trim_end();
if line.is_empty() {
break;
}
if let Some(len_str) = line.strip_prefix("Content-Length: ") {
length = len_str.parse().unwrap_or(0);
}
}
if length == 0 {
return None;
}
let mut buffer = vec![0; length];
stdin.read_exact(&mut buffer).await.unwrap_or(0);
serde_json::from_slice(&buffer).ok()
}
pub async fn send_response(response: JsonRpcResponse) {
let msg = serde_json::to_string(&response).unwrap();
let payload = format!("Content-Length: {}\r\n\r\n{}", msg.len(), msg);
let mut stdout = tokio::io::stdout();
let _ = stdout.write_all(payload.as_bytes()).await;
let _ = stdout.flush().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;
}
-612
View File
@@ -1,612 +0,0 @@
#[path = "mcp.rs"]
pub mod mcp;
use mcp::{read_message, send_response, send_error, JsonRpcResponse};
use serde_json::json;
use tokio::net::UnixStream;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
fn init_logging(app_name: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
let base_dir = std::env::var("MCP_MEMORY_STORE_DIR").unwrap_or_else(|_| {
dirs::home_dir()
.map(|mut h| {
h.push(".gemini/mcp_memory");
h.to_string_lossy().to_string()
})
.unwrap_or_else(|| ".gemini/mcp_memory".into())
});
let log_dir = std::path::PathBuf::from(base_dir).join("logs");
std::fs::create_dir_all(&log_dir).unwrap_or_default();
let file_appender = tracing_appender::rolling::daily(log_dir, format!("{}.log", app_name));
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();
Some(guard)
}
#[tokio::main]
pub async fn main() {
let _guard = init_logging("linux-nvim");
let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
loop {
let msg = match read_message(&mut stdin).await {
Some(m) => m,
None => break,
};
tokio::spawn(async move {
let id = msg.id.clone().unwrap_or(json!(null));
match msg.method.as_str() {
"initialize" => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(json!({
"capabilities": {},
"serverInfo": {
"name": "mcp-memory-linux-nvim",
"version": "0.1.0"
}
})),
error: None,
}).await;
}
"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_execute_lua",
"description": "Execute arbitrary Lua code in Neovim and return the result (JSON serialized).",
"inputSchema": {
"type": "object",
"properties": {
"code": { "type": "string" }
},
"required": ["code"]
}
},
{
"name": "nvim_list_buffers",
"description": "Get a list of all loaded Neovim buffers and their IDs.",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "nvim_get_diagnostics",
"description": "Get all LSP diagnostics (errors, warnings) for the active buffer.",
"inputSchema": {
"type": "object",
"properties": {}
}
}
]
})),
error: None,
}).await;
}
"tools/call" => {
let params = msg.params.clone().unwrap_or(json!({}));
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
let args = params.get("arguments").cloned().unwrap_or(json!({}));
match name {
"nvim_goto_line" => {
let file = args.get("file").and_then(|v| v.as_str()).unwrap_or("");
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(1);
let cmd = format!("edit {} | {} | normal! zz", file, line);
match send_nvim_command(&cmd).await {
Ok(_) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(json!({
"content": [
{ "type": "text", "text": format!("Successfully jumped to {}:{}", file, line) }
]
})),
error: None,
}).await;
}
Err(e) => {
send_error(id, -32603, &format!("Failed to execute command: {}", e)).await;
}
}
}
"nvim_get_active_buffer" => {
match get_nvim_active_buffer().await {
Ok(content) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(json!({
"content": [
{ "type": "text", "text": content }
]
})),
error: None,
}).await;
}
Err(e) => {
send_error(id, -32603, &format!("Failed to get active buffer: {}", e)).await;
}
}
}
"nvim_get_cursor" => {
match get_nvim_cursor().await {
Ok(content) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(json!({
"content": [
{ "type": "text", "text": content }
]
})),
error: None,
}).await;
}
Err(e) => {
send_error(id, -32603, &format!("Failed to get cursor: {}", e)).await;
}
}
}
"nvim_get_visual_selection" => {
match get_nvim_visual_selection().await {
Ok(content) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(json!({
"content": [
{ "type": "text", "text": content }
]
})),
error: None,
}).await;
}
Err(e) => {
send_error(id, -32603, &format!("Failed to get visual selection: {}", e)).await;
}
}
}
"nvim_set_diagnostics" => {
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(1);
let message = args.get("message").and_then(|v| v.as_str()).unwrap_or("");
match set_nvim_diagnostics(line, message).await {
Ok(_) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(json!({
"content": [
{ "type": "text", "text": format!("Successfully set diagnostic on line {}", line) }
]
})),
error: None,
}).await;
}
Err(e) => {
send_error(id, -32603, &format!("Failed to set diagnostic: {}", e)).await;
}
}
}
"nvim_execute_lua" => {
let code = args.get("code").and_then(|v| v.as_str()).unwrap_or("");
match execute_nvim_lua(code).await {
Ok(content) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(), id, result: Some(json!({ "content": [{ "type": "text", "text": content }] })), error: None,
}).await;
}
Err(e) => { send_error(id, -32603, &format!("Failed to execute lua: {}", e)).await; }
}
}
"nvim_list_buffers" => {
let code = r#"
local bufs = vim.api.nvim_list_bufs()
local loaded = {}
for _, b in ipairs(bufs) do
if vim.api.nvim_buf_is_loaded(b) then
local name = vim.api.nvim_buf_get_name(b)
table.insert(loaded, {id = b, name = name == "" and "[No Name]" or name})
end
end
return loaded
"#;
match execute_nvim_lua(code).await {
Ok(content) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(), id, result: Some(json!({ "content": [{ "type": "text", "text": content }] })), error: None,
}).await;
}
Err(e) => { send_error(id, -32603, &format!("Failed to list buffers: {}", e)).await; }
}
}
"nvim_get_diagnostics" => {
let code = r#"
local diags = vim.diagnostic.get(0)
local res = {}
for _, d in ipairs(diags) do
table.insert(res, {
line = d.lnum + 1,
col = d.col,
message = d.message,
severity = d.severity
})
end
return res
"#;
match execute_nvim_lua(code).await {
Ok(content) => {
send_response(JsonRpcResponse {
jsonrpc: "2.0".to_string(), id, result: Some(json!({ "content": [{ "type": "text", "text": content }] })), error: None,
}).await;
}
Err(e) => { send_error(id, -32603, &format!("Failed to get diagnostics: {}", e)).await; }
}
}
_ => {
send_error(id, -32601, "Tool not found").await;
}
}
}
_ => {
// Ignore other methods
}
}
});
}
}
async fn get_socket_path() -> Result<String, String> {
// 1. Try active_nvim.txt first
if let Ok(home) = std::env::var("HOME") {
let path = format!("{}/.gemini/active_nvim.txt", home);
if let Ok(content) = std::fs::read_to_string(&path) {
let p = content.trim().to_string();
if !p.is_empty() {
return Ok(p);
}
}
}
// 2. Fallback: search /tmp/nvim.*/0
if let Ok(entries) = std::fs::read_dir("/tmp") {
for entry in entries.flatten() {
if let Ok(name) = entry.file_name().into_string() {
if name.starts_with("nvim.") {
let path = entry.path().join("0");
if path.exists() {
return Ok(path.to_string_lossy().to_string());
}
}
}
}
}
Err("Could not find Neovim socket".to_string())
}
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 { arr[1].clone() } else { rmpv::Value::Nil }
} else { rmpv::Value::Nil };
let socket_path = get_socket_path().await?;
let mut stream = UnixStream::connect(socket_path).await.map_err(|e| e.to_string())?;
let mut buf = Vec::new();
rmpv::encode::write_value(&mut buf, &req).map_err(|e| e.to_string())?;
stream.write_all(&buf).await.map_err(|e| e.to_string())?;
let mut resp_buf = Vec::new();
let mut chunk = vec![0u8; 8192];
let mut offset = 0;
loop {
let mut cursor = std::io::Cursor::new(&resp_buf[offset..]);
match rmpv::decode::read_value(&mut cursor) {
Ok(val) => {
offset += cursor.position() as usize;
if let rmpv::Value::Array(ref arr) = val {
if arr.len() >= 4 && arr[0] == rmpv::Value::Integer(1.into()) && arr[1] == msgid {
return Ok(val);
}
}
continue;
},
Err(_) => {
let read_future = stream.read(&mut chunk);
match tokio::time::timeout(tokio::time::Duration::from_secs(5), read_future).await {
Ok(Ok(n)) => {
if n == 0 { return Err("Connection closed".into()); }
resp_buf.extend_from_slice(&chunk[..n]);
}
Ok(Err(e)) => return Err(e.to_string()),
Err(_) => return Err("Timeout waiting for Neovim response".into()),
}
}
}
}
}
async fn send_nvim_command(cmd: &str) -> Result<(), String> {
use rmpv::Value as RmpValue;
let req = RmpValue::Array(vec![
RmpValue::Integer(0.into()),
RmpValue::Integer(1.into()), // msgid
RmpValue::String("nvim_command".into()),
RmpValue::Array(vec![RmpValue::String(cmd.into())]),
]);
let resp = call_nvim(req).await?;
if let RmpValue::Array(arr) = resp {
if !arr[2].is_nil() {
return Err(format!("Neovim error: {:?}", arr[2]));
}
return Ok(());
}
Err("Invalid response".to_string())
}
async fn get_nvim_active_buffer() -> Result<String, String> {
use rmpv::Value as RmpValue;
let req = RmpValue::Array(vec![
RmpValue::Integer(0.into()),
RmpValue::Integer(2.into()), // msgid
RmpValue::String("nvim_buf_get_lines".into()),
RmpValue::Array(vec![
RmpValue::Integer(0.into()),
RmpValue::Integer(0.into()),
RmpValue::Integer((-1).into()),
RmpValue::Boolean(true),
]),
]);
let resp = call_nvim(req).await?;
if let RmpValue::Array(arr) = resp {
if !arr[2].is_nil() {
return Err(format!("Neovim error: {:?}", arr[2]));
}
if let RmpValue::Array(lines) = &arr[3] {
let mut text = String::new();
for line in lines {
if let RmpValue::String(s) = line {
if let Some(s) = s.as_str() {
text.push_str(s);
text.push('\n');
}
}
}
return Ok(text);
}
}
Err("Invalid response".to_string())
}
async fn get_nvim_cursor() -> Result<String, String> {
use rmpv::Value as RmpValue;
let req = RmpValue::Array(vec![
RmpValue::Integer(0.into()),
RmpValue::Integer(3.into()), // msgid
RmpValue::String("nvim_win_get_cursor".into()),
RmpValue::Array(vec![
RmpValue::Integer(0.into()),
]),
]);
let resp = call_nvim(req).await?;
if let RmpValue::Array(arr) = resp {
if !arr[2].is_nil() {
return Err(format!("Neovim error: {:?}", arr[2]));
}
if let RmpValue::Array(pos) = &arr[3] {
if pos.len() == 2 {
if let (RmpValue::Integer(row), RmpValue::Integer(col)) = (&pos[0], &pos[1]) {
return Ok(format!("Line: {}, Column: {}", row, col));
}
}
}
}
Err("Invalid response".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
"#;
use rmpv::Value as RmpValue;
let req = RmpValue::Array(vec![
RmpValue::Integer(0.into()),
RmpValue::Integer(4.into()), // msgid
RmpValue::String("nvim_exec_lua".into()),
RmpValue::Array(vec![
RmpValue::String(lua_script.into()),
RmpValue::Array(vec![]),
]),
]);
let resp = call_nvim(req).await?;
if let RmpValue::Array(arr) = resp {
if !arr[2].is_nil() {
return Err(format!("Neovim error: {:?}", arr[2]));
}
if let RmpValue::String(s) = &arr[3] {
if let Some(text) = s.as_str() {
return Ok(text.to_string());
}
}
}
Err("Invalid response".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 = {} - 1,
col = 0,
severity = vim.diagnostic.severity.WARN,
message = "{}",
}}}}
vim.diagnostic.set(ns, 0, diagnostics, {{}})
"#, line, escaped_message);
use rmpv::Value as RmpValue;
let req = RmpValue::Array(vec![
RmpValue::Integer(0.into()),
RmpValue::Integer(5.into()), // msgid
RmpValue::String("nvim_exec_lua".into()),
RmpValue::Array(vec![
RmpValue::String(lua_script.into()),
RmpValue::Array(vec![]),
]),
]);
let resp = call_nvim(req).await?;
if let RmpValue::Array(arr) = resp {
if !arr[2].is_nil() {
return Err(format!("Neovim error: {:?}", arr[2]));
}
return Ok(());
}
Err("Invalid response".to_string())
}
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)),
}
}
async fn execute_nvim_lua(code: &str) -> Result<String, String> {
use rmpv::Value as RmpValue;
let req = RmpValue::Array(vec![
RmpValue::Integer(0.into()),
RmpValue::Integer(6.into()), // msgid
RmpValue::String("nvim_exec_lua".into()),
RmpValue::Array(vec![
RmpValue::String(code.into()),
RmpValue::Array(vec![]),
]),
]);
let resp = call_nvim(req).await?;
if let RmpValue::Array(arr) = resp {
if !arr[2].is_nil() {
return Err(format!("Neovim error: {:?}", arr[2]));
}
if arr.len() > 3 {
return Ok(serde_json::to_string_pretty(&rmpv_to_json(&arr[3])).unwrap_or_default());
}
return Ok("".to_string());
}
Err("Invalid response".to_string())
}
+124
View File
@@ -0,0 +1,124 @@
use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Command, Stdio};
fn send_message(stdin: &mut std::process::ChildStdin, msg: Value) {
let s = serde_json::to_string(&msg).unwrap();
let payload = format!("Content-Length: {}\r\n\r\n{}", s.len(), s);
stdin.write_all(payload.as_bytes()).unwrap();
stdin.flush().unwrap();
}
fn read_message(stdout: &mut std::process::ChildStdout) -> Option<Value> {
let mut reader = BufReader::new(stdout);
let mut length = 0;
// Read headers
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
return None; // EOF
}
let line = line.trim_end();
if line.is_empty() {
break;
}
if let Some(len_str) = line.strip_prefix("Content-Length: ") {
length = len_str.parse().unwrap_or(0);
}
}
if length == 0 {
return None;
}
// Read body
let mut buf = vec![0u8; length];
reader.read_exact(&mut buf).unwrap();
let body_str = String::from_utf8_lossy(&buf);
Some(serde_json::from_str(&body_str).unwrap())
}
#[test]
#[cfg(unix)]
fn test_mcp_initialization_and_tools_list() {
let mut nvim_exe = std::env::current_exe().unwrap();
nvim_exe.pop();
nvim_exe.pop();
nvim_exe.push(format!("mcp-memory-linux-nvim{}", std::env::consts::EXE_SUFFIX));
let mut child = Command::new(&nvim_exe)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("Failed to start mcp-memory-linux-nvim");
let mut stdin = child.stdin.take().expect("Failed to open stdin");
let mut stdout = child.stdout.take().expect("Failed to open stdout");
// 0. Test server/discover (probe)
let discover_req = json!({
"jsonrpc": "2.0",
"method": "server/discover",
"params": {},
"id": 0
});
send_message(&mut stdin, discover_req);
let discover_resp = read_message(&mut stdout).expect("Failed to read server/discover response");
assert_eq!(discover_resp["error"]["code"], -32601);
// 1. Test Initialize
let init_req = json!({
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0"
}
},
"id": 1
});
// Send initialize using JSONL format!
let s = serde_json::to_string(&init_req).unwrap();
stdin.write_all(format!("{}\n", s).as_bytes()).unwrap();
stdin.flush().unwrap();
let init_resp = read_message(&mut stdout).expect("Failed to read initialize response");
assert_eq!(init_resp["jsonrpc"], "2.0");
assert_eq!(init_resp["id"], 1);
// Verify capabilities
let capabilities = &init_resp["result"]["capabilities"];
assert_eq!(capabilities["tools"], serde_json::json!({}));
// 2. Test tools/list
let tools_req = json!({
"jsonrpc": "2.0",
"method": "tools/list",
"params": {},
"id": 2
});
send_message(&mut stdin, tools_req);
let tools_resp = read_message(&mut stdout).expect("Failed to read tools/list response");
assert_eq!(tools_resp["jsonrpc"], "2.0");
assert_eq!(tools_resp["id"], 2);
let tools = tools_resp["result"]["tools"].as_array().expect("result.tools must be an array");
assert!(!tools.is_empty(), "Server must expose at least one tool");
let has_get_active_buffer = tools.iter().any(|t| t["name"] == "nvim_get_active_buffer");
assert!(has_get_active_buffer, "Missing nvim_get_active_buffer tool");
child.kill().expect("Failed to kill child");
child.wait().expect("Failed to wait on child");
}