diff --git a/CHANGELOG.md b/CHANGELOG.md index 08b8838..665fe21 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] +### Changed + +- **LSP API** — Breaking change: `InboundMessage` is now non-exhaustive and includes a server-request variant for LSP server-to-client JSON-RPC requests. Downstream exhaustive matches must include a wildcard arm. + +### Fixed + +- **LSP server requests** — Handle server-to-client requests such as `client/registerCapability`, fixing tsgo timeouts. + ## [0.3.6] - 2026-04-21 ### Changed diff --git a/crates/mcpls-core/src/lsp/client.rs b/crates/mcpls-core/src/lsp/client.rs index feba0e2..786d3d7 100644 --- a/crates/mcpls-core/src/lsp/client.rs +++ b/crates/mcpls-core/src/lsp/client.rs @@ -15,7 +15,9 @@ use tracing::{debug, error, trace, warn}; use crate::config::LspServerConfig; use crate::error::{Error, Result}; use crate::lsp::transport::LspTransport; -use crate::lsp::types::{InboundMessage, JsonRpcRequest, LspNotification, RequestId}; +use crate::lsp::types::{ + InboundMessage, JsonRpcError, JsonRpcRequest, JsonRpcResponse, LspNotification, RequestId, +}; /// JSON-RPC protocol version. const JSONRPC_VERSION: &str = "2.0"; @@ -371,6 +373,15 @@ impl LspClient { warn!("Received response for unknown request ID: {:?}", response.id); } } + InboundMessage::Request(request) => { + debug!( + "Received server request: {} (id={:?})", + request.method, request.id + ); + let response = Self::server_request_response(request); + let value = serde_json::to_value(&response)?; + transport.send(&value).await?; + } InboundMessage::Notification(notification) => { debug!("Received notification: {}", notification.method); @@ -403,6 +414,51 @@ impl LspClient { Ok(()) } + + fn server_request_response(request: JsonRpcRequest) -> JsonRpcResponse { + match Self::server_request_result(&request.method, request.params.as_ref()) { + Ok(result) => JsonRpcResponse { + jsonrpc: JSONRPC_VERSION.to_string(), + id: request.id, + result: Some(result), + error: None, + }, + Err(error) => JsonRpcResponse { + jsonrpc: JSONRPC_VERSION.to_string(), + id: request.id, + result: None, + error: Some(error), + }, + } + } + + fn server_request_result( + method: &str, + params: Option<&Value>, + ) -> std::result::Result { + match method { + "client/registerCapability" + | "client/unregisterCapability" + | "workspace/workspaceFolders" + | "window/showMessageRequest" => Ok(Value::Null), + "workspace/configuration" => Ok(Self::workspace_configuration_result(params)), + "workspace/applyEdit" => Ok(serde_json::json!({ "applied": false })), + _ => Err(JsonRpcError { + code: -32601, + message: format!("Unhandled server request: {method}"), + data: None, + }), + } + } + + fn workspace_configuration_result(params: Option<&Value>) -> Value { + let item_count = params + .and_then(|value| value.get("items")) + .and_then(Value::as_array) + .map_or(0, Vec::len); + + Value::Array(vec![Value::Null; item_count]) + } } #[cfg(test)] @@ -446,6 +502,52 @@ mod tests { ); } + #[test] + fn test_register_capability_request_is_acknowledged() { + let request = JsonRpcRequest { + jsonrpc: JSONRPC_VERSION.to_string(), + id: RequestId::String("ts1".to_string()), + method: "client/registerCapability".to_string(), + params: Some(serde_json::json!({ "registrations": [] })), + }; + + let response = LspClient::server_request_response(request); + + assert_eq!(response.id, RequestId::String("ts1".to_string())); + assert_eq!(response.result, Some(Value::Null)); + assert!(response.error.is_none()); + } + + #[test] + fn test_workspace_configuration_request_returns_null_per_item() { + let result = LspClient::workspace_configuration_result(Some(&serde_json::json!({ + "items": [{ "section": "typescript" }, { "section": "editor" }] + }))); + + assert_eq!(result, serde_json::json!([null, null])); + } + + #[test] + fn test_unknown_server_request_returns_method_not_found() { + let request = JsonRpcRequest { + jsonrpc: JSONRPC_VERSION.to_string(), + id: RequestId::String("unknown-1".to_string()), + method: "custom/request".to_string(), + params: None, + }; + + let response = LspClient::server_request_response(request); + + assert!(response.result.is_none()); + match response.error { + Some(error) => { + assert_eq!(error.code, -32601); + assert_eq!(error.message, "Unhandled server request: custom/request"); + } + None => panic!("unknown request should return error"), + } + } + #[tokio::test] async fn test_null_response_handling() { use crate::lsp::types::{JsonRpcResponse, RequestId}; diff --git a/crates/mcpls-core/src/lsp/transport.rs b/crates/mcpls-core/src/lsp/transport.rs index 2bd7b98..bbb3afa 100644 --- a/crates/mcpls-core/src/lsp/transport.rs +++ b/crates/mcpls-core/src/lsp/transport.rs @@ -16,7 +16,7 @@ use tokio::process::{ChildStdin, ChildStdout}; use tracing::{trace, warn}; use crate::error::{Error, Result}; -use crate::lsp::types::{InboundMessage, JsonRpcNotification, JsonRpcResponse}; +use crate::lsp::types::{InboundMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse}; /// Maximum allowed Content-Length (10 MB) const MAX_CONTENT_LENGTH: usize = 10 * 1024 * 1024; @@ -104,15 +104,7 @@ impl LspTransport { let value: Value = serde_json::from_str(&content)?; - if value.get("id").is_some() { - let response: JsonRpcResponse = serde_json::from_value(value) - .map_err(|e| Error::LspProtocolError(format!("Invalid response: {e}")))?; - Ok(InboundMessage::Response(response)) - } else { - let notification: JsonRpcNotification = serde_json::from_value(value) - .map_err(|e| Error::LspProtocolError(format!("Invalid notification: {e}")))?; - Ok(InboundMessage::Notification(notification)) - } + parse_inbound_message(value) } /// Read headers until blank line. @@ -163,10 +155,39 @@ impl LspTransport { } } +fn parse_inbound_message(value: Value) -> Result { + if value.get("method").is_some() { + if value.get("id").is_some() { + let request: JsonRpcRequest = serde_json::from_value(value) + .map_err(|e| Error::LspProtocolError(format!("Invalid request: {e}")))?; + Ok(InboundMessage::Request(request)) + } else { + let notification: JsonRpcNotification = serde_json::from_value(value) + .map_err(|e| Error::LspProtocolError(format!("Invalid notification: {e}")))?; + Ok(InboundMessage::Notification(notification)) + } + } else if value.get("id").is_some() + && (value.get("result").is_some() || value.get("error").is_some()) + { + let response: JsonRpcResponse = serde_json::from_value(value) + .map_err(|e| Error::LspProtocolError(format!("Invalid response: {e}")))?; + Ok(InboundMessage::Response(response)) + } else if value.get("id").is_some() { + Err(Error::LspProtocolError( + "Response messages with an id must include either result or error".to_string(), + )) + } else { + Err(Error::LspProtocolError( + "Message must be a request, response, or notification".to_string(), + )) + } +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; + use crate::lsp::types::RequestId; #[test] fn test_header_parsing() { @@ -266,6 +287,41 @@ mod tests { assert!(!content.contains("\"id\"")); } + #[test] + fn test_server_request_parsing() { + let value = serde_json::json!({ + "jsonrpc": "2.0", + "id": "ts1", + "method": "client/registerCapability", + "params": {"registrations": []} + }); + + let message = parse_inbound_message(value).unwrap(); + match message { + InboundMessage::Request(request) => { + assert_eq!(request.id, RequestId::String("ts1".to_string())); + assert_eq!(request.method, "client/registerCapability"); + } + other => panic!("expected request, got {other:?}"), + } + } + + #[test] + fn test_id_only_message_is_protocol_error() { + let value = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1 + }); + + let error = parse_inbound_message(value).unwrap_err(); + assert!(matches!(error, Error::LspProtocolError(_))); + assert!( + error + .to_string() + .contains("must include either result or error") + ); + } + #[test] fn test_message_serialization_error_response() { let error_response = serde_json::json!({ diff --git a/crates/mcpls-core/src/lsp/types.rs b/crates/mcpls-core/src/lsp/types.rs index a7cd326..e2adaf3 100644 --- a/crates/mcpls-core/src/lsp/types.rs +++ b/crates/mcpls-core/src/lsp/types.rs @@ -72,9 +72,12 @@ pub enum RequestId { /// Inbound message from LSP server. #[derive(Debug, Clone)] +#[non_exhaustive] pub enum InboundMessage { /// Response to a request. Response(JsonRpcResponse), + /// Request from server to client. + Request(JsonRpcRequest), /// Notification from server. Notification(JsonRpcNotification), } @@ -111,7 +114,7 @@ impl LspNotification { /// /// # Examples /// - /// ``` + /// ```rust,ignore /// use mcpls_core::lsp::types::LspNotification; /// use serde_json::json; /// @@ -130,6 +133,7 @@ impl LspNotification { /// _ => panic!("Expected LogMessage variant"), /// } /// ``` + #[must_use] pub fn parse(method: &str, params: Option) -> Self { match method { "textDocument/publishDiagnostics" => {