diff --git a/CHANGELOG.md b/CHANGELOG.md index 254f33a..eaa3f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `get_server_status` — New MCP tool showing registered LSP servers and their status (ready/initializing/etc.), with document counts per language + +### Changed + +- **Shorter tool descriptions** — Condensed MCP tool descriptions for better compatibility with AI agent context windows + ## [0.3.0] - 2025-12-28 Major feature release adding LSP notification handling and 3 new MCP tools for real-time diagnostics and server monitoring. diff --git a/README.md b/README.md index c5dbd33..d13c381 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ Claude: [get_references] Found 4 matches: | Tool | What it does | |------|--------------| +| `get_server_status` | Show registered LSP servers and their status | | `get_server_logs` | Debug LSP issues with internal log messages | | `get_server_messages` | User-facing messages from the language server | diff --git a/crates/mcpls-core/src/bridge/state.rs b/crates/mcpls-core/src/bridge/state.rs index e518853..18abe2c 100644 --- a/crates/mcpls-core/src/bridge/state.rs +++ b/crates/mcpls-core/src/bridge/state.rs @@ -96,6 +96,12 @@ impl DocumentTracker { self.documents.is_empty() } + /// Get all tracked documents. + #[must_use] + pub const fn documents(&self) -> &HashMap { + &self.documents + } + /// Open a document and track its state. /// /// Returns the document URI for use in LSP requests. @@ -641,4 +647,40 @@ mod tests { let result = tracker.open(PathBuf::from("/test/over.rs"), over_size_content); assert!(matches!(result, Err(Error::FileSizeLimitExceeded { .. }))); } + + #[test] + fn test_documents_accessor_returns_empty_map_for_new_tracker() { + let tracker = DocumentTracker::new(); + let docs = tracker.documents(); + assert!(docs.is_empty()); + } + + #[test] + fn test_documents_accessor_returns_all_open_documents() { + let mut tracker = DocumentTracker::new(); + let path1 = PathBuf::from("/test/file1.rs"); + let path2 = PathBuf::from("/test/file2.rs"); + + tracker.open(path1.clone(), "content1".to_string()).unwrap(); + tracker.open(path2.clone(), "content2".to_string()).unwrap(); + + let docs = tracker.documents(); + assert_eq!(docs.len(), 2); + assert!(docs.contains_key(&path1)); + assert!(docs.contains_key(&path2)); + } + + #[test] + fn test_documents_accessor_reflects_document_state() { + let mut tracker = DocumentTracker::new(); + let path = PathBuf::from("/test/file.rs"); + + tracker.open(path.clone(), "initial".to_string()).unwrap(); + tracker.update(&path, "updated".to_string()); + + let docs = tracker.documents(); + let state = docs.get(&path).unwrap(); + assert_eq!(state.content, "updated"); + assert_eq!(state.version, 2); + } } diff --git a/crates/mcpls-core/src/bridge/translator.rs b/crates/mcpls-core/src/bridge/translator.rs index 987fa29..98eed06 100644 --- a/crates/mcpls-core/src/bridge/translator.rs +++ b/crates/mcpls-core/src/bridge/translator.rs @@ -402,6 +402,30 @@ pub struct ServerMessagesResult { pub messages: Vec, } +/// Status information for a single LSP server. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspServerStatus { + /// Language identifier for the server. + pub language_id: String, + /// Current status of the server. + pub status: String, + /// Command used to start the server. + pub command: String, + /// Number of documents tracked by this server. + pub document_count: usize, +} + +/// Result of server status request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerStatusResult { + /// List of server status entries. + pub servers: Vec, + /// Total number of servers. + pub total_servers: usize, + /// Total number of tracked documents across all servers. + pub document_count: usize, +} + /// Maximum allowed position value for validation. const MAX_POSITION_VALUE: u32 = 1_000_000; /// Maximum allowed range size in lines. @@ -1429,6 +1453,45 @@ impl Translator { let messages: Vec<_> = all_messages.iter().take(limit).cloned().collect(); Ok(ServerMessagesResult { messages }) } + + /// Handle server status request. + /// + /// Returns the status of all registered LSP servers, including their language ID, + /// current state, command, and number of tracked documents. + /// + /// # Errors + /// + /// This method does not return errors. + pub async fn handle_server_status(&self) -> Result { + let mut servers = Vec::new(); + + for (language_id, client) in &self.lsp_clients { + let state = client.state().await; + let config = client.config(); + + let document_count = self + .document_tracker + .documents() + .keys() + .filter(|path| detect_language(path) == *language_id) + .count(); + + servers.push(LspServerStatus { + language_id: language_id.clone(), + status: state.to_string(), + command: config.command.clone(), + document_count, + }); + } + + let total_servers = servers.len(); + let document_count = self.document_tracker.documents().len(); + Ok(ServerStatusResult { + servers, + total_servers, + document_count, + }) + } } /// Extract hover contents as markdown string. @@ -2674,4 +2737,331 @@ mod tests { let result = translator.handle_cached_diagnostics(test_file.to_str().unwrap()); assert!(matches!(result, Err(Error::PathOutsideWorkspace(_)))); } + + #[test] + fn test_lsp_server_status_creation() { + let status = LspServerStatus { + language_id: "rust".to_string(), + status: "ready".to_string(), + command: "rust-analyzer".to_string(), + document_count: 5, + }; + + assert_eq!(status.language_id, "rust"); + assert_eq!(status.status, "ready"); + assert_eq!(status.command, "rust-analyzer"); + assert_eq!(status.document_count, 5); + } + + #[test] + fn test_lsp_server_status_json_serialization() { + let status = LspServerStatus { + language_id: "rust".to_string(), + status: "initializing".to_string(), + command: "rust-analyzer".to_string(), + document_count: 0, + }; + + let json = serde_json::to_string(&status).unwrap(); + assert!(json.contains("\"language_id\":\"rust\"")); + assert!(json.contains("\"status\":\"initializing\"")); + assert!(json.contains("\"command\":\"rust-analyzer\"")); + assert!(json.contains("\"document_count\":0")); + } + + #[test] + fn test_lsp_server_status_json_deserialization() { + let json = r#"{"language_id":"python","status":"ready","command":"pylsp","document_count":3}"#; + let status: LspServerStatus = serde_json::from_str(json).unwrap(); + + assert_eq!(status.language_id, "python"); + assert_eq!(status.status, "ready"); + assert_eq!(status.command, "pylsp"); + assert_eq!(status.document_count, 3); + } + + #[test] + fn test_server_status_result_creation() { + let server1 = LspServerStatus { + language_id: "rust".to_string(), + status: "ready".to_string(), + command: "rust-analyzer".to_string(), + document_count: 5, + }; + let server2 = LspServerStatus { + language_id: "python".to_string(), + status: "initializing".to_string(), + command: "pylsp".to_string(), + document_count: 0, + }; + + let result = ServerStatusResult { + servers: vec![server1, server2], + total_servers: 2, + document_count: 0, + }; + + assert_eq!(result.servers.len(), 2); + assert_eq!(result.total_servers, 2); + assert_eq!(result.servers[0].language_id, "rust"); + assert_eq!(result.servers[1].language_id, "python"); + } + + #[test] + fn test_server_status_result_empty() { + let result = ServerStatusResult { + servers: vec![], + total_servers: 0, + document_count: 0, + }; + + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("\"servers\":[]")); + assert!(json.contains("\"total_servers\":0")); + } + + #[test] + fn test_server_status_result_json_serialization() { + let server = LspServerStatus { + language_id: "rust".to_string(), + status: "ready".to_string(), + command: "rust-analyzer".to_string(), + document_count: 3, + }; + let result = ServerStatusResult { + servers: vec![server], + total_servers: 1, + document_count: 3, + }; + + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("\"servers\":[")); + assert!(json.contains("\"total_servers\":1")); + assert!(json.contains("\"language_id\":\"rust\"")); + } + + #[test] + fn test_server_status_result_json_deserialization() { + let json = r#"{"servers":[{"language_id":"go","status":"uninitialized","command":"gopls","document_count":0}],"total_servers":1,"document_count":0}"#; + let result: ServerStatusResult = serde_json::from_str(json).unwrap(); + + assert_eq!(result.total_servers, 1); + assert_eq!(result.document_count, 0); + assert_eq!(result.servers.len(), 1); + assert_eq!(result.servers[0].language_id, "go"); + assert_eq!(result.servers[0].status, "uninitialized"); + } + + #[test] + fn test_lsp_server_status_all_valid_statuses() { + let valid_statuses = ["ready", "initializing", "uninitialized", "shutting_down", "shutdown"]; + + for status_value in valid_statuses { + let status = LspServerStatus { + language_id: "test".to_string(), + status: status_value.to_string(), + command: "test-cmd".to_string(), + document_count: 0, + }; + + let json = serde_json::to_string(&status).unwrap(); + assert!(json.contains(&format!("\"status\":\"{}\"", status_value))); + + let deserialized: LspServerStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.status, status_value); + } + } + + #[tokio::test] + async fn test_handle_server_status_empty_workspace() { + let mut translator = Translator::new(); + + let result = translator.handle_server_status().await; + assert!(result.is_ok()); + + let status = result.unwrap(); + assert!(status.servers.is_empty()); + assert_eq!(status.total_servers, 0); + } + + #[tokio::test] + async fn test_handle_server_status_returns_server_status_result() { + let translator = Translator::new(); + + let result = translator.handle_server_status().await; + assert!(result.is_ok()); + + let status_result = result.unwrap(); + assert_eq!(status_result.servers.len(), status_result.total_servers); + } + + #[tokio::test] + async fn test_handle_server_status_with_registered_client() { + use crate::config::LspServerConfig; + + let mut translator = Translator::new(); + + let config = LspServerConfig::rust_analyzer(); + let client = LspClient::new(config); + translator.register_client("rust".to_string(), client); + + let result = translator.handle_server_status().await; + assert!(result.is_ok()); + + let status = result.unwrap(); + assert_eq!(status.total_servers, 1); + assert_eq!(status.servers.len(), 1); + + let server_status = &status.servers[0]; + assert_eq!(server_status.language_id, "rust"); + assert_eq!(server_status.command, "rust-analyzer"); + assert_eq!(server_status.status, "uninitialized"); + assert_eq!(server_status.document_count, 0); + } + + #[tokio::test] + async fn test_handle_server_status_multiple_servers() { + use crate::config::LspServerConfig; + + let mut translator = Translator::new(); + + let rust_config = LspServerConfig::rust_analyzer(); + let rust_client = LspClient::new(rust_config); + translator.register_client("rust".to_string(), rust_client); + + let python_config = LspServerConfig::pyright(); + let python_client = LspClient::new(python_config); + translator.register_client("python".to_string(), python_client); + + let result = translator.handle_server_status().await; + assert!(result.is_ok()); + + let status = result.unwrap(); + assert_eq!(status.total_servers, 2); + assert_eq!(status.servers.len(), 2); + + let language_ids: Vec<&str> = status.servers.iter().map(|s| s.language_id.as_str()).collect(); + assert!(language_ids.contains(&"rust")); + assert!(language_ids.contains(&"python")); + } + + #[tokio::test] + async fn test_handle_server_status_document_count() { + use crate::config::LspServerConfig; + + let mut translator = Translator::new(); + let temp_dir = TempDir::new().unwrap(); + + let test_file1 = temp_dir.path().join("test1.rs"); + let test_file2 = temp_dir.path().join("test2.rs"); + fs::write(&test_file1, "fn main() {}").unwrap(); + fs::write(&test_file2, "fn helper() {}").unwrap(); + + translator + .document_tracker_mut() + .open(test_file1, "fn main() {}".to_string()) + .unwrap(); + translator + .document_tracker_mut() + .open(test_file2, "fn helper() {}".to_string()) + .unwrap(); + + let config = LspServerConfig::rust_analyzer(); + let client = LspClient::new(config); + translator.register_client("rust".to_string(), client); + + let result = translator.handle_server_status().await; + assert!(result.is_ok()); + + let status = result.unwrap(); + assert_eq!(status.servers.len(), 1); + + let rust_server = &status.servers[0]; + assert_eq!(rust_server.language_id, "rust"); + assert_eq!(rust_server.document_count, 2); + } + + #[tokio::test] + async fn test_handle_server_status_document_count_per_language() { + use crate::config::LspServerConfig; + + let mut translator = Translator::new(); + let temp_dir = TempDir::new().unwrap(); + + let rust_file = temp_dir.path().join("test.rs"); + let python_file = temp_dir.path().join("test.py"); + fs::write(&rust_file, "fn main() {}").unwrap(); + fs::write(&python_file, "def main(): pass").unwrap(); + + translator + .document_tracker_mut() + .open(rust_file, "fn main() {}".to_string()) + .unwrap(); + translator + .document_tracker_mut() + .open(python_file, "def main(): pass".to_string()) + .unwrap(); + + let rust_config = LspServerConfig::rust_analyzer(); + let rust_client = LspClient::new(rust_config); + translator.register_client("rust".to_string(), rust_client); + + let python_config = LspServerConfig::pyright(); + let python_client = LspClient::new(python_config); + translator.register_client("python".to_string(), python_client); + + let result = translator.handle_server_status().await; + assert!(result.is_ok()); + + let status = result.unwrap(); + assert_eq!(status.total_servers, 2); + + for server in &status.servers { + if server.language_id == "rust" { + assert_eq!(server.document_count, 1); + } else if server.language_id == "python" { + assert_eq!(server.document_count, 1); + } + } + } + + #[tokio::test] + async fn test_handle_server_status_status_lowercase() { + use crate::config::LspServerConfig; + + let mut translator = Translator::new(); + + let config = LspServerConfig::rust_analyzer(); + let client = LspClient::new(config); + translator.register_client("rust".to_string(), client); + + let result = translator.handle_server_status().await; + assert!(result.is_ok()); + + let status = result.unwrap(); + let server_status = &status.servers[0]; + + let valid_statuses = ["ready", "initializing", "uninitialized", "shutting_down", "shutdown"]; + assert!( + valid_statuses.contains(&server_status.status.as_str()), + "Status '{}' should be lowercase", + server_status.status + ); + } + + #[tokio::test] + async fn test_handle_server_status_json_serializable() { + let mut translator = Translator::new(); + + let result = translator.handle_server_status().await; + assert!(result.is_ok()); + + let status = result.unwrap(); + let json_result = serde_json::to_string(&status); + assert!(json_result.is_ok()); + + let json = json_result.unwrap(); + assert!(json.contains("\"servers\"")); + assert!(json.contains("\"total_servers\"")); + } } diff --git a/crates/mcpls-core/src/lsp/client.rs b/crates/mcpls-core/src/lsp/client.rs index feba0e2..51493d4 100644 --- a/crates/mcpls-core/src/lsp/client.rs +++ b/crates/mcpls-core/src/lsp/client.rs @@ -170,6 +170,12 @@ impl LspClient { *self.state.lock().await } + /// Get the configuration for this client. + #[must_use] + pub const fn config(&self) -> &LspServerConfig { + &self.config + } + /// Send request and wait for response with timeout. /// /// # Type Parameters @@ -631,4 +637,64 @@ mod tests { fn test_jsonrpc_version_constant() { assert_eq!(JSONRPC_VERSION, "2.0"); } + + #[tokio::test] + async fn test_state_returns_current_state() { + let config = LspServerConfig::rust_analyzer(); + let client = LspClient::new(config); + + let state = client.state().await; + assert_eq!(state, super::super::ServerState::Uninitialized); + } + + #[test] + fn test_config_returns_config_reference() { + let config = LspServerConfig::rust_analyzer(); + let client = LspClient::new(config); + + let config_ref = client.config(); + assert_eq!(config_ref.language_id, "rust"); + assert_eq!(config_ref.command, "rust-analyzer"); + } + + #[test] + fn test_config_returns_custom_config() { + use std::collections::HashMap; + + let mut env = HashMap::new(); + env.insert("TEST_VAR".to_string(), "test_value".to_string()); + + let config = LspServerConfig { + language_id: "custom".to_string(), + command: "custom-server".to_string(), + args: vec!["--arg1".to_string()], + env, + file_patterns: vec!["**/*.custom".to_string()], + initialization_options: Some(serde_json::json!({"option": true})), + timeout_seconds: 45, + }; + let client = LspClient::new(config); + + let config_ref = client.config(); + assert_eq!(config_ref.language_id, "custom"); + assert_eq!(config_ref.command, "custom-server"); + assert_eq!(config_ref.args, vec!["--arg1"]); + assert_eq!(config_ref.env.get("TEST_VAR"), Some(&"test_value".to_string())); + assert_eq!(config_ref.timeout_seconds, 45); + } + + #[test] + fn test_config_same_after_clone() { + let config = LspServerConfig::pyright(); + let client = LspClient::new(config); + let cloned = client.clone(); + + let orig_config = client.config(); + let cloned_config = cloned.config(); + + assert_eq!(orig_config.language_id, cloned_config.language_id); + assert_eq!(orig_config.command, cloned_config.command); + assert_eq!(orig_config.args, cloned_config.args); + assert_eq!(orig_config.timeout_seconds, cloned_config.timeout_seconds); + } } diff --git a/crates/mcpls-core/src/lsp/lifecycle.rs b/crates/mcpls-core/src/lsp/lifecycle.rs index 03bd136..9903dc9 100644 --- a/crates/mcpls-core/src/lsp/lifecycle.rs +++ b/crates/mcpls-core/src/lsp/lifecycle.rs @@ -53,6 +53,19 @@ impl ServerState { } } +impl std::fmt::Display for ServerState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::Uninitialized => "uninitialized", + Self::Initializing => "initializing", + Self::Ready => "ready", + Self::ShuttingDown => "shutting_down", + Self::Shutdown => "shutdown", + }; + write!(f, "{s}") + } +} + /// Configuration for LSP server initialization. #[derive(Debug, Clone)] pub struct ServerInitConfig { @@ -349,6 +362,31 @@ mod tests { assert!(debug_str.contains("Ready")); } + #[test] + fn test_server_state_display_ready() { + assert_eq!(format!("{}", ServerState::Ready), "ready"); + } + + #[test] + fn test_server_state_display_initializing() { + assert_eq!(format!("{}", ServerState::Initializing), "initializing"); + } + + #[test] + fn test_server_state_display_uninitialized() { + assert_eq!(format!("{}", ServerState::Uninitialized), "uninitialized"); + } + + #[test] + fn test_server_state_display_shutting_down() { + assert_eq!(format!("{}", ServerState::ShuttingDown), "shutting_down"); + } + + #[test] + fn test_server_state_display_shutdown() { + assert_eq!(format!("{}", ServerState::Shutdown), "shutdown"); + } + #[test] fn test_server_init_config_clone() { let config = ServerInitConfig { diff --git a/crates/mcpls-core/src/mcp/server.rs b/crates/mcpls-core/src/mcp/server.rs index c8545a2..d29fd33 100644 --- a/crates/mcpls-core/src/mcp/server.rs +++ b/crates/mcpls-core/src/mcp/server.rs @@ -15,7 +15,7 @@ use super::tools::{ CachedDiagnosticsParams, CallHierarchyCallsParams, CallHierarchyPrepareParams, CodeActionsParams, CompletionsParams, DefinitionParams, DiagnosticsParams, DocumentSymbolsParams, FormatDocumentParams, HoverParams, ReferencesParams, RenameParams, - ServerLogsParams, ServerMessagesParams, WorkspaceSymbolParams, + ServerLogsParams, ServerMessagesParams, ServerStatusParams, WorkspaceSymbolParams, }; use crate::bridge::Translator; @@ -40,7 +40,7 @@ impl McplsServer { /// Get hover information at a position in a file. #[tool( - description = "Get hover information (type, documentation) at a position in a file. Returns type signatures, documentation comments, and inferred types for the symbol under cursor. Use this to understand what a variable, function, or type represents without navigating to its definition." + description = "Type and documentation info at position. Returns signatures, docs, and inferred types for symbols." )] async fn get_hover( &self, @@ -64,7 +64,7 @@ impl McplsServer { /// Get the definition location of a symbol. #[tool( - description = "Get the definition location of a symbol at the specified position. Returns file path, line, and character where the symbol (function, variable, type, etc.) is defined. Use this to navigate from a symbol usage to its original declaration or implementation." + description = "Definition location of symbol at position. Returns file path, line, and character where declared." )] async fn get_definition( &self, @@ -90,7 +90,7 @@ impl McplsServer { /// Find all references to a symbol. #[tool( - description = "Find all references to a symbol at the specified position. Returns a list of all locations (file, line, character) where the symbol is used across the workspace. Use this to understand how widely a function/variable/type is used before refactoring, or to find all call sites of a function." + description = "All references to symbol at position. Returns locations across workspace where symbol is used." )] async fn get_references( &self, @@ -117,7 +117,7 @@ impl McplsServer { /// Get diagnostics for a file. #[tool( - description = "Get diagnostics (errors, warnings) for a file. Triggers language server analysis and returns compilation errors, warnings, hints, and other issues with severity, message, and location. Use this to check code for problems before running or after making changes." + description = "Diagnostics for a file. Returns errors, warnings, and hints with severity and location." )] async fn get_diagnostics( &self, @@ -137,7 +137,7 @@ impl McplsServer { /// Rename a symbol across the workspace. #[tool( - description = "Rename a symbol across the workspace. Returns a list of text edits to apply across all files where the symbol is used. This is a safe refactoring operation that updates the symbol name consistently in declarations, usages, imports, and documentation. Use this instead of find-and-replace for reliable renaming." + description = "Rename symbol across workspace. Returns text edits for all files where symbol is used." )] async fn rename_symbol( &self, @@ -164,7 +164,7 @@ impl McplsServer { /// Get code completion suggestions. #[tool( - description = "Get code completion suggestions at a position in a file. Returns available completions including methods, functions, variables, types, keywords, and snippets with their documentation and type information. Use after typing a dot, colon, or partial identifier to see what can be inserted." + description = "Completion suggestions at position. Returns methods, functions, variables, types, and snippets." )] async fn get_completions( &self, @@ -191,7 +191,7 @@ impl McplsServer { /// Get all symbols in a document. #[tool( - description = "Get all symbols (functions, classes, variables) in a document. Returns a hierarchical outline of the file including functions, methods, classes, structs, enums, constants, and their locations. Use this to understand file structure, navigate to specific symbols, or get an overview of what a file contains." + description = "Symbols in a file. Returns hierarchical outline with functions, classes, structs, and locations." )] async fn get_document_symbols( &self, @@ -211,7 +211,7 @@ impl McplsServer { /// Format a document according to language server rules. #[tool( - description = "Format a document according to the language server's formatting rules. Returns a list of text edits to apply for proper indentation, spacing, and style. The formatting follows language-specific conventions (rustfmt for Rust, prettier for JS/TS, etc.). Use this to automatically fix code style issues." + description = "Format document with language-specific rules. Returns text edits for indentation, spacing, and style." )] async fn format_document( &self, @@ -237,7 +237,7 @@ impl McplsServer { /// Search for symbols across the workspace. #[tool( - description = "Search for symbols across the entire workspace by name or pattern. Supports partial matching and fuzzy search to find functions, types, constants, etc. by name without knowing their exact location. Use this when you know the name of something but not which file it's in, or to discover related symbols." + description = "Search workspace symbols by name. Supports partial matching and fuzzy search." )] async fn workspace_symbol_search( &self, @@ -263,7 +263,7 @@ impl McplsServer { /// Get code actions for a range. #[tool( - description = "Get available code actions (quick fixes, refactorings) for a range in a file. Returns suggested fixes for diagnostics, refactoring options (extract function, inline variable), and source actions (organize imports, generate code). Each action includes edits to apply. Use this to get IDE-style automated fixes and refactorings." + description = "Code actions for range. Returns quick fixes, refactorings, and source actions with edits." )] async fn get_code_actions( &self, @@ -299,7 +299,7 @@ impl McplsServer { /// Prepare call hierarchy at a position. #[tool( - description = "Prepare call hierarchy at a position, returns callable items. This is the first step for analyzing function call relationships. Returns a call hierarchy item that can be passed to get_incoming_calls or get_outgoing_calls. Use this on a function to start exploring its callers or callees." + description = "Prepare call hierarchy at position. Returns callable items for incoming/outgoing call analysis." )] async fn prepare_call_hierarchy( &self, @@ -325,7 +325,7 @@ impl McplsServer { /// Get incoming calls (callers). #[tool( - description = "Get functions that call the specified item (callers). Takes a call hierarchy item from prepare_call_hierarchy and returns all functions/methods that call it. Use this to trace backwards through the call graph and understand how a function is invoked and from where." + description = "Functions calling the specified item. Takes call hierarchy item, returns all callers." )] async fn get_incoming_calls( &self, @@ -345,7 +345,7 @@ impl McplsServer { /// Get outgoing calls (callees). #[tool( - description = "Get functions called by the specified item (callees). Takes a call hierarchy item from prepare_call_hierarchy and returns all functions/methods it calls. Use this to trace forward through the call graph and understand what dependencies a function has." + description = "Functions called by the specified item. Takes call hierarchy item, returns all callees." )] async fn get_outgoing_calls( &self, @@ -365,7 +365,7 @@ impl McplsServer { /// Get cached diagnostics for a file. #[tool( - description = "Get cached diagnostics for a file from LSP server notifications. Returns diagnostics that were pushed by the language server (rather than requested on-demand). This is faster than get_diagnostics as it uses cached data. Use this to quickly check recent errors/warnings without triggering new analysis." + description = "Cached diagnostics from server notifications. Faster than get_diagnostics, no new analysis." )] async fn get_cached_diagnostics( &self, @@ -385,7 +385,7 @@ impl McplsServer { /// Get recent LSP server log messages. #[tool( - description = "Get recent LSP server log messages with optional level filtering. Returns internal log messages from the language server for debugging LSP issues. Filter by level (error, warning, info, debug) to focus on relevant messages. Use this to diagnose why the language server might not be working correctly." + description = "Recent server log messages. Filter by level (error, warning, info, debug) for debugging." )] async fn get_server_logs( &self, @@ -405,7 +405,7 @@ impl McplsServer { /// Get recent LSP server messages. #[tool( - description = "Get recent LSP server messages (showMessage notifications). Returns user-facing messages from the language server like prompts, warnings, and status updates that would normally appear in IDE popups. Use this to see important messages the language server wanted to communicate." + description = "Recent server messages (showMessage notifications). User-facing prompts and status updates." )] async fn get_server_messages( &self, @@ -422,6 +422,26 @@ impl McplsServer { Err(e) => Err(McpError::internal_error(e.to_string(), None)), } } + + /// Get the status of all registered LSP servers. + #[tool( + description = "Status of all registered LSP servers. Returns server state, command, and document counts." + )] + async fn get_server_status( + &self, + Parameters(ServerStatusParams {}): Parameters, + ) -> Result { + let result = { + let translator = self.context.translator.lock().await; + translator.handle_server_status().await + }; + + match result { + Ok(value) => serde_json::to_string(&value) + .map_err(|e| McpError::internal_error(format!("Serialization error: {e}"), None)), + Err(e) => Err(McpError::internal_error(e.to_string(), None)), + } + } } #[tool_handler] @@ -838,4 +858,49 @@ mod tests { let result = server.get_server_messages(params).await; assert!(result.is_ok()); } + + #[tokio::test] + async fn test_server_status_tool_empty_workspace() { + let server = create_test_server(); + let params = Parameters(ServerStatusParams {}); + + let result = server.get_server_status(params).await; + assert!(result.is_ok()); + + let json_str = result.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + assert!(parsed.get("servers").is_some()); + + let servers = parsed.get("servers").unwrap().as_array().unwrap(); + assert_eq!(servers.len(), 0); + } + + #[tokio::test] + async fn test_server_status_tool_returns_json() { + let server = create_test_server(); + let params = Parameters(ServerStatusParams {}); + + let result = server.get_server_status(params).await; + assert!(result.is_ok()); + + let json_str = result.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + assert!(parsed.is_object()); + } + + #[tokio::test] + async fn test_server_status_tool_document_count() { + let server = create_test_server(); + let params = Parameters(ServerStatusParams {}); + + let result = server.get_server_status(params).await; + assert!(result.is_ok()); + + let json_str = result.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + assert!(parsed.get("document_count").is_some()); + + let count = parsed.get("document_count").unwrap().as_u64().unwrap(); + assert_eq!(count, 0); + } } diff --git a/crates/mcpls-core/src/mcp/tools.rs b/crates/mcpls-core/src/mcp/tools.rs index f7c216b..bd7ba49 100644 --- a/crates/mcpls-core/src/mcp/tools.rs +++ b/crates/mcpls-core/src/mcp/tools.rs @@ -249,3 +249,70 @@ pub struct ServerMessagesParams { const fn default_message_limit() -> usize { 20 } + +/// Parameters for the `get_server_status` tool. +#[derive(Debug, Clone, Default, Serialize, JsonSchema)] +#[schemars(description = "Parameters for getting the current status of all LSP servers.")] +pub struct ServerStatusParams {} + +impl<'de> Deserialize<'de> for ServerStatusParams { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // Accept both null and {} as valid empty params + let value = Option::::deserialize(deserializer)?; + match value { + None | Some(serde_json::Value::Object(_) | serde_json::Value::Null) => { + Ok(Self {}) + } + Some(_) => Err(serde::de::Error::custom( + "expected null or object for ServerStatusParams", + )), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn server_status_params_can_be_created() { + let _params = ServerStatusParams {}; + } + + #[test] + fn server_status_params_serializes_to_empty_object() { + let params = ServerStatusParams {}; + let json = serde_json::to_string(¶ms).unwrap(); + assert_eq!(json, "{}"); + } + + #[test] + fn server_status_params_deserializes_from_empty_object() { + let params: ServerStatusParams = serde_json::from_str("{}").unwrap(); + let _ = params; + } + + #[test] + fn server_status_params_deserializes_from_null() { + let params: ServerStatusParams = serde_json::from_str("null").unwrap(); + let _ = params; + } + + #[test] + fn server_status_params_implements_debug() { + let params = ServerStatusParams {}; + let debug_str = format!("{:?}", params); + assert!(debug_str.contains("ServerStatusParams")); + } + + #[test] + fn server_status_params_implements_clone() { + let params = ServerStatusParams {}; + let cloned = params.clone(); + let _ = cloned; + } +} diff --git a/docs/user-guide/tools-reference.md b/docs/user-guide/tools-reference.md index 23828cf..753b6b0 100644 --- a/docs/user-guide/tools-reference.md +++ b/docs/user-guide/tools-reference.md @@ -1,6 +1,6 @@ # MCP Tools Reference -Complete reference for all 16 MCP tools provided by mcpls. +Complete reference for all 17 MCP tools provided by mcpls. ## Overview @@ -46,6 +46,7 @@ mcpls exposes semantic code intelligence from Language Server Protocol (LSP) ser | Tool | Description | |------|-------------| +| [get_server_status](#get_server_status) | Get registered LSP servers and their status | | [get_server_logs](#get_server_logs) | Get LSP server log messages | | [get_server_messages](#get_server_messages) | Get LSP server show messages | @@ -813,6 +814,76 @@ Get diagnostics from LSP server push notifications (cached). --- +## get_server_status + +Get the status of all registered LSP servers. + +### Parameters + +```json +{} +``` + +No parameters required. + +### Returns + +```json +{ + "servers": [ + { + "language_id": "rust", + "status": "ready", + "command": "rust-analyzer", + "document_count": 5 + }, + { + "language_id": "python", + "status": "initializing", + "command": "pyright-langserver", + "document_count": 0 + } + ], + "total_servers": 2, + "document_count": 5 +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `servers` | array | List of registered LSP servers | +| `servers[].language_id` | string | Language identifier (rust, python, etc.) | +| `servers[].status` | string | Server state: ready, initializing, uninitialized, shutting_down, shutdown | +| `servers[].command` | string | Server command (e.g., rust-analyzer) | +| `servers[].document_count` | integer | Number of open documents for this language | +| `total_servers` | integer | Total number of registered servers | +| `document_count` | integer | Total documents across all servers | + +### Example Use Cases + +**Check server health:** +``` +User: Which language servers are running? +Claude: [Uses get_server_status] 2 servers registered: + - rust: ready (rust-analyzer, 5 documents) + - python: initializing (pyright-langserver) +``` + +**Debug initialization:** +``` +User: Why isn't completion working for Python files? +Claude: [Uses get_server_status] The Python server is still initializing. + Wait a moment for it to become ready. +``` + +### Notes + +- Returns empty `servers` array if no LSP servers are configured +- Status values are always lowercase strings +- Document count reflects currently open/tracked documents + +--- + ## get_server_logs Get recent log messages from LSP servers.