Skip to content

Commit 522bad5

Browse files
authored
fix(lsp): handle server requests (#101)
1 parent 7abaf87 commit 522bad5

4 files changed

Lines changed: 182 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- **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.
13+
14+
### Fixed
15+
16+
- **LSP server requests** — Handle server-to-client requests such as `client/registerCapability`, fixing tsgo timeouts.
17+
1018
## [0.3.6] - 2026-04-21
1119

1220
### Changed

crates/mcpls-core/src/lsp/client.rs

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ use tracing::{debug, error, trace, warn};
1515
use crate::config::LspServerConfig;
1616
use crate::error::{Error, Result};
1717
use crate::lsp::transport::LspTransport;
18-
use crate::lsp::types::{InboundMessage, JsonRpcRequest, LspNotification, RequestId};
18+
use crate::lsp::types::{
19+
InboundMessage, JsonRpcError, JsonRpcRequest, JsonRpcResponse, LspNotification, RequestId,
20+
};
1921

2022
/// JSON-RPC protocol version.
2123
const JSONRPC_VERSION: &str = "2.0";
@@ -371,6 +373,15 @@ impl LspClient {
371373
warn!("Received response for unknown request ID: {:?}", response.id);
372374
}
373375
}
376+
InboundMessage::Request(request) => {
377+
debug!(
378+
"Received server request: {} (id={:?})",
379+
request.method, request.id
380+
);
381+
let response = Self::server_request_response(request);
382+
let value = serde_json::to_value(&response)?;
383+
transport.send(&value).await?;
384+
}
374385
InboundMessage::Notification(notification) => {
375386
debug!("Received notification: {}", notification.method);
376387

@@ -403,6 +414,51 @@ impl LspClient {
403414

404415
Ok(())
405416
}
417+
418+
fn server_request_response(request: JsonRpcRequest) -> JsonRpcResponse {
419+
match Self::server_request_result(&request.method, request.params.as_ref()) {
420+
Ok(result) => JsonRpcResponse {
421+
jsonrpc: JSONRPC_VERSION.to_string(),
422+
id: request.id,
423+
result: Some(result),
424+
error: None,
425+
},
426+
Err(error) => JsonRpcResponse {
427+
jsonrpc: JSONRPC_VERSION.to_string(),
428+
id: request.id,
429+
result: None,
430+
error: Some(error),
431+
},
432+
}
433+
}
434+
435+
fn server_request_result(
436+
method: &str,
437+
params: Option<&Value>,
438+
) -> std::result::Result<Value, JsonRpcError> {
439+
match method {
440+
"client/registerCapability"
441+
| "client/unregisterCapability"
442+
| "workspace/workspaceFolders"
443+
| "window/showMessageRequest" => Ok(Value::Null),
444+
"workspace/configuration" => Ok(Self::workspace_configuration_result(params)),
445+
"workspace/applyEdit" => Ok(serde_json::json!({ "applied": false })),
446+
_ => Err(JsonRpcError {
447+
code: -32601,
448+
message: format!("Unhandled server request: {method}"),
449+
data: None,
450+
}),
451+
}
452+
}
453+
454+
fn workspace_configuration_result(params: Option<&Value>) -> Value {
455+
let item_count = params
456+
.and_then(|value| value.get("items"))
457+
.and_then(Value::as_array)
458+
.map_or(0, Vec::len);
459+
460+
Value::Array(vec![Value::Null; item_count])
461+
}
406462
}
407463

408464
#[cfg(test)]
@@ -446,6 +502,52 @@ mod tests {
446502
);
447503
}
448504

505+
#[test]
506+
fn test_register_capability_request_is_acknowledged() {
507+
let request = JsonRpcRequest {
508+
jsonrpc: JSONRPC_VERSION.to_string(),
509+
id: RequestId::String("ts1".to_string()),
510+
method: "client/registerCapability".to_string(),
511+
params: Some(serde_json::json!({ "registrations": [] })),
512+
};
513+
514+
let response = LspClient::server_request_response(request);
515+
516+
assert_eq!(response.id, RequestId::String("ts1".to_string()));
517+
assert_eq!(response.result, Some(Value::Null));
518+
assert!(response.error.is_none());
519+
}
520+
521+
#[test]
522+
fn test_workspace_configuration_request_returns_null_per_item() {
523+
let result = LspClient::workspace_configuration_result(Some(&serde_json::json!({
524+
"items": [{ "section": "typescript" }, { "section": "editor" }]
525+
})));
526+
527+
assert_eq!(result, serde_json::json!([null, null]));
528+
}
529+
530+
#[test]
531+
fn test_unknown_server_request_returns_method_not_found() {
532+
let request = JsonRpcRequest {
533+
jsonrpc: JSONRPC_VERSION.to_string(),
534+
id: RequestId::String("unknown-1".to_string()),
535+
method: "custom/request".to_string(),
536+
params: None,
537+
};
538+
539+
let response = LspClient::server_request_response(request);
540+
541+
assert!(response.result.is_none());
542+
match response.error {
543+
Some(error) => {
544+
assert_eq!(error.code, -32601);
545+
assert_eq!(error.message, "Unhandled server request: custom/request");
546+
}
547+
None => panic!("unknown request should return error"),
548+
}
549+
}
550+
449551
#[tokio::test]
450552
async fn test_null_response_handling() {
451553
use crate::lsp::types::{JsonRpcResponse, RequestId};

crates/mcpls-core/src/lsp/transport.rs

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use tokio::process::{ChildStdin, ChildStdout};
1616
use tracing::{trace, warn};
1717

1818
use crate::error::{Error, Result};
19-
use crate::lsp::types::{InboundMessage, JsonRpcNotification, JsonRpcResponse};
19+
use crate::lsp::types::{InboundMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse};
2020

2121
/// Maximum allowed Content-Length (10 MB)
2222
const MAX_CONTENT_LENGTH: usize = 10 * 1024 * 1024;
@@ -104,15 +104,7 @@ impl LspTransport {
104104

105105
let value: Value = serde_json::from_str(&content)?;
106106

107-
if value.get("id").is_some() {
108-
let response: JsonRpcResponse = serde_json::from_value(value)
109-
.map_err(|e| Error::LspProtocolError(format!("Invalid response: {e}")))?;
110-
Ok(InboundMessage::Response(response))
111-
} else {
112-
let notification: JsonRpcNotification = serde_json::from_value(value)
113-
.map_err(|e| Error::LspProtocolError(format!("Invalid notification: {e}")))?;
114-
Ok(InboundMessage::Notification(notification))
115-
}
107+
parse_inbound_message(value)
116108
}
117109

118110
/// Read headers until blank line.
@@ -163,10 +155,39 @@ impl LspTransport {
163155
}
164156
}
165157

