1pub mod bridge;
37pub mod config;
38pub mod error;
39pub mod lsp;
40pub mod mcp;
41pub mod transport;
42
43use std::path::PathBuf;
44use std::sync::Arc;
45
46use bridge::resources::make_uri;
47use bridge::{ResourceSubscriptions, Translator};
48pub use config::ServerConfig;
49pub use error::Error;
50use lsp::{LspNotification, LspServer, ServerInitConfig};
51use rmcp::model::ResourceUpdatedNotificationParam;
52use tokio::sync::{Mutex, OnceCell};
53use tokio::task::JoinSet;
54use tracing::{error, info, warn};
55#[cfg(feature = "transport-http")]
56pub use transport::HttpConfig;
57pub use transport::Transport;
58#[cfg(feature = "transport-http")]
59use transport::run_http;
60use transport::run_stdio;
61
62pub(crate) async fn diagnostics_pump(
80 _lang: String,
81 mut rx: tokio::sync::mpsc::Receiver<LspNotification>,
82 translator: Arc<Mutex<Translator>>,
83 subs: Arc<ResourceSubscriptions>,
84 peer_cell: Arc<OnceCell<rmcp::Peer<rmcp::RoleServer>>>,
85 mut cancel_rx: tokio::sync::watch::Receiver<bool>,
86) {
87 loop {
88 tokio::select! {
89 result = cancel_rx.changed() => {
91 if result.is_err() || *cancel_rx.borrow() {
93 break;
94 }
95 }
96 msg = rx.recv() => {
97 let Some(notif) = msg else { break };
98 match notif {
99 LspNotification::PublishDiagnostics(p) => {
100 {
102 let mut t = translator.lock().await;
103 t.notification_cache_mut()
104 .store_diagnostics(&p.uri, p.version, p.diagnostics);
105 }
106
107 if subs.is_empty().await {
109 continue;
110 }
111
112 let Some(peer) = peer_cell.get() else { continue };
114 let Some(path) = bridge::uri_to_path(&p.uri) else { continue };
115 let Ok(mcp_uri) = make_uri(&path) else { continue };
116
117 if !subs.contains(&mcp_uri).await {
121 continue;
122 }
123
124 if peer
125 .notify_resource_updated(ResourceUpdatedNotificationParam::new(
126 mcp_uri,
127 ))
128 .await
129 .is_err()
130 {
131 break;
133 }
134 }
135 LspNotification::LogMessage(m) => {
136 let mut t = translator.lock().await;
137 t.notification_cache_mut()
138 .store_log(m.typ.into(), m.message);
139 }
140 LspNotification::ShowMessage(m) => {
141 let mut t = translator.lock().await;
142 t.notification_cache_mut()
143 .store_message(m.typ.into(), m.message);
144 }
145 LspNotification::Progress { .. } | LspNotification::Other { .. } => {}
146 }
147 }
148 }
149 }
150}
151
152fn register_servers(
157 mut result: lsp::ServerInitResult,
158 translator: &mut bridge::Translator,
159) -> std::collections::HashMap<String, tokio::sync::mpsc::Receiver<lsp::LspNotification>> {
160 let mut receivers = std::collections::HashMap::new();
161 for (lang, server) in &mut result.servers {
162 receivers.insert(lang.clone(), server.take_notification_rx());
163 }
164 for (language_id, server) in result.servers {
165 let client = server.client().clone();
166 translator.register_client(language_id.clone(), client);
167 translator.register_server(language_id.clone(), server);
168 }
169 receivers
170}
171
172fn resolve_workspace_roots(config_roots: &[PathBuf]) -> Vec<PathBuf> {
183 if config_roots.is_empty() {
184 match std::env::current_dir() {
185 Ok(cwd) => {
186 match cwd.canonicalize() {
188 Ok(canonical) => {
189 info!(
190 "Using current directory as workspace root: {}",
191 canonical.display()
192 );
193 vec![canonical]
194 }
195 Err(e) => {
196 warn!(
199 "Failed to canonicalize current directory: {e}, using non-canonical path"
200 );
201 vec![cwd]
202 }
203 }
204 }
205 Err(e) => {
206 warn!("Failed to get current directory: {e}, using fallback");
209 vec![PathBuf::from(".")]
210 }
211 }
212 } else {
213 config_roots.to_vec()
214 }
215}
216
217pub async fn serve(config: ServerConfig) -> Result<(), Error> {
235 serve_with(config, Transport::Stdio).await
236}
237
238pub async fn serve_with(config: ServerConfig, transport: Transport) -> Result<(), Error> {
275 info!("Starting MCPLS server...");
276
277 let workspace_roots = resolve_workspace_roots(&config.workspace.roots);
278 let extension_map = config.build_effective_extension_map();
279 let max_depth = Some(config.workspace.heuristics_max_depth);
280
281 let mut translator = Translator::new().with_extensions(extension_map);
282 translator.set_workspace_roots(workspace_roots.clone());
283
284 let applicable_configs: Vec<ServerInitConfig> = config
285 .lsp_servers
286 .iter()
287 .filter_map(|lsp_config| {
288 let should_spawn = workspace_roots
289 .iter()
290 .any(|root| lsp_config.should_spawn(root, max_depth));
291
292 if !should_spawn {
293 info!(
294 "Skipping LSP server '{}' ({}): no project markers found",
295 lsp_config.language_id, lsp_config.command
296 );
297 return None;
298 }
299
300 Some(ServerInitConfig {
301 server_config: lsp_config.clone(),
302 workspace_roots: workspace_roots.clone(),
303 initialization_options: lsp_config.initialization_options.clone(),
304 notification_tx: None,
305 })
306 })
307 .collect();
308
309 info!(
310 "Attempting to spawn {} applicable LSP server(s)...",
311 applicable_configs.len()
312 );
313
314 let expected_languages: std::collections::HashSet<String> = applicable_configs
318 .iter()
319 .map(|c| c.server_config.language_id.clone())
320 .collect();
321 translator.set_expected_languages(expected_languages);
322
323 let translator = Arc::new(Mutex::new(translator));
330 let subscriptions = Arc::new(ResourceSubscriptions::new());
331 let peer_cell = Arc::new(OnceCell::new());
333
334 let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
336
337 if applicable_configs.is_empty() {
338 warn!("No applicable LSP servers configured — starting in protocol-only mode");
339 } else {
340 info!(
341 "Spawning {} LSP server(s) in the background...",
342 applicable_configs.len()
343 );
344 spawn_lsp_servers_background(
345 applicable_configs,
346 Arc::clone(&translator),
347 Arc::clone(&subscriptions),
348 Arc::clone(&peer_cell),
349 cancel_rx.clone(),
350 );
351 }
352
353 info!("Starting MCP server with rmcp...");
354 let mcp_server = mcp::McplsServer::new(Arc::clone(&translator), Arc::clone(&subscriptions));
355 info!("MCPLS server initialized successfully");
356
357 let result = match transport {
358 Transport::Stdio => {
359 info!("Listening for MCP requests on stdio...");
360 run_stdio(mcp_server, &peer_cell).await
361 }
362 #[cfg(feature = "transport-http")]
363 Transport::Http(cfg) => run_http(mcp_server, cfg).await,
364 };
365
366 let _ = cancel_tx.send(true);
368
369 info!("MCPLS server shutting down");
370 result
371}
372
373fn spawn_lsp_servers_background(
384 applicable_configs: Vec<ServerInitConfig>,
385 translator: Arc<Mutex<Translator>>,
386 subscriptions: Arc<ResourceSubscriptions>,
387 peer_cell: Arc<OnceCell<rmcp::Peer<rmcp::RoleServer>>>,
388 cancel_rx: tokio::sync::watch::Receiver<bool>,
389) {
390 tokio::spawn(async move {
391 let result = LspServer::spawn_batch(&applicable_configs).await;
392
393 if result.all_failed() {
394 error!(
395 "All {} configured LSP server(s) failed to initialize",
396 result.failure_count()
397 );
398 for failure in &result.failures {
399 error!("Server initialization failed: {}", failure);
400 }
401 translator.lock().await.clear_expected_languages();
403 return;
404 }
405
406 if result.partial_success() {
407 warn!(
408 "Partial server initialization: {} succeeded, {} failed",
409 result.server_count(),
410 result.failure_count()
411 );
412 for failure in &result.failures {
413 error!("Server initialization failed: {}", failure);
414 }
415 }
416
417 let server_count = result.server_count();
418 let notification_receivers = {
419 let mut t = translator.lock().await;
420 let receivers = register_servers(result, &mut t);
421 t.clear_expected_languages();
426 receivers
427 };
428 info!("Proceeding with {} LSP server(s)", server_count);
429
430 let mut pumps: JoinSet<()> = JoinSet::new();
432 for (lang, rx) in notification_receivers {
433 pumps.spawn(diagnostics_pump(
434 lang,
435 rx,
436 Arc::clone(&translator),
437 Arc::clone(&subscriptions),
438 Arc::clone(&peer_cell),
439 cancel_rx.clone(),
440 ));
441 }
442 while pumps.join_next().await.is_some() {}
443 });
444}
445
446#[cfg(test)]
447#[allow(clippy::unwrap_used)]
448mod tests {
449 use super::*;
450
451 #[test]
452 fn test_resolve_workspace_roots_empty_config() {
453 let roots = resolve_workspace_roots(&[]);
454 assert_eq!(roots.len(), 1);
455 assert!(
456 roots[0].is_absolute(),
457 "Workspace root should be absolute path"
458 );
459 }
460
461 #[test]
462 fn test_resolve_workspace_roots_with_config() {
463 let config_roots = vec![PathBuf::from("/test/root")];
464 let roots = resolve_workspace_roots(&config_roots);
465 assert_eq!(roots, config_roots);
466 }
467
468 #[test]
469 fn test_resolve_workspace_roots_multiple_paths() {
470 let config_roots = vec![PathBuf::from("/test/root1"), PathBuf::from("/test/root2")];
471 let roots = resolve_workspace_roots(&config_roots);
472 assert_eq!(roots, config_roots);
473 assert_eq!(roots.len(), 2);
474 }
475
476 #[test]
477 fn test_resolve_workspace_roots_preserves_order() {
478 let config_roots = vec![
479 PathBuf::from("/workspace/alpha"),
480 PathBuf::from("/workspace/beta"),
481 PathBuf::from("/workspace/gamma"),
482 ];
483 let roots = resolve_workspace_roots(&config_roots);
484 assert_eq!(roots[0], PathBuf::from("/workspace/alpha"));
485 assert_eq!(roots[1], PathBuf::from("/workspace/beta"));
486 assert_eq!(roots[2], PathBuf::from("/workspace/gamma"));
487 }
488
489 #[test]
490 fn test_resolve_workspace_roots_single_path() {
491 let config_roots = vec![PathBuf::from("/single/workspace")];
492 let roots = resolve_workspace_roots(&config_roots);
493 assert_eq!(roots.len(), 1);
494 assert_eq!(roots[0], PathBuf::from("/single/workspace"));
495 }
496
497 #[test]
498 fn test_resolve_workspace_roots_empty_returns_cwd() {
499 let roots = resolve_workspace_roots(&[]);
500 assert!(
501 !roots.is_empty(),
502 "Should return at least one workspace root"
503 );
504 }
505
506 #[test]
507 fn test_resolve_workspace_roots_relative_paths() {
508 let config_roots = vec![
509 PathBuf::from("relative/path1"),
510 PathBuf::from("relative/path2"),
511 ];
512 let roots = resolve_workspace_roots(&config_roots);
513 assert_eq!(roots.len(), 2);
514 assert_eq!(roots[0], PathBuf::from("relative/path1"));
515 assert_eq!(roots[1], PathBuf::from("relative/path2"));
516 }
517
518 #[test]
519 fn test_resolve_workspace_roots_mixed_paths() {
520 let config_roots = vec![
521 PathBuf::from("/absolute/path"),
522 PathBuf::from("relative/path"),
523 ];
524 let roots = resolve_workspace_roots(&config_roots);
525 assert_eq!(roots.len(), 2);
526 assert_eq!(roots[0], PathBuf::from("/absolute/path"));
527 assert_eq!(roots[1], PathBuf::from("relative/path"));
528 }
529
530 #[test]
531 fn test_resolve_workspace_roots_with_dot_path() {
532 let config_roots = vec![PathBuf::from(".")];
533 let roots = resolve_workspace_roots(&config_roots);
534 assert_eq!(roots, config_roots);
535 }
536
537 #[test]
538 fn test_resolve_workspace_roots_with_parent_path() {
539 let config_roots = vec![PathBuf::from("..")];
540 let roots = resolve_workspace_roots(&config_roots);
541 assert_eq!(roots.len(), 1);
542 assert_eq!(roots[0], PathBuf::from(".."));
543 }
544
545 #[test]
546 fn test_resolve_workspace_roots_unicode_paths() {
547 let config_roots = vec![
548 PathBuf::from("/workspace/テスト"),
549 PathBuf::from("/workspace/тест"),
550 ];
551 let roots = resolve_workspace_roots(&config_roots);
552 assert_eq!(roots.len(), 2);
553 assert_eq!(roots[0], PathBuf::from("/workspace/テスト"));
554 assert_eq!(roots[1], PathBuf::from("/workspace/тест"));
555 }
556
557 #[test]
558 fn test_resolve_workspace_roots_spaces_in_paths() {
559 let config_roots = vec![
560 PathBuf::from("/workspace/path with spaces"),
561 PathBuf::from("/another path/workspace"),
562 ];
563 let roots = resolve_workspace_roots(&config_roots);
564 assert_eq!(roots.len(), 2);
565 assert_eq!(roots[0], PathBuf::from("/workspace/path with spaces"));
566 }
567
568 mod graceful_degradation_tests {
570 use super::*;
571 use crate::error::ServerSpawnFailure;
572 use crate::lsp::ServerInitResult;
573
574 #[test]
575 fn test_all_servers_failed_error_handling() {
576 let mut result = ServerInitResult::new();
577 result.add_failure(ServerSpawnFailure {
578 language_id: "rust".to_string(),
579 command: "rust-analyzer".to_string(),
580 message: "not found".to_string(),
581 });
582 result.add_failure(ServerSpawnFailure {
583 language_id: "python".to_string(),
584 command: "pyright".to_string(),
585 message: "not found".to_string(),
586 });
587
588 assert!(result.all_failed());
589 assert_eq!(result.failure_count(), 2);
590 assert_eq!(result.server_count(), 0);
591 }
592
593 #[test]
594 fn test_partial_success_detection() {
595 use std::collections::HashMap;
596
597 let mut result = ServerInitResult::new();
598 result.servers = HashMap::new(); result.add_failure(ServerSpawnFailure {
601 language_id: "python".to_string(),
602 command: "pyright".to_string(),
603 message: "not found".to_string(),
604 });
605
606 assert_eq!(result.failure_count(), 1);
608 assert_eq!(result.server_count(), 0);
609 }
610
611 #[test]
612 fn test_all_servers_succeeded_detection() {
613 use std::collections::HashMap;
614
615 let mut result = ServerInitResult::new();
616 result.servers = HashMap::new(); assert_eq!(result.failure_count(), 0);
619 assert!(!result.all_failed());
620 assert!(!result.partial_success());
621 }
622
623 #[test]
624 fn test_all_servers_failed_to_init_error() {
625 let failures = vec![
626 ServerSpawnFailure {
627 language_id: "rust".to_string(),
628 command: "rust-analyzer".to_string(),
629 message: "command not found".to_string(),
630 },
631 ServerSpawnFailure {
632 language_id: "python".to_string(),
633 command: "pyright".to_string(),
634 message: "permission denied".to_string(),
635 },
636 ];
637
638 let err = Error::AllServersFailedToInit { count: 2, failures };
639
640 assert!(err.to_string().contains("all LSP servers failed"));
641 assert!(err.to_string().contains("2 configured"));
642
643 if let Error::AllServersFailedToInit { count, failures: f } = err {
645 assert_eq!(count, 2);
646 assert_eq!(f.len(), 2);
647 assert_eq!(f[0].language_id, "rust");
648 assert_eq!(f[1].language_id, "python");
649 } else {
650 panic!("Expected AllServersFailedToInit error");
651 }
652 }
653
654 #[test]
655 fn test_graceful_degradation_with_empty_config() {
656 let result = ServerInitResult::new();
657
658 assert!(!result.all_failed());
660 assert!(!result.partial_success());
661 assert!(!result.has_servers());
662 assert_eq!(result.server_count(), 0);
663 assert_eq!(result.failure_count(), 0);
664 }
665
666 #[test]
667 fn test_server_spawn_failure_display() {
668 let failure = ServerSpawnFailure {
669 language_id: "typescript".to_string(),
670 command: "tsserver".to_string(),
671 message: "executable not found in PATH".to_string(),
672 };
673
674 let display = failure.to_string();
675 assert!(display.contains("typescript"));
676 assert!(display.contains("tsserver"));
677 assert!(display.contains("executable not found"));
678 }
679
680 #[test]
681 fn test_result_helpers_consistency() {
682 let mut result = ServerInitResult::new();
683
684 assert!(!result.has_servers());
686 assert!(!result.all_failed());
687 assert!(!result.partial_success());
688
689 result.add_failure(ServerSpawnFailure {
691 language_id: "go".to_string(),
692 command: "gopls".to_string(),
693 message: "error".to_string(),
694 });
695
696 assert!(result.all_failed());
697 assert!(!result.has_servers());
698 assert!(!result.partial_success());
699 }
700
701 #[tokio::test]
702 async fn test_serve_degrades_when_all_servers_fail_to_spawn() {
703 use crate::config::{LspServerConfig, WorkspaceConfig};
704
705 let config = ServerConfig {
715 workspace: WorkspaceConfig {
716 roots: vec![PathBuf::from("/tmp/test-workspace")],
717 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
718 language_extensions: vec![],
719 heuristics_max_depth: 10,
720 },
721 lsp_servers: vec![LspServerConfig {
722 language_id: "rust".to_string(),
723 command: "nonexistent-command-that-will-fail-12345".to_string(),
724 args: vec![],
725 env: std::collections::HashMap::new(),
726 file_patterns: vec!["**/*.rs".to_string()],
727 initialization_options: None,
728 timeout_seconds: 10,
729 heuristics: None,
730 }],
731 };
732
733 let outcome =
738 tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
739
740 match outcome {
741 Err(_elapsed) => {}
743 Ok(Ok(())) => {}
745 Ok(Err(err)) => assert!(
747 !matches!(err, Error::NoServersAvailable(_))
748 && !matches!(err, Error::AllServersFailedToInit { .. }),
749 "serve() must not fail fast now that LSP init is backgrounded; got: {err:?}"
750 ),
751 }
752 }
753
754 #[tokio::test]
755 async fn test_serve_starts_with_empty_config() {
756 use crate::config::WorkspaceConfig;
757
758 let config = ServerConfig {
762 workspace: WorkspaceConfig {
763 roots: vec![PathBuf::from("/tmp/test-workspace")],
764 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
765 language_extensions: vec![],
766 heuristics_max_depth: 10,
767 },
768 lsp_servers: vec![],
769 };
770
771 let result = serve(config).await;
772
773 if let Err(ref err) = result {
776 assert!(
777 !matches!(err, Error::NoServersAvailable(_)),
778 "serve() must not return NoServersAvailable for empty lsp_servers config"
779 );
780 }
781 }
782 }
783
784 #[allow(clippy::unwrap_used, clippy::expect_used)]
789 mod pump_tests {
790 use lsp_types::{PublishDiagnosticsParams, Uri};
791 use tokio::sync::{mpsc, watch};
792
793 use super::*;
794
795 fn make_translator() -> Arc<Mutex<Translator>> {
796 Arc::new(Mutex::new(Translator::new()))
797 }
798
799 fn make_subs() -> Arc<ResourceSubscriptions> {
800 Arc::new(ResourceSubscriptions::new())
801 }
802
803 type PeerCell = Arc<OnceCell<rmcp::Peer<rmcp::RoleServer>>>;
804
805 fn make_peer_cell() -> PeerCell {
806 Arc::new(OnceCell::new())
807 }
808
809 #[tokio::test]
811 async fn test_pump_caches_before_peer_set() {
812 let translator = make_translator();
813 let subs = make_subs();
814 let peer_cell = make_peer_cell();
815 let (tx, rx) = mpsc::channel(8);
816 let (_cancel_tx, cancel_rx) = watch::channel(false);
819
820 let t = Arc::clone(&translator);
821 tokio::spawn(diagnostics_pump(
822 "rust".to_string(),
823 rx,
824 t,
825 Arc::clone(&subs),
826 Arc::clone(&peer_cell),
827 cancel_rx,
828 ));
829
830 let uri: Uri = "file:///test/main.rs".parse().unwrap();
831 tx.send(LspNotification::PublishDiagnostics(
832 PublishDiagnosticsParams {
833 uri: uri.clone(),
834 diagnostics: vec![],
835 version: None,
836 },
837 ))
838 .await
839 .unwrap();
840 drop(tx);
841
842 let cached = tokio::time::timeout(std::time::Duration::from_secs(5), async {
844 loop {
845 tokio::task::yield_now().await;
846 let found = {
847 let guard = translator.lock().await;
848 guard
849 .notification_cache()
850 .get_diagnostics(uri.as_str())
851 .is_some()
852 };
853 if found {
854 return true;
855 }
856 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
857 }
858 })
859 .await
860 .expect("pump did not cache diagnostics within 5 s");
861 assert!(cached, "diagnostics should be cached before peer is set");
862 }
863
864 #[tokio::test]
866 async fn test_pump_exits_on_cancel() {
867 let translator = make_translator();
868 let subs = make_subs();
869 let peer_cell = make_peer_cell();
870 let (_tx, rx) = mpsc::channel::<LspNotification>(8);
871 let (cancel_tx, cancel_rx) = watch::channel(false);
872
873 let handle = tokio::spawn(diagnostics_pump(
874 "rust".to_string(),
875 rx,
876 translator,
877 subs,
878 peer_cell,
879 cancel_rx,
880 ));
881
882 cancel_tx.send(true).unwrap();
883 tokio::time::timeout(std::time::Duration::from_millis(200), handle)
885 .await
886 .expect("pump did not exit within timeout")
887 .unwrap();
888 }
889
890 #[tokio::test]
892 async fn test_pump_exits_when_cancel_sender_dropped() {
893 let translator = make_translator();
894 let subs = make_subs();
895 let peer_cell = make_peer_cell();
896 let (_tx, rx) = mpsc::channel::<LspNotification>(8);
897 let (cancel_tx, cancel_rx) = watch::channel(false);
898
899 let handle = tokio::spawn(diagnostics_pump(
900 "rust".to_string(),
901 rx,
902 translator,
903 subs,
904 peer_cell,
905 cancel_rx,
906 ));
907
908 drop(cancel_tx); tokio::time::timeout(std::time::Duration::from_millis(200), handle)
910 .await
911 .expect("pump did not exit within timeout")
912 .unwrap();
913 }
914 }
915}