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
116    let mut translator = Translator::new().with_extensions(extension_map);
117    translator.set_workspace_roots(workspace_roots.clone());
118
119    // Build configurations for batch spawning with heuristics filtering
120    let applicable_configs: Vec<ServerInitConfig> = config
121        .lsp_servers
122        .iter()
123        .filter_map(|lsp_config| {
124            let should_spawn = workspace_roots
125                .iter()
126                .any(|root| lsp_config.should_spawn(root));
127
128            if !should_spawn {
129                info!(
130                    "Skipping LSP server '{}' ({}): no project markers found",
131                    lsp_config.language_id, lsp_config.command
132                );
133                return None;
134            }
135
136            Some(ServerInitConfig {
137                server_config: lsp_config.clone(),
138                workspace_roots: workspace_roots.clone(),
139                initialization_options: lsp_config.initialization_options.clone(),
140            })
141        })
142        .collect();
143
144    info!(
145        "Attempting to spawn {} applicable LSP server(s)...",
146        applicable_configs.len()
147    );
148
149    // Spawn all servers with graceful degradation
150    let result = LspServer::spawn_batch(&applicable_configs).await;
151
152    // Handle the three possible outcomes
153    if result.all_failed() {
154        return Err(Error::AllServersFailedToInit {
155            count: result.failure_count(),
156            failures: result.failures,
157        });
158    }
159
160    if result.partial_success() {
161        warn!(
162            "Partial server initialization: {} succeeded, {} failed",
163            result.server_count(),
164            result.failure_count()
165        );
166        for failure in &result.failures {
167            error!("Server initialization failed: {}", failure);
168        }
169    }
170
171    // Check if at least one server successfully initialized
172    if !result.has_servers() {
173        return Err(Error::NoServersAvailable(
174            "none configured or all failed to initialize".to_string(),
175        ));
176    }
177
178    // Register all successfully initialized servers
179    let server_count = result.server_count();
180    for (language_id, server) in result.servers {
181        let client = server.client().clone();
182        translator.register_client(language_id.clone(), client);
183        translator.register_server(language_id.clone(), server);
184    }
185
186    info!("Proceeding with {} LSP server(s)", server_count);
187
188    let translator = Arc::new(Mutex::new(translator));
189
190    info!("Starting MCP server with rmcp...");
191    let mcp_server = mcp::McplsServer::new(translator);
192
193    info!("MCPLS server initialized successfully");
194    info!("Listening for MCP requests on stdio...");
195
196    let service = mcp_server
197        .serve(rmcp::transport::stdio())
198        .await
199        .map_err(|e| Error::McpServer(format!("Failed to start MCP server: {e}")))?;
200
201    service
202        .waiting()
203        .await
204        .map_err(|e| Error::McpServer(format!("MCP server error: {e}")))?;
205
206    info!("MCPLS server shutting down");
207    Ok(())
208}
209
210#[cfg(test)]
211#[allow(clippy::unwrap_used)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_resolve_workspace_roots_empty_config() {
217        let roots = resolve_workspace_roots(&[]);
218        assert_eq!(roots.len(), 1);
219        assert!(
220            roots[0].is_absolute(),
221            "Workspace root should be absolute path"
222        );
223    }
224
225    #[test]
226    fn test_resolve_workspace_roots_with_config() {
227        let config_roots = vec![PathBuf::from("/test/root")];
228        let roots = resolve_workspace_roots(&config_roots);
229        assert_eq!(roots, config_roots);
230    }
231
232    #[test]
233    fn test_resolve_workspace_roots_multiple_paths() {
234        let config_roots = vec![PathBuf::from("/test/root1"), PathBuf::from("/test/root2")];
235        let roots = resolve_workspace_roots(&config_roots);
236        assert_eq!(roots, config_roots);
237        assert_eq!(roots.len(), 2);
238    }
239
240    #[test]
241    fn test_resolve_workspace_roots_preserves_order() {
242        let config_roots = vec![
243            PathBuf::from("/workspace/alpha"),
244            PathBuf::from("/workspace/beta"),
245            PathBuf::from("/workspace/gamma"),
246        ];
247        let roots = resolve_workspace_roots(&config_roots);
248        assert_eq!(roots[0], PathBuf::from("/workspace/alpha"));
249        assert_eq!(roots[1], PathBuf::from("/workspace/beta"));
250        assert_eq!(roots[2], PathBuf::from("/workspace/gamma"));
251    }
252
253    #[test]
254    fn test_resolve_workspace_roots_single_path() {
255        let config_roots = vec![PathBuf::from("/single/workspace")];
256        let roots = resolve_workspace_roots(&config_roots);
257        assert_eq!(roots.len(), 1);
258        assert_eq!(roots[0], PathBuf::from("/single/workspace"));
259    }
260
261    #[test]
262    fn test_resolve_workspace_roots_empty_returns_cwd() {
263        let roots = resolve_workspace_roots(&[]);
264        assert!(
265            !roots.is_empty(),
266            "Should return at least one workspace root"
267        );
268    }
269
270    #[test]
271    fn test_resolve_workspace_roots_relative_paths() {
272        let config_roots = vec![
273            PathBuf::from("relative/path1"),
274            PathBuf::from("relative/path2"),
275        ];
276        let roots = resolve_workspace_roots(&config_roots);
277        assert_eq!(roots.len(), 2);
278        assert_eq!(roots[0], PathBuf::from("relative/path1"));
279        assert_eq!(roots[1], PathBuf::from("relative/path2"));
280    }
281
282    #[test]
283    fn test_resolve_workspace_roots_mixed_paths() {
284        let config_roots = vec![
285            PathBuf::from("/absolute/path"),
286            PathBuf::from("relative/path"),
287        ];
288        let roots = resolve_workspace_roots(&config_roots);
289        assert_eq!(roots.len(), 2);
290        assert_eq!(roots[0], PathBuf::from("/absolute/path"));
291        assert_eq!(roots[1], PathBuf::from("relative/path"));
292    }
293
294    #[test]
295    fn test_resolve_workspace_roots_with_dot_path() {
296        let config_roots = vec![PathBuf::from(".")];
297        let roots = resolve_workspace_roots(&config_roots);
298        assert_eq!(roots, config_roots);
299    }
300
301    #[test]
302    fn test_resolve_workspace_roots_with_parent_path() {
303        let config_roots = vec![PathBuf::from("..")];
304        let roots = resolve_workspace_roots(&config_roots);
305        assert_eq!(roots.len(), 1);
306        assert_eq!(roots[0], PathBuf::from(".."));
307    }
308
309    #[test]
310    fn test_resolve_workspace_roots_unicode_paths() {
311        let config_roots = vec![
312            PathBuf::from("/workspace/テスト"),
313            PathBuf::from("/workspace/тест"),
314        ];
315        let roots = resolve_workspace_roots(&config_roots);
316        assert_eq!(roots.len(), 2);
317        assert_eq!(roots[0], PathBuf::from("/workspace/テスト"));
318        assert_eq!(roots[1], PathBuf::from("/workspace/тест"));
319    }
320
321    #[test]
322    fn test_resolve_workspace_roots_spaces_in_paths() {
323        let config_roots = vec![
324            PathBuf::from("/workspace/path with spaces"),
325            PathBuf::from("/another path/workspace"),
326        ];
327        let roots = resolve_workspace_roots(&config_roots);
328        assert_eq!(roots.len(), 2);
329        assert_eq!(roots[0], PathBuf::from("/workspace/path with spaces"));
330    }
331
332    // Tests for graceful degradation behavior
333    mod graceful_degradation_tests {
334        use super::*;
335        use crate::error::ServerSpawnFailure;
336        use crate::lsp::ServerInitResult;
337
338        #[test]
339        fn test_all_servers_failed_error_handling() {
340            let mut result = ServerInitResult::new();
341            result.add_failure(ServerSpawnFailure {
342                language_id: "rust".to_string(),
343                command: "rust-analyzer".to_string(),
344                message: "not found".to_string(),
345            });
346            result.add_failure(ServerSpawnFailure {
347                language_id: "python".to_string(),
348                command: "pyright".to_string(),
349                message: "not found".to_string(),
350            });
351
352            assert!(result.all_failed());
353            assert_eq!(result.failure_count(), 2);
354            assert_eq!(result.server_count(), 0);
355        }
356
357        #[test]
358        fn test_partial_success_detection() {
359            use std::collections::HashMap;
360
361            let mut result = ServerInitResult::new();
362            // Simulate one success and one failure
363            result.servers = HashMap::new(); // Would have a real server in production
364            result.add_failure(ServerSpawnFailure {
365                language_id: "python".to_string(),
366                command: "pyright".to_string(),
367                message: "not found".to_string(),
368            });
369
370            // Without actual servers, we can verify the failure was recorded
371            assert_eq!(result.failure_count(), 1);
372            assert_eq!(result.server_count(), 0);
373        }
374
375        #[test]
376        fn test_all_servers_succeeded_detection() {
377            use std::collections::HashMap;
378
379            let mut result = ServerInitResult::new();
380            result.servers = HashMap::new(); // Would have real servers in production
381
382            assert_eq!(result.failure_count(), 0);
383            assert!(!result.all_failed());
384            assert!(!result.partial_success());
385        }
386
387        #[test]
388        fn test_all_servers_failed_to_init_error() {
389            let failures = vec![
390                ServerSpawnFailure {
391                    language_id: "rust".to_string(),
392                    command: "rust-analyzer".to_string(),
393                    message: "command not found".to_string(),
394                },
395                ServerSpawnFailure {
396                    language_id: "python".to_string(),
397                    command: "pyright".to_string(),
398                    message: "permission denied".to_string(),
399                },
400            ];
401
402            let err = Error::AllServersFailedToInit { count: 2, failures };
403
404            assert!(err.to_string().contains("all LSP servers failed"));
405            assert!(err.to_string().contains("2 configured"));
406
407            // Verify failures are preserved
408            if let Error::AllServersFailedToInit { count, failures: f } = err {
409                assert_eq!(count, 2);
410                assert_eq!(f.len(), 2);
411                assert_eq!(f[0].language_id, "rust");
412                assert_eq!(f[1].language_id, "python");
413            } else {
414                panic!("Expected AllServersFailedToInit error");
415            }
416        }
417
418        #[test]
419        fn test_graceful_degradation_with_empty_config() {
420            let result = ServerInitResult::new();
421
422            // Empty config means no servers configured
423            assert!(!result.all_failed());
424            assert!(!result.partial_success());
425            assert!(!result.has_servers());
426            assert_eq!(result.server_count(), 0);
427            assert_eq!(result.failure_count(), 0);
428        }
429
430        #[test]
431        fn test_server_spawn_failure_display() {
432            let failure = ServerSpawnFailure {
433                language_id: "typescript".to_string(),
434                command: "tsserver".to_string(),
435                message: "executable not found in PATH".to_string(),
436            };
437
438            let display = failure.to_string();
439            assert!(display.contains("typescript"));
440            assert!(display.contains("tsserver"));
441            assert!(display.contains("executable not found"));
442        }
443
444        #[test]
445        fn test_result_helpers_consistency() {
446            let mut result = ServerInitResult::new();
447
448            // Initially empty
449            assert!(!result.has_servers());
450            assert!(!result.all_failed());
451            assert!(!result.partial_success());
452
453            // Add a failure
454            result.add_failure(ServerSpawnFailure {
455                language_id: "go".to_string(),
456                command: "gopls".to_string(),
457                message: "error".to_string(),
458            });
459
460            assert!(result.all_failed());
461            assert!(!result.has_servers());
462            assert!(!result.partial_success());
463        }
464
465        #[tokio::test]
466        async fn test_serve_fails_with_no_servers_available() {
467            use crate::config::{LspServerConfig, WorkspaceConfig};
468
469            // Create a config with an invalid server (guaranteed to fail)
470            let config = ServerConfig {
471                workspace: WorkspaceConfig {
472                    roots: vec![PathBuf::from("/tmp/test-workspace")],
473                    position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
474                    language_extensions: vec![],
475                },
476                lsp_servers: vec![LspServerConfig {
477                    language_id: "rust".to_string(),
478                    command: "nonexistent-command-that-will-fail-12345".to_string(),
479                    args: vec![],
480                    env: std::collections::HashMap::new(),
481                    file_patterns: vec!["**/*.rs".to_string()],
482                    initialization_options: None,
483                    timeout_seconds: 10,
484                    heuristics: None,
485                }],
486            };
487
488            let result = serve(config).await;
489
490            assert!(result.is_err());
491            let err = result.unwrap_err();
492
493            // The serve function should now return NoServersAvailable error
494            // because all servers failed, but has_servers() returned false
495            assert!(
496                matches!(err, Error::NoServersAvailable(_))
497                    || matches!(err, Error::AllServersFailedToInit { .. }),
498                "Expected NoServersAvailable or AllServersFailedToInit error, got: {err:?}"
499            );
500        }
501
502        #[tokio::test]
503        async fn test_serve_fails_with_empty_config() {
504            use crate::config::WorkspaceConfig;
505
506            // Create a config with no servers
507            let config = ServerConfig {
508                workspace: WorkspaceConfig {
509                    roots: vec![PathBuf::from("/tmp/test-workspace")],
510                    position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
511                    language_extensions: vec![],
512                },
513                lsp_servers: vec![],
514            };
515
516            let result = serve(config).await;
517
518            assert!(result.is_err());
519            let err = result.unwrap_err();
520
521            // Should return NoServersAvailable because no servers were configured
522            assert!(
523                matches!(err, Error::NoServersAvailable(_)),
524                "Expected NoServersAvailable error, got: {err:?}"
525            );
526
527            if let Error::NoServersAvailable(msg) = err {
528                assert!(msg.contains("none configured"));
529            }
530        }
531    }
532}