Skip to main content

mcpls_core/
lib.rs

1//! # mcpls-core
2//!
3//! Core library for MCP (Model Context Protocol) to LSP (Language Server Protocol) translation.
4//!
5//! This crate provides the fundamental building blocks for bridging AI agents with
6//! language servers, enabling semantic code intelligence through MCP tools.
7//!
8//! ## Architecture
9//!
10//! The library is organized into several modules:
11//!
12//! - [`lsp`] - LSP client implementation for communicating with language servers
13//! - [`mcp`] - MCP tool definitions and handlers
14//! - [`bridge`] - Translation layer between MCP and LSP protocols
15//! - [`config`] - Configuration types and loading
16//! - [`mod@error`] - Error types for the library
17//!
18//! ## Example
19//!
20//! ```rust,ignore
21//! use mcpls_core::{serve, ServerConfig};
22//!
23//! #[tokio::main]
24//! async fn main() -> Result<(), mcpls_core::Error> {
25//!     let config = ServerConfig::load()?;
26//!     serve(config).await
27//! }
28//! ```
29
30pub mod bridge;
31pub mod config;
32pub mod error;
33pub mod lsp;
34pub mod mcp;
35
36use std::path::PathBuf;
37use std::sync::Arc;
38
39use bridge::Translator;
40pub use config::ServerConfig;
41pub use error::Error;
42use lsp::{LspServer, ServerInitConfig};
43use rmcp::ServiceExt;
44use tokio::sync::Mutex;
45use tracing::{error, info, warn};
46
47/// Resolve workspace roots from config or current directory.
48///
49/// If no workspace roots are provided in the configuration, this function
50/// will use the current working directory, canonicalized for security.
51///
52/// # Returns
53///
54/// A vector of workspace root paths. If config roots are provided, they are
55/// returned as-is. Otherwise, returns the canonicalized current directory,
56/// falling back to relative "." if canonicalization fails.
57fn resolve_workspace_roots(config_roots: &[PathBuf]) -> Vec<PathBuf> {
58    if config_roots.is_empty() {
59        match std::env::current_dir() {
60            Ok(cwd) => {
61                // current_dir() always returns an absolute path
62                match cwd.canonicalize() {
63                    Ok(canonical) => {
64                        info!(
65                            "Using current directory as workspace root: {}",
66                            canonical.display()
67                        );
68                        vec![canonical]
69                    }
70                    Err(e) => {
71                        // Canonicalization can fail if directory was deleted or permissions changed
72                        // but cwd itself is still absolute
73                        warn!(
74                            "Failed to canonicalize current directory: {e}, using non-canonical path"
75                        );
76                        vec![cwd]
77                    }
78                }
79            }
80            Err(e) => {
81                // This is extremely rare - only happens if cwd was deleted or unlinked
82                // In this case, we have no choice but to use a relative path
83                warn!("Failed to get current directory: {e}, using fallback");
84                vec![PathBuf::from(".")]
85            }
86        }
87    } else {
88        config_roots.to_vec()
89    }
90}
91
92/// Start the MCPLS server with the given configuration.
93///
94/// This is the primary entry point for running the MCP-LSP bridge.
95/// Implements graceful degradation: if some but not all LSP servers fail
96/// to initialize, the service continues with available servers.
97///
98/// # Errors
99///
100/// Returns an error if:
101/// - All LSP servers fail to initialize
102/// - MCP server setup fails
103/// - Configuration is invalid
104///
105/// # Graceful Degradation
106///
107/// - **All servers succeed**: Service runs normally
108/// - **Partial success**: Logs warnings for failures, continues with available servers
109/// - **All servers fail**: Returns `Error::AllServersFailedToInit` with details
110pub async fn serve(config: ServerConfig) -> Result<(), Error> {
111    info!("Starting MCPLS server...");
112
113    let workspace_roots = resolve_workspace_roots(&config.workspace.roots);
114    let extension_map = config.workspace.build_extension_map();
115    let max_depth = Some(config.workspace.heuristics_max_depth);
116
117    let mut translator = Translator::new().with_extensions(extension_map);
118    translator.set_workspace_roots(workspace_roots.clone());
119
120    // Build configurations for batch spawning with heuristics filtering
121    let applicable_configs: Vec<ServerInitConfig> = config
122        .lsp_servers
123        .iter()
124        .filter_map(|lsp_config| {
125            let should_spawn = workspace_roots
126                .iter()
127                .any(|root| lsp_config.should_spawn(root, max_depth));
128
129            if !should_spawn {
130                info!(
131                    "Skipping LSP server '{}' ({}): no project markers found",
132                    lsp_config.language_id, lsp_config.command
133                );
134                return None;
135            }
136
137            Some(ServerInitConfig {
138                server_config: lsp_config.clone(),
139                workspace_roots: workspace_roots.clone(),
140                initialization_options: lsp_config.initialization_options.clone(),
141            })
142        })
143        .collect();
144
145    info!(
146        "Attempting to spawn {} applicable LSP server(s)...",
147        applicable_configs.len()
148    );
149
150    // Spawn all servers with graceful degradation
151    let result = LspServer::spawn_batch(&applicable_configs).await;
152
153    // Handle the three possible outcomes
154    if result.all_failed() {
155        return Err(Error::AllServersFailedToInit {
156            count: result.failure_count(),
157            failures: result.failures,
158        });
159    }
160
161    if result.partial_success() {
162        warn!(
163            "Partial server initialization: {} succeeded, {} failed",
164            result.server_count(),
165            result.failure_count()
166        );
167        for failure in &result.failures {
168            error!("Server initialization failed: {}", failure);
169        }
170    }
171
172    // Check if at least one server successfully initialized
173    if !result.has_servers() {
174        return Err(Error::NoServersAvailable(
175            "none configured or all failed to initialize".to_string(),
176        ));
177    }
178
179    // Register all successfully initialized servers
180    let server_count = result.server_count();
181    for (language_id, server) in result.servers {
182        let client = server.client().clone();
183        translator.register_client(language_id.clone(), client);
184        translator.register_server(language_id.clone(), server);
185    }
186
187    info!("Proceeding with {} LSP server(s)", server_count);
188
189    let translator = Arc::new(Mutex::new(translator));
190
191    info!("Starting MCP server with rmcp...");
192    let mcp_server = mcp::McplsServer::new(translator);
193
194    info!("MCPLS server initialized successfully");
195    info!("Listening for MCP requests on stdio...");
196
197    let service = mcp_server
198        .serve(rmcp::transport::stdio())
199        .await
200        .map_err(|e| Error::McpServer(format!("Failed to start MCP server: {e}")))?;
201
202    service
203        .waiting()
204        .await
205        .map_err(|e| Error::McpServer(format!("MCP server error: {e}")))?;
206
207    info!("MCPLS server shutting down");
208    Ok(())
209}
210
211#[cfg(test)]
212#[allow(clippy::unwrap_used)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn test_resolve_workspace_roots_empty_config() {
218        let roots = resolve_workspace_roots(&[]);
219        assert_eq!(roots.len(), 1);
220        assert!(
221            roots[0].is_absolute(),
222            "Workspace root should be absolute path"
223        );
224    }
225
226    #[test]
227    fn test_resolve_workspace_roots_with_config() {
228        let config_roots = vec![PathBuf::from("/test/root")];
229        let roots = resolve_workspace_roots(&config_roots);
230        assert_eq!(roots, config_roots);
231    }
232
233    #[test]
234    fn test_resolve_workspace_roots_multiple_paths() {
235        let config_roots = vec![PathBuf::from("/test/root1"), PathBuf::from("/test/root2")];
236        let roots = resolve_workspace_roots(&config_roots);
237        assert_eq!(roots, config_roots);
238        assert_eq!(roots.len(), 2);
239    }
240
241    #[test]
242    fn test_resolve_workspace_roots_preserves_order() {
243        let config_roots = vec![
244            PathBuf::from("/workspace/alpha"),
245            PathBuf::from("/workspace/beta"),
246            PathBuf::from("/workspace/gamma"),
247        ];
248        let roots = resolve_workspace_roots(&config_roots);
249        assert_eq!(roots[0], PathBuf::from("/workspace/alpha"));
250        assert_eq!(roots[1], PathBuf::from("/workspace/beta"));
251        assert_eq!(roots[2], PathBuf::from("/workspace/gamma"));
252    }
253
254    #[test]
255    fn test_resolve_workspace_roots_single_path() {
256        let config_roots = vec![PathBuf::from("/single/workspace")];
257        let roots = resolve_workspace_roots(&config_roots);
258        assert_eq!(roots.len(), 1);
259        assert_eq!(roots[0], PathBuf::from("/single/workspace"));
260    }
261
262    #[test]
263    fn test_resolve_workspace_roots_empty_returns_cwd() {
264        let roots = resolve_workspace_roots(&[]);
265        assert!(
266            !roots.is_empty(),
267            "Should return at least one workspace root"
268        );
269    }
270
271    #[test]
272    fn test_resolve_workspace_roots_relative_paths() {
273        let config_roots = vec![
274            PathBuf::from("relative/path1"),
275            PathBuf::from("relative/path2"),
276        ];
277        let roots = resolve_workspace_roots(&config_roots);
278        assert_eq!(roots.len(), 2);
279        assert_eq!(roots[0], PathBuf::from("relative/path1"));
280        assert_eq!(roots[1], PathBuf::from("relative/path2"));
281    }
282
283    #[test]
284    fn test_resolve_workspace_roots_mixed_paths() {
285        let config_roots = vec![
286            PathBuf::from("/absolute/path"),
287            PathBuf::from("relative/path"),
288        ];
289        let roots = resolve_workspace_roots(&config_roots);
290        assert_eq!(roots.len(), 2);
291        assert_eq!(roots[0], PathBuf::from("/absolute/path"));
292        assert_eq!(roots[1], PathBuf::from("relative/path"));
293    }
294
295    #[test]
296    fn test_resolve_workspace_roots_with_dot_path() {
297        let config_roots = vec![PathBuf::from(".")];
298        let roots = resolve_workspace_roots(&config_roots);
299        assert_eq!(roots, config_roots);
300    }
301
302    #[test]
303    fn test_resolve_workspace_roots_with_parent_path() {
304        let config_roots = vec![PathBuf::from("..")];
305        let roots = resolve_workspace_roots(&config_roots);
306        assert_eq!(roots.len(), 1);
307        assert_eq!(roots[0], PathBuf::from(".."));
308    }
309
310    #[test]
311    fn test_resolve_workspace_roots_unicode_paths() {
312        let config_roots = vec![
313            PathBuf::from("/workspace/テスト"),
314            PathBuf::from("/workspace/тест"),
315        ];
316        let roots = resolve_workspace_roots(&config_roots);
317        assert_eq!(roots.len(), 2);
318        assert_eq!(roots[0], PathBuf::from("/workspace/テスト"));
319        assert_eq!(roots[1], PathBuf::from("/workspace/тест"));
320    }
321
322    #[test]
323    fn test_resolve_workspace_roots_spaces_in_paths() {
324        let config_roots = vec![
325            PathBuf::from("/workspace/path with spaces"),
326            PathBuf::from("/another path/workspace"),
327        ];
328        let roots = resolve_workspace_roots(&config_roots);
329        assert_eq!(roots.len(), 2);
330        assert_eq!(roots[0], PathBuf::from("/workspace/path with spaces"));
331    }
332
333    // Tests for graceful degradation behavior
334    mod graceful_degradation_tests {
335        use super::*;
336        use crate::error::ServerSpawnFailure;
337        use crate::lsp::ServerInitResult;
338
339        #[test]
340        fn test_all_servers_failed_error_handling() {
341            let mut result = ServerInitResult::new();
342            result.add_failure(ServerSpawnFailure {
343                language_id: "rust".to_string(),
344                command: "rust-analyzer".to_string(),
345                message: "not found".to_string(),
346            });
347            result.add_failure(ServerSpawnFailure {
348                language_id: "python".to_string(),
349                command: "pyright".to_string(),
350                message: "not found".to_string(),
351            });
352
353            assert!(result.all_failed());
354            assert_eq!(result.failure_count(), 2);
355            assert_eq!(result.server_count(), 0);
356        }
357
358        #[test]
359        fn test_partial_success_detection() {
360            use std::collections::HashMap;
361
362            let mut result = ServerInitResult::new();
363            // Simulate one success and one failure
364            result.servers = HashMap::new(); // Would have a real server in production
365            result.add_failure(ServerSpawnFailure {
366                language_id: "python".to_string(),
367                command: "pyright".to_string(),
368                message: "not found".to_string(),
369            });
370
371            // Without actual servers, we can verify the failure was recorded
372            assert_eq!(result.failure_count(), 1);
373            assert_eq!(result.server_count(), 0);
374        }
375
376        #[test]
377        fn test_all_servers_succeeded_detection() {
378            use std::collections::HashMap;
379
380            let mut result = ServerInitResult::new();
381            result.servers = HashMap::new(); // Would have real servers in production
382
383            assert_eq!(result.failure_count(), 0);
384            assert!(!result.all_failed());
385            assert!(!result.partial_success());
386        }
387
388        #[test]
389        fn test_all_servers_failed_to_init_error() {
390            let failures = vec![
391                ServerSpawnFailure {
392                    language_id: "rust".to_string(),
393                    command: "rust-analyzer".to_string(),
394                    message: "command not found".to_string(),
395                },
396                ServerSpawnFailure {
397                    language_id: "python".to_string(),
398                    command: "pyright".to_string(),
399                    message: "permission denied".to_string(),
400                },
401            ];
402
403            let err = Error::AllServersFailedToInit { count: 2, failures };
404
405            assert!(err.to_string().contains("all LSP servers failed"));
406            assert!(err.to_string().contains("2 configured"));
407
408            // Verify failures are preserved
409            if let Error::AllServersFailedToInit { count, failures: f } = err {
410                assert_eq!(count, 2);
411                assert_eq!(f.len(), 2);
412                assert_eq!(f[0].language_id, "rust");
413                assert_eq!(f[1].language_id, "python");
414            } else {
415                panic!("Expected AllServersFailedToInit error");
416            }
417        }
418
419        #[test]
420        fn test_graceful_degradation_with_empty_config() {
421            let result = ServerInitResult::new();
422
423            // Empty config means no servers configured
424            assert!(!result.all_failed());
425            assert!(!result.partial_success());
426            assert!(!result.has_servers());
427            assert_eq!(result.server_count(), 0);
428            assert_eq!(result.failure_count(), 0);
429        }
430
431        #[test]
432        fn test_server_spawn_failure_display() {
433            let failure = ServerSpawnFailure {
434                language_id: "typescript".to_string(),
435                command: "tsserver".to_string(),
436                message: "executable not found in PATH".to_string(),
437            };
438
439            let display = failure.to_string();
440            assert!(display.contains("typescript"));
441            assert!(display.contains("tsserver"));
442            assert!(display.contains("executable not found"));
443        }
444
445        #[test]
446        fn test_result_helpers_consistency() {
447            let mut result = ServerInitResult::new();
448
449            // Initially empty
450            assert!(!result.has_servers());
451            assert!(!result.all_failed());
452            assert!(!result.partial_success());
453
454            // Add a failure
455            result.add_failure(ServerSpawnFailure {
456                language_id: "go".to_string(),
457                command: "gopls".to_string(),
458                message: "error".to_string(),
459            });
460
461            assert!(result.all_failed());
462            assert!(!result.has_servers());
463            assert!(!result.partial_success());
464        }
465
466        #[tokio::test]
467        async fn test_serve_fails_with_no_servers_available() {
468            use crate::config::{LspServerConfig, WorkspaceConfig};
469
470            // Create a config with an invalid server (guaranteed to fail)
471            let config = ServerConfig {
472                workspace: WorkspaceConfig {
473                    roots: vec![PathBuf::from("/tmp/test-workspace")],
474                    position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
475                    language_extensions: vec![],
476                    heuristics_max_depth: 10,
477                },
478                lsp_servers: vec![LspServerConfig {
479                    language_id: "rust".to_string(),
480                    command: "nonexistent-command-that-will-fail-12345".to_string(),
481                    args: vec![],
482                    env: std::collections::HashMap::new(),
483                    file_patterns: vec!["**/*.rs".to_string()],
484                    initialization_options: None,
485                    timeout_seconds: 10,
486                    heuristics: None,
487                }],
488            };
489
490            let result = serve(config).await;
491
492            assert!(result.is_err());
493            let err = result.unwrap_err();
494
495            // The serve function should now return NoServersAvailable error
496            // because all servers failed, but has_servers() returned false
497            assert!(
498                matches!(err, Error::NoServersAvailable(_))
499                    || matches!(err, Error::AllServersFailedToInit { .. }),
500                "Expected NoServersAvailable or AllServersFailedToInit error, got: {err:?}"
501            );
502        }
503
504        #[tokio::test]
505        async fn test_serve_fails_with_empty_config() {
506            use crate::config::WorkspaceConfig;
507
508            // Create a config with no servers
509            let config = ServerConfig {
510                workspace: WorkspaceConfig {
511                    roots: vec![PathBuf::from("/tmp/test-workspace")],
512                    position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
513                    language_extensions: vec![],
514                    heuristics_max_depth: 10,
515                },
516                lsp_servers: vec![],
517            };
518
519            let result = serve(config).await;
520
521            assert!(result.is_err());
522            let err = result.unwrap_err();
523
524            // Should return NoServersAvailable because no servers were configured
525            assert!(
526                matches!(err, Error::NoServersAvailable(_)),
527                "Expected NoServersAvailable error, got: {err:?}"
528            );
529
530            if let Error::NoServersAvailable(msg) = err {
531                assert!(msg.contains("none configured"));
532            }
533        }
534    }
535}