use std::process::Command;
use std::sync::Arc;
use dashmap::DashMap;
use rmcp::{ErrorData as McpError, RoleServer, service::RequestContext};
use tracing::{debug, info, warn};
use crate::{
neovim::{NeovimClientTrait, NeovimError},
server::{
hybrid_router::{DynamicToolBox, HybridToolRouter},
lua_tools,
},
};
impl From<NeovimError> for McpError {
fn from(err: NeovimError) -> Self {
match err {
NeovimError::Connection(msg) => McpError::invalid_request(msg, None),
NeovimError::Lsp { code, message } => {
McpError::invalid_request(format!("LSP Error: {code}, {message}"), None)
}
NeovimError::Api(msg) => McpError::internal_error(msg, None),
}
}
}
pub struct NeovimMcpServer {
pub nvim_clients: Arc<DashMap<String, Box<dyn NeovimClientTrait + Send>>>,
pub hybrid_router: HybridToolRouter,
pub connect_mode: Option<String>,
}
impl NeovimMcpServer {
pub fn new() -> Self {
Self::with_connect_mode(None)
}
pub fn with_connect_mode(connect_mode: Option<String>) -> Self {
debug!("Creating new NeovimMcpServer instance");
let static_router = crate::server::tools::build_tool_router();
let static_tool_descriptions = Self::tool_descriptions();
Self {
nvim_clients: Arc::new(DashMap::new()),
hybrid_router: HybridToolRouter::new(static_router, static_tool_descriptions),
connect_mode,
}
}
pub fn router(&self) -> &HybridToolRouter {
&self.hybrid_router
}
pub fn generate_shorter_connection_id(&self, target: &str) -> String {
let full_hash = b3sum(target);
let id_length = 7;
for start in 0..=(full_hash.len().saturating_sub(id_length)) {
let candidate = &full_hash[start..start + id_length];
if let Some(existing_client) = self.nvim_clients.get(candidate) {
if let Some(existing_target) = existing_client.target()
&& existing_target == target
{
return candidate.to_string();
}
continue;
}
return candidate.to_string();
}
full_hash
}
pub fn get_connection(
&'_ self,
connection_id: &str,
) -> Result<dashmap::mapref::one::Ref<'_, String, Box<dyn NeovimClientTrait + Send>>, McpError>
{
self.nvim_clients.get(connection_id).ok_or_else(|| {
McpError::invalid_request(
format!("No Neovim connection found for ID: {connection_id}"),
None,
)
})
}
pub fn get_connections_instruction(&self) -> String {
let mut instructions = String::from("## Connection Status\n\n");
if let Some(ref connect_mode) = self.connect_mode {
instructions.push_str(&format!("Connection mode: `{}`\n\n", connect_mode));
}
let connections: Vec<_> = self
.nvim_clients
.iter()
.map(|entry| {
let connection_id = entry.key();
let target = entry
.value()
.target()
.unwrap_or_else(|| "Unknown".to_string());
format!(
"- **Connection ID: `{}`** → Target: `{}`",
connection_id, target
)
})
.collect();
if connections.is_empty() {
instructions.push_str("**Active Connections:** None\n\n");
} else {
instructions.push_str("**Active Connections:**\n\n");
for connection in connections {
instructions.push_str(&format!("{}\n", connection));
}
instructions.push_str("\n**Ready to use!** You can immediately use any connection-aware tools with the connection IDs above.");
}
instructions
}
pub fn register_dynamic_tool(
&self,
connection_id: &str,
tool: DynamicToolBox,
) -> Result<(), McpError> {
self.hybrid_router
.register_dynamic_tool(connection_id, tool)
}
pub fn unregister_dynamic_tools(&self, connection_id: &str) {
self.hybrid_router.unregister_dynamic_tools(connection_id)
}
pub fn get_dynamic_tool_count(&self, connection_id: &str) -> usize {
self.hybrid_router.get_connection_tool_count(connection_id)
}
pub async fn discover_and_register_lua_tools(&self) -> Result<(), McpError> {
for item in self.nvim_clients.iter() {
let connection_id = item.key().as_str();
let client = item.value().as_ref();
lua_tools::discover_and_register_lua_tools(self, connection_id, client).await?;
}
Ok(())
}
pub(crate) async fn setup_new_client(
&self,
connection_id: &String,
client: Box<dyn NeovimClientTrait + Send + Sync>,
ctx: &RequestContext<RoleServer>,
) -> Result<(), McpError> {
client.setup_autocmd().await?;
let mut should_notify = self.nvim_clients.is_empty();
if let Err(e) =
lua_tools::discover_and_register_lua_tools(self, connection_id, client.as_ref()).await
{
tracing::warn!(
"Failed to discover Lua tools for connection '{}': {}",
connection_id,
e
);
} else {
should_notify = true;
}
self.nvim_clients.insert(connection_id.clone(), client);
if should_notify {
ctx.peer
.notify_tool_list_changed()
.await
.unwrap_or_else(|e| {
tracing::warn!(
"Failed to notify tool list changed for connection '{}': {}",
connection_id,
e
);
});
}
Ok(())
}
}
impl Default for NeovimMcpServer {
fn default() -> Self {
Self::new()
}
}
pub fn b3sum(input: &str) -> String {
blake3::hash(input.as_bytes()).to_hex().to_string()
}
#[allow(dead_code)]
fn get_git_root() -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()?;
if output.status.success() {
let result = String::from_utf8(output.stdout).ok()?;
Some(result.trim().to_string())
} else {
None
}
}
fn get_temp_dir() -> String {
if cfg!(target_os = "windows") {
std::env::var("TEMP").unwrap_or_else(|_| "C:\\temp".to_string())
} else {
"/tmp".to_string()
}
}
pub fn find_get_all_targets() -> Vec<String> {
let temp_dir = get_temp_dir();
let pattern = format!("{temp_dir}/nvim-mcp.*.sock");
match glob::glob(&pattern) {
Ok(paths) => paths
.filter_map(|entry| entry.ok())
.map(|path| path.to_string_lossy().to_string())
.collect(),
Err(_) => Vec::new(),
}
}
fn get_current_project_root() -> String {
if let Some(git_root) = get_git_root() {
return git_root;
}
std::env::current_dir()
.unwrap_or_else(|err| {
warn!("Failed to get current working directory: {}", err);
std::path::PathBuf::from("<unknown project root>")
})
.to_string_lossy()
.to_string()
}
fn escape_path(path: &str) -> String {
path.trim().replace("/", "%")
}
pub fn find_targets_for_current_project() -> Vec<String> {
let current_project_root = get_current_project_root();
let escaped_project_root = escape_path(¤t_project_root);
let temp_dir = get_temp_dir();
let pattern = format!("{temp_dir}/nvim-mcp.{escaped_project_root}.*.sock");
match glob::glob(&pattern) {
Ok(paths) => paths
.filter_map(|entry| entry.ok())
.map(|path| path.to_string_lossy().to_string())
.collect(),
Err(e) => {
warn!(
"Glob error while searching for Neovim sockets with pattern '{}': {}",
pattern, e
);
Vec::new()
}
}
}
pub async fn auto_connect_single_target(
server: &NeovimMcpServer,
target: &str,
) -> Result<String, NeovimError> {
let connection_id = server.generate_shorter_connection_id(target);
if let Some(mut old_client) = server.nvim_clients.get_mut(&connection_id) {
if let Some(existing_target) = old_client.target()
&& existing_target == target
{
debug!("Already connected to {target} with ID {connection_id}");
return Ok(connection_id); }
debug!("Disconnecting old connection for {target}");
let _ = old_client.disconnect().await;
}
let mut client = crate::neovim::NeovimClient::default();
client.connect_path(target).await?;
client.setup_autocmd().await?;
server
.nvim_clients
.insert(connection_id.clone(), Box::new(client));
debug!("Successfully connected to {target} with ID {connection_id}");
Ok(connection_id)
}
pub async fn auto_connect_current_project_targets(
server: &NeovimMcpServer,
) -> Result<Vec<String>, Vec<(String, String)>> {
let project_targets = find_targets_for_current_project();
let current_project = get_current_project_root();
if project_targets.is_empty() {
info!("No Neovim instances found for current project: {current_project}");
return Ok(Vec::new());
}
info!(
"Found {} Neovim instances for current project: {current_project}",
project_targets.len()
);
let mut successful_connections = Vec::new();
let mut failed_connections = Vec::new();
for target in project_targets {
match auto_connect_single_target(server, &target).await {
Ok(connection_id) => {
successful_connections.push(connection_id);
info!("Auto-connected to project Neovim instance: {target}");
}
Err(e) => {
failed_connections.push((target.clone(), e.to_string()));
warn!("Failed to auto-connect to {target}: {e}");
}
}
}
if successful_connections.is_empty() && !failed_connections.is_empty() {
Err(failed_connections)
} else {
Ok(successful_connections)
}
}