158+
fn parse_inbound_message(value: Value) -> Result<InboundMessage> {
159+
if value.get("method").is_some() {
160+
if value.get("id").is_some() {
161+
let request: JsonRpcRequest = serde_json::from_value(value)
162+
.map_err(|e| Error::LspProtocolError(format!("Invalid request: {e}")))?;
163+
Ok(InboundMessage::Request(request))
164+
} else {
165+
let notification: JsonRpcNotification = serde_json::from_value(value)
166+
.map_err(|e| Error::LspProtocolError(format!("Invalid notification: {e}")))?;
167+
Ok(InboundMessage::Notification(notification))
168+
}
169+
} else if value.get("id").is_some()
170+
&& (value.get("result").is_some() || value.get("error").is_some())
171+
{
172+
let response: JsonRpcResponse = serde_json::from_value(value)
173+
.map_err(|e| Error::LspProtocolError(format!("Invalid response: {e}")))?;
174+
Ok(InboundMessage::Response(response))
175+
} else if value.get("id").is_some() {
176+
Err(Error::LspProtocolError(
177+
"Response messages with an id must include either result or error".to_string(),
178+
))
179+
} else {
180+
Err(Error::LspProtocolError(
181+
"Message must be a request, response, or notification".to_string(),
182+
))
183+
}
184+
}
185+
166186
#[cfg(test)]
167187
#[allow(clippy::unwrap_used)]
168188
mod tests {
169189
use super::*;
190+
use crate::lsp::types::RequestId;
170191

171192
#[test]
172193
fn test_header_parsing() {
@@ -266,6 +287,41 @@ mod tests {
266287
assert!(!content.contains("\"id\""));
267288
}
268289

290+
#[test]
291+
fn test_server_request_parsing() {
292+
let value = serde_json::json!({
293+
"jsonrpc": "2.0",
294+
"id": "ts1",
295+
"method": "client/registerCapability",
296+
"params": {"registrations": []}
297+
});
298+
299+
let message = parse_inbound_message(value).unwrap();
300+
match message {
301+
InboundMessage::Request(request) => {
302+
assert_eq!(request.id, RequestId::String("ts1".to_string()));
303+
assert_eq!(request.method, "client/registerCapability");
304+
}
305+
other => panic!("expected request, got {other:?}"),
306+
}
307+
}
308+
309+
#[test]
310+
fn test_id_only_message_is_protocol_error() {
311+
let value = serde_json::json!({
312+
"jsonrpc": "2.0",
313+
"id": 1
314+
});
315+
316+
let error = parse_inbound_message(value).unwrap_err();
317+
assert!(matches!(error, Error::LspProtocolError(_)));
318+
assert!(
319+
error
320+
.to_string()
321+
.contains("must include either result or error")
322+
);
323+
}
324+
269325
#[test]
270326
fn test_message_serialization_error_response() {
271327
let error_response = serde_json::json!({

crates/mcpls-core/src/lsp/types.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,12 @@ pub enum RequestId {
7272

7373
/// Inbound message from LSP server.
7474
#[derive(Debug, Clone)]
75+
#[non_exhaustive]
7576
pub enum InboundMessage {
7677
/// Response to a request.
7778
Response(JsonRpcResponse),
79+
/// Request from server to client.
80+
Request(JsonRpcRequest),
7881
/// Notification from server.
7982
Notification(JsonRpcNotification),
8083
}
@@ -111,7 +114,7 @@ impl LspNotification {
111114
///
112115
/// # Examples
113116
///
114-
/// ```
117+
/// ```rust,ignore
115118
/// use mcpls_core::lsp::types::LspNotification;
116119
/// use serde_json::json;
117120
///
@@ -130,6 +133,7 @@ impl LspNotification {
130133
/// _ => panic!("Expected LogMessage variant"),
131134
/// }
132135
/// ```
136+
#[must_use]
133137
pub fn parse(method: &str, params: Option<serde_json::Value>) -> Self {
134138
match method {
135139
"textDocument/publishDiagnostics" => {

0 commit comments

Comments
 (0)