refactor(mcp): strip sse fallback architecture in favor of pure websockets and fix nvim NDJSON bug
This commit is contained in:
1 parent
0e29b12ac8
commit
3716c3e698
33 files changed
+2082
-1756
No files matched your search
@@ -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" }
|
||||
@@ -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))]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user