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 pub fn generate_shorter_connection_id(&self, target: &str) -> String {
56 let full_hash = b3sum(target);
57 let id_length = 7;
58
59 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 if let Some(existing_target) = existing_client.target()
66 && existing_target == target
67 {
68 return candidate.to_string();
70 }
71 continue;
73 }
74
75 return candidate.to_string();
77 }
78
79 full_hash
81 }
82
83 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 pub fn get_connections_instruction(&self) -> String {
99 let mut instructions = String::from("## Connection Status\n\n");
100
101 if let Some(ref connect_mode) = self.connect_mode {
104 instructions.push_str(&format!("Connection mode: `{}`\n\n", connect_mode));
105 }
106
107 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 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 pub fn unregister_dynamic_tools(&self, connection_id: &str) {
149 self.hybrid_router.unregister_dynamic_tools(connection_id)
150 }
151
152 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 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
214pub fn b3sum(input: &str) -> String {
216 blake3::hash(input.as_bytes()).to_hex().to_string()
217}
218
219#[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
235fn 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
244pub 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
259fn get_current_project_root() -> String {
262 if let Some(git_root) = get_git_root() {
264 return git_root;
265 }
266
267 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
277fn escape_path(path: &str) -> String {
280 path.trim().replace("/", "%")
281}
282
283pub fn find_targets_for_current_project() -> Vec<String> {
286 let current_project_root = get_current_project_root();
287 let escaped_project_root = escape_path(¤t_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
307pub 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 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); }
323 debug!("Disconnecting old connection for {target}");
325 let _ = old_client.disconnect().await;
326 }
327
328 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
340pub 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}