nvim_mcp/server/
core.rs

1use std::process::Command;
2use std::sync::Arc;
3
4use dashmap::DashMap;
5use rmcp::{ErrorData as McpError, RoleServer, service::RequestContext};
6use tracing::{debug, info, warn};
7
8use crate::{
9    neovim::{NeovimClientTrait, NeovimError},
10    server::{
11        hybrid_router::{DynamicToolBox, HybridToolRouter},
12        lua_tools,
13    },
14};
15
16impl From<NeovimError> for McpError {
17    fn from(err: NeovimError) -> Self {
18        match err {
19            NeovimError::Connection(msg) => McpError::invalid_request(msg, None),
20            NeovimError::Lsp { code, message } => {
21                McpError::invalid_request(format!("LSP Error: {code}, {message}"), None)
22            }
23            NeovimError::Api(msg) => McpError::internal_error(msg, None),
24        }
25    }
26}
27
28pub struct NeovimMcpServer {
29    pub nvim_clients: Arc<DashMap<String, Box<dyn NeovimClientTrait + Send>>>,
30    pub hybrid_router: HybridToolRouter,
31    pub connect_mode: Option<String>,
32}
33
34impl NeovimMcpServer {
35    pub fn new() -> Self {
36        Self::with_connect_mode(None)
37    }
38
39    pub fn with_connect_mode(connect_mode: Option<String>) -> Self {
40        debug!("Creating new NeovimMcpServer instance");
41        let static_router = crate::server::tools::build_tool_router();
42        let static_tool_descriptions = Self::tool_descriptions();
43        Self {
44            nvim_clients: Arc::new(DashMap::new()),
45            hybrid_router: HybridToolRouter::new(static_router, static_tool_descriptions),
46            connect_mode,
47        }
48    }
49
50    pub fn router(&self) -> &HybridToolRouter {
51        &self.hybrid_router
52    }
53
54    /// Generate shorter connection ID with collision detection
55    pub fn generate_shorter_connection_id(&self, target: &str) -> String {
56        let full_hash = b3sum(target);
57        let id_length = 7;
58
59        // Try different starting positions in the hash for 7-char IDs
60        for start in 0..=(full_hash.len().saturating_sub(id_length)) {
61            let candidate = &full_hash[start..start + id_length];
62
63            if let Some(existing_client) = self.nvim_clients.get(candidate) {
64                // Check if the existing connection has the same target
65                if let Some(existing_target) = existing_client.target()
66                    && existing_target == target
67                {
68                    // Same target, return existing connection ID (connection replacement)
69                    return candidate.to_string();
70                }
71                // Different target, continue looking for another ID
72                continue;
73            }
74
75            // No existing connection with this ID, safe to use
76            return candidate.to_string();
77        }
78
79        // Fallback to full hash if somehow all combinations are taken
80        full_hash
81    }
82
83    /// Get connection by ID with proper error handling
84    pub fn get_connection(
85        &'_ self,
86        connection_id: &str,
87    ) -> Result<dashmap::mapref::one::Ref<'_, String, Box<dyn NeovimClientTrait + Send>>, McpError>
88    {
89        self.nvim_clients.get(connection_id).ok_or_else(|| {
90            McpError::invalid_request(
91                format!("No Neovim connection found for ID: {connection_id}"),
92                None,
93            )
94        })
95    }
96
97    /// Get dynamic connections info for LLM
98    pub fn get_connections_instruction(&self) -> String {
99        let mut instructions = String::from("## Connection Status\n\n");
100
101        // Add connection status section
102
103        if let Some(ref connect_mode) = self.connect_mode {
104            instructions.push_str(&format!("Connection mode: `{}`\n\n", connect_mode));
105        }
106
107        // Show active connections with their IDs
108        let connections: Vec<_> = self
109            .nvim_clients
110            .iter()
111            .map(|entry| {
112                let connection_id = entry.key();
113                let target = entry
114                    .value()
115                    .target()
116                    .unwrap_or_else(|| "Unknown".to_string());
117                format!(
118                    "- **Connection ID: `{}`** → Target: `{}`",
119                    connection_id, target
120                )
121            })
122            .collect();
123
124        if connections.is_empty() {
125            instructions.push_str("**Active Connections:** None\n\n");
126        } else {
127            instructions.push_str("**Active Connections:**\n\n");
128            for connection in connections {
129                instructions.push_str(&format!("{}\n", connection));
130            }
131            instructions.push_str("\n**Ready to use!** You can immediately use any connection-aware tools with the connection IDs above.");
132        }
133
134        instructions
135    }
136
137    /// Register a connection-specific tool with clean name
138    pub fn register_dynamic_tool(
139        &self,
140        connection_id: &str,
141        tool: DynamicToolBox,
142    ) -> Result<(), McpError> {
143        self.hybrid_router
144            .register_dynamic_tool(connection_id, tool)
145    }
146
147    /// Remove all dynamic tools for a connection
148    pub fn unregister_dynamic_tools(&self, connection_id: &str) {
149        self.hybrid_router.unregister_dynamic_tools(connection_id)
150    }
151
152    /// Get count of dynamic tools for a connection
153    pub fn get_dynamic_tool_count(&self, connection_id: &str) -> usize {
154        self.hybrid_router.get_connection_tool_count(connection_id)
155    }
156
157    pub async fn discover_and_register_lua_tools(&self) -> Result<(), McpError> {
158        for item in self.nvim_clients.iter() {
159            let connection_id = item.key().as_str();
160            let client = item.value().as_ref();
161            lua_tools::discover_and_register_lua_tools(self, connection_id, client).await?;
162        }
163        Ok(())
164    }
165
166    pub(crate) async fn setup_new_client(
167        &self,
168        connection_id: &String,
169        client: Box<dyn NeovimClientTrait + Send + Sync>,
170        ctx: &RequestContext<RoleServer>,
171    ) -> Result<(), McpError> {
172        client.setup_autocmd().await?;
173
174        let mut should_notify = self.nvim_clients.is_empty();
175
176        // Discover and register Lua tools for this connection
177        if let Err(e) =
178            lua_tools::discover_and_register_lua_tools(self, connection_id, client.as_ref()).await
179        {
180            tracing::warn!(
181                "Failed to discover Lua tools for connection '{}': {}",
182                connection_id,
183                e
184            );
185        } else {
186            should_notify = true;
187        }
188
189        self.nvim_clients.insert(connection_id.clone(), client);
190
191        if should_notify {
192            ctx.peer
193                .notify_tool_list_changed()
194                .await
195                .unwrap_or_else(|e| {
196                    tracing::warn!(
197                        "Failed to notify tool list changed for connection '{}': {}",
198                        connection_id,
199                        e
200                    );
201                });
202        }
203
204        Ok(())
205    }
206}
207
208impl Default for NeovimMcpServer {
209    fn default() -> Self {
210        Self::new()
211    }
212}
213
214/// Generate BLAKE3 hash from input string
215pub fn b3sum(input: &str) -> String {
216    blake3::hash(input.as_bytes()).to_hex().to_string()
217}
218
219/// Get git root directory
220#[allow(dead_code)]
221fn get_git_root() -> Option<String> {
222    let output = Command::new("git")
223        .args(["rev-parse", "--show-toplevel"])
224        .output()
225        .ok()?;
226
227    if output.status.success() {
228        let result = String::from_utf8(output.stdout).ok()?;
229        Some(result.trim().to_string())
230    } else {
231        None
232    }
233}
234
235/// Get platform-specific temp directory
236fn get_temp_dir() -> String {
237    if cfg!(target_os = "windows") {
238        std::env::var("TEMP").unwrap_or_else(|_| "C:\\temp".to_string())
239    } else {
240        "/tmp".to_string()
241    }
242}
243
244/// Find all existing nvim-mcp socket targets in the filesystem
245/// Returns a vector of socket paths that match the pattern generated by the Lua plugin
246pub fn find_get_all_targets() -> Vec<String> {
247    let temp_dir = get_temp_dir();
248    let pattern = format!("{temp_dir}/nvim-mcp.*.sock");
249
250    match glob::glob(&pattern) {
251        Ok(paths) => paths
252            .filter_map(|entry| entry.ok())
253            .map(|path| path.to_string_lossy().to_string())
254            .collect(),
255        Err(_) => Vec::new(),
256    }
257}
258
259/// Get current project root directory
260/// Tries git root first, falls back to current working directory
261fn get_current_project_root() -> String {
262    // Try git root first
263    if let Some(git_root) = get_git_root() {
264        return git_root;
265    }
266
267    // Fallback to current working directory
268    std::env::current_dir()
269        .unwrap_or_else(|err| {
270            warn!("Failed to get current working directory: {}", err);
271            std::path::PathBuf::from("<unknown project root>")
272        })
273        .to_string_lossy()
274        .to_string()
275}
276
277/// Escape path for use in filename by replacing problematic characters
278/// Matches the Lua plugin behavior: replaces '/' with '%'
279fn escape_path(path: &str) -> String {
280    path.trim().replace("/", "%")
281}
282
283/// Find nvim-mcp socket targets for the current project only
284/// Returns sockets that match the current project's escaped path
285pub fn find_targets_for_current_project() -> Vec<String> {
286    let current_project_root = get_current_project_root();
287    let escaped_project_root = escape_path(&current_project_root);
288
289    let temp_dir = get_temp_dir();
290    let pattern = format!("{temp_dir}/nvim-mcp.{escaped_project_root}.*.sock");
291
292    match glob::glob(&pattern) {
293        Ok(paths) => paths
294            .filter_map(|entry| entry.ok())
295            .map(|path| path.to_string_lossy().to_string())
296            .collect(),
297        Err(e) => {
298            warn!(
299                "Glob error while searching for Neovim sockets with pattern '{}': {}",
300                pattern, e
301            );
302            Vec::new()
303        }
304    }
305}
306
307/// Connect to a single target and return the connection ID
308/// Reusable for both auto-connect and specific target modes
309pub async fn auto_connect_single_target(
310    server: &NeovimMcpServer,
311    target: &str,
312) -> Result<String, NeovimError> {
313    let connection_id = server.generate_shorter_connection_id(target);
314
315    // Check if already connected (connection replacement logic)
316    if let Some(mut old_client) = server.nvim_clients.get_mut(&connection_id) {
317        if let Some(existing_target) = old_client.target()
318            && existing_target == target
319        {
320            debug!("Already connected to {target} with ID {connection_id}");
321            return Ok(connection_id); // Already connected to same target
322        }
323        // Different target, disconnect old one
324        debug!("Disconnecting old connection for {target}");
325        let _ = old_client.disconnect().await;
326    }
327
328    // Import NeovimClient here to avoid circular imports
329    let mut client = crate::neovim::NeovimClient::default();
330    client.connect_path(target).await?;
331    client.setup_autocmd().await?;
332
333    server
334        .nvim_clients
335        .insert(connection_id.clone(), Box::new(client));
336    debug!("Successfully connected to {target} with ID {connection_id}");
337    Ok(connection_id)
338}
339
340/// Auto-connect to all Neovim targets for the current project
341/// Returns list of successful connection IDs, or list of failures
342pub async fn auto_connect_current_project_targets(
343    server: &NeovimMcpServer,
344) -> Result<Vec<String>, Vec<(String, String)>> {
345    let project_targets = find_targets_for_current_project();
346    let current_project = get_current_project_root();
347
348    if project_targets.is_empty() {
349        info!("No Neovim instances found for current project: {current_project}");
350        return Ok(Vec::new());
351    }
352
353    info!(
354        "Found {} Neovim instances for current project: {current_project}",
355        project_targets.len()
356    );
357
358    let mut successful_connections = Vec::new();
359    let mut failed_connections = Vec::new();
360
361    for target in project_targets {
362        match auto_connect_single_target(server, &target).await {
363            Ok(connection_id) => {
364                successful_connections.push(connection_id);
365                info!("Auto-connected to project Neovim instance: {target}");
366            }
367            Err(e) => {
368                failed_connections.push((target.clone(), e.to_string()));
369                warn!("Failed to auto-connect to {target}: {e}");
370            }
371        }
372    }
373
374    if successful_connections.is_empty() && !failed_connections.is_empty() {
375        Err(failed_connections)
376    } else {
377        Ok(successful_connections)
378    }
379}