Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Comment thread
dergachoff marked this conversation as resolved.
## [0.3.6] - 2026-04-21

### Changed
Expand Down
104 changes: 103 additions & 1 deletion crates/mcpls-core/src/lsp/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<Value, JsonRpcError> {
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)]
Expand Down Expand Up @@ -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};
Expand Down
76 changes: 66 additions & 10 deletions crates/mcpls-core/src/lsp/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -163,10 +155,39 @@ impl LspTransport {
}
}

fn parse_inbound_message(value: Value) -> Result<InboundMessage> {
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() {
Expand Down Expand Up @@ -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!({
Expand Down
6 changes: 5 additions & 1 deletion crates/mcpls-core/src/lsp/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand Down Expand Up @@ -111,7 +114,7 @@ impl LspNotification {
///
/// # Examples
///
/// ```
/// ```rust,ignore
/// use mcpls_core::lsp::types::LspNotification;
/// use serde_json::json;
///
Expand All @@ -130,6 +133,7 @@ impl LspNotification {
/// _ => panic!("Expected LogMessage variant"),
/// }
/// ```
#[must_use]
pub fn parse(method: &str, params: Option<serde_json::Value>) -> Self {
match method {
"textDocument/publishDiagnostics" => {
Expand Down
Loading