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