feat(lsp): add LSP server stub
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 14s
CI / build (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 33s
CI / typecheck (pull_request) Successful in 38s
CI / unit_tests (pull_request) Successful in 4m0s
CI / docker (pull_request) Successful in 38s
CI / integration_tests (pull_request) Successful in 4m48s
CI / coverage (pull_request) Successful in 4m31s
CI / lint (push) Successful in 12s
CI / quality (push) Successful in 16s
CI / build (push) Successful in 15s
CI / security (push) Successful in 32s
CI / typecheck (push) Successful in 35s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 2m27s
CI / integration_tests (push) Successful in 3m3s
CI / docker (push) Successful in 38s
CI / coverage (push) Successful in 4m25s
CI / benchmark-publish (push) Successful in 16m28s
CI / benchmark-regression (pull_request) Successful in 28m52s

Added minimal LSP server entrypoint supporting initialize/shutdown/exit
handshake over JSON-RPC stdin/stdout transport with Content-Length
framing. Unsupported methods return MethodNotFound error with descriptive
message. Wired LSP requests through ACP facade in local mode. Added
agents lsp serve CLI command with --log-level flag, PID output, and
startup banner. Created reference documentation for the stub server.
Includes Behave BDD tests for protocol handshake, Robot smoke test, and
ASV startup latency benchmark.

ISSUES CLOSED: #203
This commit was merged in pull request #608.
This commit is contained in:
CoreRasurae
2026-03-04 01:07:29 +00:00
committed by Luis Mendes
parent 2bf35e5403
commit 23803f14ec
14 changed files with 2669 additions and 6 deletions
+10
View File
@@ -28,6 +28,16 @@
Fixed CONTRIBUTORS.md alphabetical ordering. Full nox quality gates pass: lint, format,
typecheck, unit tests, integration tests, coverage (97%), security scan, dead code
detection, docs build, wheel build, and ASV benchmarks. (#495)
- Added minimal LSP server stub with `agents lsp serve` CLI command supporting the
`initialize`, `shutdown`, and `exit` lifecycle handshake over JSON-RPC stdin/stdout
transport with Content-Length header framing. Unsupported methods return `MethodNotFound`
(-32601); requests before `initialize` return `ServerNotInitialized` (-32002); requests
after `shutdown` return `InvalidRequest` (-32600). Transport hardening includes 10 MB
max content-length, 32 max header lines, graceful recovery from malformed messages,
and recursion-depth protection. `LspServer` stores an `AcpLocalFacade` for future
server-mode wiring. `--log-level` flag controls logging verbosity. Includes 48 Behave
BDD scenarios, Robot Framework smoke tests, ASV startup latency benchmarks, and
`docs/reference/lsp_stub.md`. (#203)
- Validated M3 acceptance criteria for v3.2.0 milestone closure. All 10 E2E
verification tests pass against the final implementation, exercising real
CLI command paths (``plan use``, ``plan execute``, ``plan tree``,
+121
View File
@@ -0,0 +1,121 @@
"""ASV benchmarks for LSP server stub startup latency.
Measures the performance of:
- LspServer construction overhead
- Initialize request handling latency
- Full handshake (initialize + shutdown + exit) latency
- MethodNotFound error response latency
"""
from __future__ import annotations
import importlib
import io
import json
import sys
from pathlib import Path
# Ensure the local *source* tree is importable even when ASV has an
# older build of the package installed.
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
# Force-reload so ASV picks up the source tree version. Reload the
# specific submodule that contains the benchmarked code, not just the
# top-level package.
import cleveragents # noqa: E402
import cleveragents.lsp.server # noqa: E402
importlib.reload(cleveragents)
importlib.reload(cleveragents.lsp.server)
from cleveragents.lsp.server import LspServer # noqa: E402
def _encode_message(msg: dict) -> bytes:
"""Encode a JSON-RPC message with Content-Length framing."""
payload = json.dumps(msg).encode("utf-8")
header = f"Content-Length: {len(payload)}\r\n\r\n".encode()
return header + payload
def _make_streams(*messages: dict) -> tuple[io.BytesIO, io.BytesIO]:
"""Build input/output streams from a sequence of JSON-RPC messages."""
raw = b"".join(_encode_message(msg) for msg in messages)
return io.BytesIO(raw), io.BytesIO()
class LspStubStartupSuite:
"""Benchmark LSP server stub startup and request handling."""
timeout = 60
def setup(self) -> None:
"""Pre-build common messages and reusable streams for benchmarks."""
self.init_msg = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"capabilities": {}},
}
self.shutdown_msg = {
"jsonrpc": "2.0",
"id": 2,
"method": "shutdown",
}
self.exit_msg = {
"jsonrpc": "2.0",
"method": "exit",
}
self.completion_msg = {
"jsonrpc": "2.0",
"id": 3,
"method": "textDocument/completion",
"params": {},
}
# Pre-build the 100-message payload so that list construction
# overhead is excluded from the benchmark measurement.
batch_messages: list[dict] = [self.init_msg]
for i in range(100):
batch_messages.append(
{
"jsonrpc": "2.0",
"id": i + 10,
"method": "textDocument/completion",
"params": {},
}
)
batch_messages.append(self.exit_msg)
self._batch_raw = b"".join(_encode_message(msg) for msg in batch_messages)
def time_server_construction(self) -> None:
"""Measure LspServer construction overhead."""
inp, out = _make_streams(self.exit_msg)
LspServer(input_stream=inp, output_stream=out)
def time_initialize_request(self) -> None:
"""Measure initialize request handling latency."""
inp, out = _make_streams(self.init_msg, self.exit_msg)
server = LspServer(input_stream=inp, output_stream=out)
server.run()
def time_full_handshake(self) -> None:
"""Measure full lifecycle: initialize + shutdown + exit."""
inp, out = _make_streams(self.init_msg, self.shutdown_msg, self.exit_msg)
server = LspServer(input_stream=inp, output_stream=out)
server.run()
def time_method_not_found(self) -> None:
"""Measure MethodNotFound error response latency (after initialize)."""
inp, out = _make_streams(self.init_msg, self.completion_msg, self.exit_msg)
server = LspServer(input_stream=inp, output_stream=out)
server.run()
def time_100_method_not_found(self) -> None:
"""Measure 100 MethodNotFound responses (after initialize)."""
inp = io.BytesIO(self._batch_raw)
out = io.BytesIO()
server = LspServer(input_stream=inp, output_stream=out)
server.run()
+184
View File
@@ -0,0 +1,184 @@
# LSP Server Stub
The CleverAgents LSP server stub provides a minimal Language Server
Protocol implementation that handles the core lifecycle handshake
over JSON-RPC stdin/stdout transport with Content-Length header
framing.
## Usage
Launch the stub server via the CLI:
```bash
agents lsp serve
agents lsp serve --log-level debug
```
The server reads JSON-RPC messages from **stdin** and writes
responses to **stdout**, following the LSP base protocol
specification.
### Options
| Flag | Default | Description |
|----------------|---------|------------------------------------------|
| `--log-level` | `info` | Logging level: debug, info, warning, error |
On startup the server prints a banner with its PID and transport
details to the console (stderr), then enters the message loop.
## Transport Limits
| Limit | Value | Description |
|-----------------------|---------|-----------------------------------------------------|
| Max Content-Length | 10 MB | Messages exceeding this are rejected (DoS guard) |
| Max header lines | 32 | Headers with more lines are dropped (DoS guard) |
Messages with a `Content-Length` header exceeding 10 MB
(`MAX_CONTENT_LENGTH`) are skipped with a warning log entry and
the server continues reading the next message. Up to
`MAX_CONTENT_LENGTH` bytes of body are consumed and discarded
in 64 KB chunks so that subsequent messages are not affected by
a transport desync. The discard is capped to prevent a
malicious client from blocking the server with an absurdly
large declared length.
Header sections with more than 32 lines (`MAX_HEADER_LINES`)
are skipped to prevent denial-of-service from adversarial clients
sending infinite non-empty header lines. Remaining header lines
are drained up to the blank terminator (with a secondary cap of
`MAX_HEADER_LINES` drain lines) so the stream stays aligned for
the next message.
Malformed `Content-Length` values (non-integer strings) are
rejected and remaining headers are drained before resuming.
Deeply nested JSON payloads that exceed Python's recursion limit
are caught and return a JSON-RPC parse error response. This
prevents a denial-of-service from adversarial clients sending
pathologically nested structures to exhaust the call stack.
Other recoverable transport errors — negative `Content-Length`,
incomplete message body, and malformed JSON — are also skipped
(with appropriate warning logs or JSON-RPC parse error responses)
rather than terminating the server. If the output stream is
broken (e.g. client disconnected), the server logs a warning and
terminates gracefully. Only a true EOF (closed stdin), a broken
output pipe, or an ``exit`` notification stops the event loop.
## Supported Methods
### `initialize`
Responds with a stubbed `InitializeResult` containing:
- **serverInfo**: `{ "name": "cleveragents-lsp-stub", "version": "0.1.0" }`
- **capabilities**: All capabilities from the specification's LSP
Capability Exposure section are reported as stubs (disabled or
null). The capability set includes `textDocumentSync`,
`completionProvider`, `hoverProvider`, `definitionProvider`,
`referencesProvider`, `documentFormattingProvider`, `renameProvider`,
`codeActionProvider`, `diagnosticProvider`, `signatureHelpProvider`,
`documentSymbolProvider`, and `workspaceSymbolProvider`.
A second `initialize` request is rejected with `InvalidRequest`
(-32600) per the LSP specification.
### `shutdown`
Returns a JSON-RPC success response with `result: null`. Marks the
server as ready for exit. After `shutdown`, only `exit` is
accepted — all other requests receive an `InvalidRequest` (-32600)
error, and notifications are silently ignored, per the LSP
specification (section 3.16).
Like all other requests (except `initialize`), `shutdown` is subject
to the pre-initialize guard: sending `shutdown` before `initialize`
returns a `ServerNotInitialized` (-32002) error.
### `exit`
Terminates the server event loop. If `shutdown` was received first
the exit code is 0; otherwise it is 1.
## Pre-Initialize Requests
Requests received **before** the `initialize` handshake (including
`shutdown`) return a `ServerNotInitialized` (-32002) error, per the
LSP specification:
```json
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32002,
"message": "Server not initialized: textDocument/completion"
}
}
```
Notifications sent before `initialize` are silently ignored.
## Unsupported Methods
After `initialize`, all other LSP methods (e.g.
`textDocument/didOpen`, `textDocument/completion`,
`textDocument/hover`) return a JSON-RPC `MethodNotFound` error:
```json
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32601,
"message": "Not implemented: textDocument/completion"
}
}
```
Notifications (messages without an `id` field) for unsupported
methods are silently ignored, per the JSON-RPC specification.
## ACP Facade Wiring
An `AcpLocalFacade` instance is stored on `LspServer` so that the
dispatch path can be wired through ACP when server mode lands.
In the **current stub implementation** the facade is a placeholder —
all methods are handled by hardcoded stub handlers in
`_dispatch_stub()`. The facade is **not called** at this time.
> **TODO** (M7+): Wire `_dispatch_stub()` through `self._facade`
> when server mode is implemented.
## Architecture
```
stdin → [Content-Length framing] → [JSON-RPC parser]
[LspServer._handle_message]
[_dispatch_stub (hardcoded)]
[Stub handlers]
stdout ← [Content-Length framing] ← [JSON-RPC response]
```
> Note: `AcpLocalFacade` is stored but not called in the current
> stub. The architecture diagram above will change when server
> mode routes through the facade.
## Future Roadmap
- **M7**: Wire `textDocument/didOpen` and `textDocument/didClose`
notifications to the ACP context pipeline.
- **M8**: Implement `textDocument/completion` via the LspToolAdapter,
delegating to real language server processes managed by LspRuntime.
- **M9**: Add `textDocument/diagnostics` push support with
server-initiated notifications.
- **M10**: Full LSP feature parity for supported languages
(Python, TypeScript, Go) with hot-reload of language server
configurations from the LSP Registry.
- **Server mode**: Replace stub handlers with real LSP transport
proxying through the ACP facade in server mode.
+47
View File
@@ -301,6 +301,7 @@ The following standards are integrated into the architecture:
<span style="color: cyan; font-weight: 600;">agents</span> lsp remove [<span style="color: cyan;">--yes</span>|<span style="color: yellow;">-y</span>] <span style="color: #66cc66;">&lt;NAME&gt;</span>
<span style="color: cyan; font-weight: 600;">agents</span> lsp list [(<span style="color: magenta;"><span style="color: cyan;">--namespace</span>|<span style="color: yellow;">-n</span></span>) <span style="color: #66cc66;">&lt;NS&gt;</span>] [<span style="color: cyan;">--language</span> <span style="color: #66cc66;">&lt;LANG&gt;</span>]
<span style="color: cyan; font-weight: 600;">agents</span> lsp show <span style="color: #66cc66;">&lt;NAME&gt;</span>
<span style="color: cyan; font-weight: 600;">agents</span> lsp serve [<span style="color: cyan;">--log-level</span> <span style="color: #66cc66;">&lt;LEVEL&gt;</span>]
<span style="color: cyan; font-weight: 600;">agents</span> resource type add (<span style="color: magenta;"><span style="color: cyan;">--config</span>|<span style="color: yellow;">-c</span></span>) <span style="color: #66cc66;">&lt;FILE&gt;</span> [<span style="color: cyan;">--update</span>]
<span style="color: cyan; font-weight: 600;">agents</span> resource type remove [<span style="color: cyan;">--yes</span>|<span style="color: yellow;">-y</span>] <span style="color: #66cc66;">&lt;NAME&gt;</span>
@@ -9205,6 +9206,52 @@ Show full details for a registered LSP server, including its configuration, supp
- LSP server details loaded
```
##### agents lsp serve
<div class="highlight"><pre><code><span style="color: cyan; font-weight: 600;">agents</span> lsp serve [<span style="color: cyan;">--log-level</span> <span style="color: #66cc66;">&lt;LEVEL&gt;</span>]</code></pre></div>
**Purpose**
Launch the stub LSP server over stdin/stdout using the JSON-RPC base protocol with Content-Length header framing. The server supports the `initialize`, `shutdown`, and `exit` lifecycle methods. Requests received **before** `initialize` are rejected with `ServerNotInitialized` (-32002) per the LSP specification. After initialization, unsupported LSP methods return a `MethodNotFound` (-32601) error. After `shutdown` is received, only `exit` is accepted; other requests are rejected with `InvalidRequest` (-32600). Sending `initialize` a second time is also rejected with `InvalidRequest`.
This command is intended for development and testing of LSP transport integration. In a future milestone the stub handlers will be replaced by real LSP server proxying through the ACP facade.
**Arguments**
- `--log-level LEVEL`: Logging level for the server process. Valid values: `debug`, `info` (default), `warning`, `error`. Invalid values cause immediate exit with a descriptive error message.
**Transport Limits**
| Limit | Value | Description |
|-------|-------|-------------|
| Max Content-Length | 10 MB | Messages exceeding this are rejected with a warning log (DoS guard) |
| Max header lines | 32 | Headers with more lines are rejected with a warning log (DoS guard) |
**Examples**
=== "Rich"
<div class="highlight"><pre><code>
<span style="color: #66cc66; font-weight: 600;">$</span> <span style="color: cyan; font-weight: 600;">agents</span> lsp serve
<span style="color: cyan; font-weight: 600;">CleverAgents LSP Server</span> (stub) starting — PID 7412
Log level: info
Transport: stdin/stdout (JSON-RPC, Content-Length framing)
Supported methods: initialize, shutdown, exit
All other methods → MethodNotFound (-32601)
</code></pre></div>
=== "Plain"
```
$ agents lsp serve
CleverAgents LSP Server (stub) starting — PID 7412
Log level: info
Transport: stdin/stdout (JSON-RPC, Content-Length framing)
Supported methods: initialize, shutdown, exit
All other methods → MethodNotFound (-32601)
```
#### agents validation
!!! info "Purpose"
+466
View File
@@ -0,0 +1,466 @@
@phase2 @domain @lsp @lsp_server_stub
Feature: LSP server stub JSON-RPC protocol
As a developer integrating LSP into CleverAgents
I want a minimal LSP server stub that handles core lifecycle methods
So that I can test the JSON-RPC transport and protocol handshake
Background:
Given a mock LSP transport
# ---------------------------------------------------------------------------
# Initialize handshake
# ---------------------------------------------------------------------------
@lsp_initialize
Scenario: LSP initialize returns server info and capabilities
Given an initialize request with id 1
When the LSP server processes all messages
Then the response for id 1 should have result
And the result for id 1 should contain serverInfo with name "cleveragents-lsp-stub"
And the result for id 1 should contain serverInfo with version "0.1.0"
And the result for id 1 should contain capabilities key "textDocumentSync"
@lsp_initialize
Scenario: LSP initialize with client info is accepted
Given an initialize request with id 10 and clientInfo name "test-client"
When the LSP server processes all messages
Then the response for id 10 should have result
And the result for id 10 should contain serverInfo with name "cleveragents-lsp-stub"
@lsp_initialize
Scenario: Initialized notification is silently accepted
Given an initialize request with id 1
And a notification for method "initialized"
And an exit notification
When the LSP server processes all messages
Then the response for id 1 should have result
And there should be no error responses
@lsp_initialize
Scenario: Initialized notification before initialize is silently ignored
Given a notification for method "initialized"
And an exit notification
When the LSP server processes all messages
Then there should be no error responses
And the server exit code should be 1
# ---------------------------------------------------------------------------
# Shutdown handshake
# ---------------------------------------------------------------------------
@lsp_shutdown
Scenario: LSP shutdown returns null result
Given an initialize request with id 1
And a shutdown request with id 2
And an exit notification
When the LSP server processes all messages
Then the response for id 2 should have null result
And the server exit code should be 0
@lsp_shutdown
Scenario: LSP exit without prior shutdown returns exit code 1
Given an initialize request with id 1
And an exit notification
When the LSP server processes all messages
Then the server exit code should be 1
# ---------------------------------------------------------------------------
# Unsupported methods
# ---------------------------------------------------------------------------
@lsp_unsupported
Scenario: Unsupported method after initialize returns MethodNotFound error
Given an initialize request with id 1
And a request for method "textDocument/completion" with id 5
And an exit notification
When the LSP server processes all messages
Then the response for id 5 should have error code -32601
And the error message for id 5 should contain "Not implemented: textDocument/completion"
@lsp_unsupported
Scenario: Unsupported method textDocument/hover after initialize returns error
Given an initialize request with id 1
And a request for method "textDocument/hover" with id 6
And an exit notification
When the LSP server processes all messages
Then the response for id 6 should have error code -32601
And the error message for id 6 should contain "Not implemented: textDocument/hover"
@lsp_unsupported
Scenario: Unsupported method before initialize returns ServerNotInitialized
Given a request for method "textDocument/completion" with id 5
And an exit notification
When the LSP server processes all messages
Then the response for id 5 should have error code -32002
And the error message for id 5 should contain "Server not initialized"
@lsp_unsupported
Scenario: Unsupported notification is silently ignored
Given a notification for method "textDocument/didOpen"
And an exit notification
When the LSP server processes all messages
Then there should be no error responses
# ---------------------------------------------------------------------------
# Invalid JSON-RPC messages
# ---------------------------------------------------------------------------
@lsp_invalid
Scenario: Invalid JSON returns parse error response
Given raw bytes that are not valid JSON-RPC
And an exit notification
When the LSP server processes all messages
Then a parse error response should have been sent
And the server should have stopped
@lsp_invalid
Scenario: Missing jsonrpc version field returns error
Given a message with missing jsonrpc version and id 7
When the LSP server processes all messages
Then the response for id 7 should have error code -32600
And the error message for id 7 should contain "jsonrpc version"
@lsp_invalid
Scenario: Missing method field returns error
Given a message with missing method and id 8
When the LSP server processes all messages
Then the response for id 8 should have error code -32600
And the error message for id 8 should contain "method"
# ---------------------------------------------------------------------------
# Server lifecycle
# ---------------------------------------------------------------------------
@lsp_lifecycle
Scenario: Server startup reports PID
Given an initialize request with id 1
And an exit notification
When the LSP server processes all messages
Then the server should have logged its PID
@lsp_lifecycle
Scenario: Server handles EOF gracefully
When the LSP server processes all messages with empty input
Then the server should have stopped
And the server exit code should be 1
# ---------------------------------------------------------------------------
# ACP facade wiring
# ---------------------------------------------------------------------------
@lsp_acp
Scenario: LSP server stores provided ACP facade
Given an LSP server with ACP facade
And an initialize request with id 1
When the LSP server processes all messages
Then the response for id 1 should have result
And the result for id 1 should contain serverInfo with name "cleveragents-lsp-stub"
And the server should hold the provided ACP facade
@lsp_acp
Scenario: LSP server lazily creates ACP facade on property access
Given an initialize request with id 1
And an exit notification
When the LSP server processes all messages
Then the response for id 1 should have result
And the server facade should be lazily created
# ---------------------------------------------------------------------------
# Full handshake sequence
# ---------------------------------------------------------------------------
@lsp_full_handshake
Scenario: Complete LSP lifecycle (initialize, shutdown, exit)
Given an initialize request with id 1
And a shutdown request with id 2
And an exit notification
When the LSP server processes all messages
Then the response for id 1 should have result
And the response for id 2 should have null result
And the server exit code should be 0
# ---------------------------------------------------------------------------
# Server construction validation
# ---------------------------------------------------------------------------
@lsp_construction
Scenario: LspServer rejects invalid input_stream
When I try to create an LspServer with invalid input stream
Then a TypeError should be raised for input stream
@lsp_construction
Scenario: LspServer rejects invalid output_stream
When I try to create an LspServer with invalid output stream
Then a TypeError should be raised for output stream
# ---------------------------------------------------------------------------
# Transport edge cases
# ---------------------------------------------------------------------------
@lsp_transport_edge
Scenario: Server recovers from invalid message and handles subsequent request
Given a message with missing jsonrpc version and id 7
And an initialize request with id 1
And an exit notification
When the LSP server processes all messages
Then the response for id 7 should have error code -32600
And the response for id 1 should have result
And the result for id 1 should contain serverInfo with name "cleveragents-lsp-stub"
@lsp_transport_edge
Scenario: Request with non-dict params uses empty params
Given an initialize request with id 1
And a request with non-dict params and id 20
And an exit notification
When the LSP server processes all messages
Then the response for id 20 should have error code -32601
@lsp_transport_edge
Scenario: Negative content length is handled gracefully
Given a message with negative content length
When the LSP server processes all messages
Then the server should have stopped
And the server exit code should be 1
And a transport warning should have been logged for "lsp.transport.negative_content_length"
@lsp_transport_edge
Scenario: Incomplete message body is handled gracefully
Given a message with incomplete body
When the LSP server processes all messages
Then the server should have stopped
And the server exit code should be 1
And a transport warning should have been logged for "lsp.transport.incomplete_read"
@lsp_transport_edge
Scenario: Unknown method returns MethodNotFound without data field
Given an initialize request with id 1
And a request for method "custom/withData" with id 30
And an exit notification
When the LSP server processes all messages
Then the response for id 30 should have error code -32601
And the error response for id 30 should not contain a data field
@lsp_transport_edge
Scenario: Missing content length header is handled
Given a message with missing content length header
When the LSP server processes all messages
Then the server should have stopped
And the server exit code should be 1
And a transport warning should have been logged for "lsp.transport.missing_content_length"
@lsp_transport_edge
Scenario: Oversized content length is rejected
Given a message with content length exceeding the maximum
And an exit notification
When the LSP server processes all messages
Then the server should have stopped
And the server exit code should be 1
And a transport warning should have been logged for "lsp.transport.content_length_exceeded"
@lsp_transport_edge
Scenario: Too many header lines are rejected
Given a message with too many header lines
When the LSP server processes all messages
Then the server should have stopped
And the server exit code should be 1
And a transport warning should have been logged for "lsp.transport.too_many_header_lines"
@lsp_transport_edge
Scenario: JSON array body returns InvalidRequest error
Given a message with a JSON array body
And an exit notification
When the LSP server processes all messages
Then an InvalidRequest error response should have been sent
@lsp_transport_edge
Scenario: Non-integer content length is handled gracefully
Given a message with non-integer content length
When the LSP server processes all messages
Then the server should have stopped
And the server exit code should be 1
And a transport warning should have been logged for "lsp.transport.invalid_content_length"
@lsp_transport_edge
Scenario: Zero content length returns parse error
Given a message with content length zero
And an exit notification
When the LSP server processes all messages
Then a parse error response should have been sent
And the server should have stopped
@lsp_transport_edge
Scenario: Duplicate content-length headers uses last value
Given a message with duplicate content-length headers and id 60
And an exit notification
When the LSP server processes all messages
Then the response for id 60 should have result
And the result for id 60 should contain serverInfo with name "cleveragents-lsp-stub"
@lsp_transport_edge
Scenario: Shutdown without prior initialize returns ServerNotInitialized
Given a shutdown request with id 1
And an exit notification
When the LSP server processes all messages
Then the response for id 1 should have error code -32002
And the error message for id 1 should contain "Server not initialized"
@lsp_transport_edge
Scenario: Initialize without params key uses empty params
Given a message with initialize request without params key and id 50
And an exit notification
When the LSP server processes all messages
Then the response for id 50 should have result
And the result for id 50 should contain serverInfo with name "cleveragents-lsp-stub"
# ---------------------------------------------------------------------------
# Post-shutdown request rejection (LSP spec §3.16)
# ---------------------------------------------------------------------------
@lsp_shutdown
Scenario: Request after shutdown returns InvalidRequest error
Given an initialize request with id 1
And a shutdown request with id 2
And a request for method "textDocument/completion" with id 3
And an exit notification
When the LSP server processes all messages
Then the response for id 3 should have error code -32600
And the error message for id 3 should contain "shutting down"
@lsp_shutdown
Scenario: Notification after shutdown is silently ignored
Given an initialize request with id 1
And a shutdown request with id 2
And a notification for method "textDocument/didOpen"
And an exit notification
When the LSP server processes all messages
Then the response for id 1 should have result
And the response for id 2 should have null result
And there should be no error responses
And the server exit code should be 0
# ---------------------------------------------------------------------------
# Double initialize rejection
# ---------------------------------------------------------------------------
@lsp_initialize
Scenario: Second initialize request returns InvalidRequest error
Given an initialize request with id 1
And an initialize request with id 2
And an exit notification
When the LSP server processes all messages
Then the response for id 1 should have result
And the response for id 2 should have error code -32600
And the error message for id 2 should contain "already initialized"
# ---------------------------------------------------------------------------
# Stream desync recovery (transport error followed by valid message)
# ---------------------------------------------------------------------------
@lsp_transport_edge
Scenario: Server recovers after oversized message with body and processes next request
Given an oversized message with real body followed by an initialize request with id 1
And an exit notification
When the LSP server processes all messages
Then the response for id 1 should have result
And the result for id 1 should contain serverInfo with name "cleveragents-lsp-stub"
@lsp_transport_edge
Scenario: Server recovers after invalid content-length header and processes next request
Given a message with non-integer content length followed by an initialize request with id 1
And an exit notification
When the LSP server processes all messages
Then the response for id 1 should have result
And the result for id 1 should contain serverInfo with name "cleveragents-lsp-stub"
@lsp_transport_edge
Scenario: Server recovers after too many header lines and processes next request
Given a message with too many header lines followed by an initialize request with id 1
And an exit notification
When the LSP server processes all messages
Then the response for id 1 should have result
And the result for id 1 should contain serverInfo with name "cleveragents-lsp-stub"
@lsp_transport_edge
Scenario: EOF during body discard of oversized message is handled gracefully
Given an oversized message with a short body that triggers EOF during discard
When the LSP server processes all messages
Then the server should have stopped
And the server exit code should be 1
And a transport warning should have been logged for "lsp.transport.content_length_exceeded"
@lsp_transport_edge
Scenario: Oversized single header line is handled gracefully
Given a message with a header line exceeding the maximum line length
And an exit notification
When the LSP server processes all messages
Then the server should have stopped
And the server exit code should be 1
And a transport warning should have been logged for "lsp.transport.too_many_header_lines"
@lsp_transport_edge
Scenario: Deeply nested JSON triggers parse error response
Given a message with deeply nested JSON
And an exit notification
When the LSP server processes all messages
Then a parse error response should have been sent
And a transport warning should have been logged for "lsp.transport.json_recursion_error"
And the server should have stopped
# ---------------------------------------------------------------------------
# CLI serve command
# ---------------------------------------------------------------------------
@lsp_cli_serve
Scenario: CLI serve command rejects invalid log level
When I run lsp CLI serve with log level "banana"
Then the lsp serve CLI should have failed
And the lsp serve CLI output should contain "Invalid log level"
@lsp_cli_serve
Scenario: CLI serve command accepts valid log level and runs
When I run lsp CLI serve with piped empty stdin
Then the lsp serve CLI should have exited with code 1
And the lsp serve CLI output should contain "CleverAgents LSP Server"
@lsp_cli_serve
Scenario: CLI serve command with debug log level
When I run lsp CLI serve with log level "debug" and piped empty stdin
Then the lsp serve CLI should have exited with code 1
And the lsp serve CLI output should contain "CleverAgents LSP Server"
# ---------------------------------------------------------------------------
# Broken output stream (pipe failure)
# ---------------------------------------------------------------------------
@lsp_transport_edge
Scenario: Broken output stream stops the server gracefully
Given an initialize request with id 1
And an exit notification
When the LSP server processes all messages with a broken output stream
Then the server should have stopped
And the server exit code should be 1
And a transport warning should have been logged for "lsp.transport.write_error"
# ---------------------------------------------------------------------------
# String-valued JSON-RPC request id
# ---------------------------------------------------------------------------
@lsp_initialize
Scenario: String request id is echoed correctly
Given an initialize request with string id "alpha-1"
And an exit notification
When the LSP server processes all messages
Then the response for string id "alpha-1" should have result
And the result for string id "alpha-1" should contain serverInfo with name "cleveragents-lsp-stub"
# ---------------------------------------------------------------------------
# EOF after shutdown without exit notification
# ---------------------------------------------------------------------------
@lsp_shutdown
Scenario: EOF after shutdown without exit returns exit code 0
Given an initialize request with id 1
And a shutdown request with id 2
When the LSP server processes all messages
Then the response for id 2 should have null result
And the server exit code should be 0
+7 -1
View File
@@ -5,7 +5,13 @@ Following the mock placement rule, all mocks must exist only in the
features/ directory and never in production code (implementation_plan.md).
"""
from .lsp_transport_mock import MockLspTransport, parse_lsp_responses
from .mock_ai_provider import MockAIProvider
from .mock_mcp_transport import MockMCPTransport
__all__ = ["MockAIProvider", "MockMCPTransport"]
__all__ = [
"MockAIProvider",
"MockLspTransport",
"MockMCPTransport",
"parse_lsp_responses",
]
+158
View File
@@ -0,0 +1,158 @@
"""Mock stdin/stdout transport for LSP server testing.
Provides :class:`MockLspTransport` which simulates the
Content-Length framed JSON-RPC transport used by the LSP server
without requiring a real subprocess.
The :func:`parse_lsp_responses` utility is shared with the Robot
Framework helper to avoid duplicating response-parsing logic.
All mocks reside in ``features/mocks/`` per project conventions.
"""
from __future__ import annotations
import io
import json
from typing import Any
def parse_lsp_responses(raw: bytes) -> list[dict[str, Any]]:
"""Parse Content-Length framed JSON-RPC responses from raw bytes.
Shared utility used by :class:`MockLspTransport` and the Robot
Framework helper to avoid duplicating the parsing logic.
Args:
raw: The raw bytes containing one or more Content-Length
framed JSON-RPC response messages.
Returns:
List of parsed JSON-RPC response dicts.
"""
responses: list[dict[str, Any]] = []
offset = 0
while offset < len(raw):
header_end = raw.find(b"\r\n\r\n", offset)
if header_end == -1:
break
header_section = raw[offset:header_end].decode("utf-8", errors="replace")
content_length: int | None = None
for line in header_section.split("\r\n"):
if line.lower().startswith("content-length:"):
content_length = int(line.split(":", 1)[1].strip())
if content_length is None:
break
body_start = header_end + 4
body_end = body_start + content_length
body = raw[body_start:body_end]
responses.append(json.loads(body))
offset = body_end
return responses
class MockLspTransport:
"""Simulated LSP stdin/stdout binary streams.
Encodes JSON-RPC messages with Content-Length framing into a
binary input buffer and captures binary output for assertion.
Usage::
transport = MockLspTransport()
transport.send_request("initialize", {"capabilities": {}}, id=1)
server = LspServer(
input_stream=transport.input_stream,
output_stream=transport.output_stream,
)
server.run()
responses = transport.read_responses()
"""
def __init__(self) -> None:
self._input_buf = io.BytesIO()
self._output_buf = io.BytesIO()
def send_request(
self,
method: str,
params: dict[str, Any] | None = None,
request_id: int | str | None = 1,
) -> None:
"""Append a JSON-RPC request to the input buffer.
Args:
method: The JSON-RPC method name.
params: Optional parameters dict.
request_id: The request id (``None`` for notifications).
"""
msg: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
if request_id is not None:
msg["id"] = request_id
if params is not None:
msg["params"] = params
self._append_message(msg)
def send_notification(
self,
method: str,
params: dict[str, Any] | None = None,
) -> None:
"""Append a JSON-RPC notification (no id) to the input buffer.
Args:
method: The JSON-RPC method name.
params: Optional parameters dict.
"""
self.send_request(method, params, request_id=None)
def send_raw(self, raw_bytes: bytes) -> None:
"""Append raw bytes to the input buffer.
Useful for testing malformed messages.
Args:
raw_bytes: The raw bytes to append.
"""
pos = self._input_buf.tell()
self._input_buf.seek(0, 2)
self._input_buf.write(raw_bytes)
self._input_buf.seek(pos)
def _append_message(self, msg: dict[str, Any]) -> None:
"""Encode and append a Content-Length framed message."""
payload = json.dumps(msg).encode("utf-8")
header = f"Content-Length: {len(payload)}\r\n\r\n".encode()
pos = self._input_buf.tell()
self._input_buf.seek(0, 2)
self._input_buf.write(header + payload)
self._input_buf.seek(pos)
@property
def input_stream(self) -> io.BytesIO:
"""Return the input stream positioned at the start."""
self._input_buf.seek(0)
return self._input_buf
@property
def output_stream(self) -> io.BytesIO:
"""Return the output stream for writing."""
return self._output_buf
def read_responses(self) -> list[dict[str, Any]]:
"""Parse all JSON-RPC responses from the output buffer.
Returns:
List of parsed JSON-RPC response dicts.
"""
self._output_buf.seek(0)
raw = self._output_buf.read()
if not raw:
return []
return parse_lsp_responses(raw)
__all__ = [
"MockLspTransport",
"parse_lsp_responses",
]
+717
View File
@@ -0,0 +1,717 @@
"""Step definitions for LSP server stub BDD tests.
Covers the JSON-RPC transport, protocol handshake, error handling,
ACP facade wiring, and CLI serve command for the LSP server stub.
"""
from __future__ import annotations
import io
import json
from typing import Any
import structlog
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.acp.facade import AcpLocalFacade
from cleveragents.cli.commands.lsp import app as lsp_app
from cleveragents.lsp.server import (
MAX_CONTENT_LENGTH,
MAX_HEADER_LINE_LENGTH,
MAX_HEADER_LINES,
LspServer,
)
from features.mocks.lsp_transport_mock import MockLspTransport
_cli_runner = CliRunner()
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a mock LSP transport")
def step_mock_transport(context: Context) -> None:
context.lsp_transport = MockLspTransport()
context.lsp_server: LspServer | None = None
context.lsp_responses: list[dict[str, Any]] = []
context.lsp_exit_code: int | None = None
context.lsp_facade: AcpLocalFacade | None = None
context.lsp_error: Exception | None = None
# ---------------------------------------------------------------------------
# Given — messages
# ---------------------------------------------------------------------------
@given("an initialize request with id {req_id:d}")
def step_initialize_request(context: Context, req_id: int) -> None:
context.lsp_transport.send_request(
"initialize",
{"capabilities": {}},
request_id=req_id,
)
@given('an initialize request with id {req_id:d} and clientInfo name "{name}"')
def step_initialize_with_client_info(context: Context, req_id: int, name: str) -> None:
context.lsp_transport.send_request(
"initialize",
{"capabilities": {}, "clientInfo": {"name": name}},
request_id=req_id,
)
@given("a shutdown request with id {req_id:d}")
def step_shutdown_request(context: Context, req_id: int) -> None:
context.lsp_transport.send_request("shutdown", request_id=req_id)
@given("an exit notification")
def step_exit_notification(context: Context) -> None:
context.lsp_transport.send_notification("exit")
@given('a request for method "{method}" with id {req_id:d}')
def step_request_method(context: Context, method: str, req_id: int) -> None:
context.lsp_transport.send_request(method, {}, request_id=req_id)
@given('a notification for method "{method}"')
def step_notification_method(context: Context, method: str) -> None:
context.lsp_transport.send_notification(method)
@given("raw bytes that are not valid JSON-RPC")
def step_raw_invalid_bytes(context: Context) -> None:
bad_payload = b"this is not json at all"
framed = f"Content-Length: {len(bad_payload)}\r\n\r\n".encode() + bad_payload
context.lsp_transport.send_raw(framed)
@given("a message with missing jsonrpc version and id {req_id:d}")
def step_missing_jsonrpc_version(context: Context, req_id: int) -> None:
msg = {"id": req_id, "method": "test"}
payload = json.dumps(msg).encode("utf-8")
framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload
context.lsp_transport.send_raw(framed)
@given("a message with missing method and id {req_id:d}")
def step_missing_method(context: Context, req_id: int) -> None:
msg = {"jsonrpc": "2.0", "id": req_id}
payload = json.dumps(msg).encode("utf-8")
framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload
context.lsp_transport.send_raw(framed)
@given("an LSP server with ACP facade")
def step_server_with_facade(context: Context) -> None:
context.lsp_facade = AcpLocalFacade()
# ---------------------------------------------------------------------------
# When — server execution
# ---------------------------------------------------------------------------
@when("the LSP server processes all messages")
def step_process_messages(context: Context) -> None:
facade = getattr(context, "lsp_facade", None)
server = LspServer(
input_stream=context.lsp_transport.input_stream,
output_stream=context.lsp_transport.output_stream,
facade=facade,
)
context.lsp_server = server
with structlog.testing.capture_logs() as captured:
context.lsp_exit_code = server.run()
context.lsp_captured_logs = captured
context.lsp_responses = context.lsp_transport.read_responses()
@when("the LSP server processes all messages with empty input")
def step_process_empty(context: Context) -> None:
empty_input = io.BytesIO(b"")
output_buf = io.BytesIO()
server = LspServer(input_stream=empty_input, output_stream=output_buf)
context.lsp_server = server
context.lsp_exit_code = server.run()
context.lsp_responses = []
@when("the LSP server processes all messages with a broken output stream")
def step_process_broken_output(context: Context) -> None:
"""Run the server with an output stream that raises OSError on write."""
class _BrokenOutput:
"""Mock output stream that raises on any write."""
def write(self, _data: bytes) -> int:
raise OSError("broken pipe")
def flush(self) -> None:
raise OSError("broken pipe")
server = LspServer(
input_stream=context.lsp_transport.input_stream,
output_stream=_BrokenOutput(), # type: ignore[arg-type]
)
context.lsp_server = server
with structlog.testing.capture_logs() as captured:
context.lsp_exit_code = server.run()
context.lsp_captured_logs = captured
context.lsp_responses = []
@when("I try to create an LspServer with invalid input stream")
def step_invalid_input_stream(context: Context) -> None:
try:
LspServer(input_stream="not a stream") # type: ignore[arg-type]
context.lsp_error = None
except TypeError as exc:
context.lsp_error = exc
@when("I try to create an LspServer with invalid output stream")
def step_invalid_output_stream(context: Context) -> None:
try:
LspServer(output_stream=42) # type: ignore[arg-type]
context.lsp_error = None
except TypeError as exc:
context.lsp_error = exc
# ---------------------------------------------------------------------------
# Then — response assertions
# ---------------------------------------------------------------------------
def _find_response(context: Context, req_id: int) -> dict[str, Any]:
for resp in context.lsp_responses:
if resp.get("id") == req_id:
return resp
raise AssertionError(
f"No response with id={req_id} found in {context.lsp_responses}"
)
def _find_response_any(
context: Context,
req_id: int | str,
) -> dict[str, Any]:
"""Find a response by id, supporting both int and str ids."""
for resp in context.lsp_responses:
if resp.get("id") == req_id:
return resp
raise AssertionError(
f"No response with id={req_id!r} found in {context.lsp_responses}"
)
@then("the response for id {req_id:d} should have result")
def step_response_has_result(context: Context, req_id: int) -> None:
resp = _find_response(context, req_id)
assert "result" in resp, f"Response {resp} missing 'result'"
assert resp["result"] is not None, f"Response result is None: {resp}"
@then("the response for id {req_id:d} should have null result")
def step_response_null_result(context: Context, req_id: int) -> None:
resp = _find_response(context, req_id)
assert "result" in resp, f"Response {resp} missing 'result'"
assert resp["result"] is None, f"Expected null result, got {resp['result']}"
@then('the result for id {req_id:d} should contain serverInfo with name "{name}"')
def step_result_server_name(context: Context, req_id: int, name: str) -> None:
resp = _find_response(context, req_id)
result = resp.get("result", {})
server_info = result.get("serverInfo", {})
assert server_info.get("name") == name, (
f"Expected serverInfo.name={name}, got {server_info}"
)
@then('the result for id {req_id:d} should contain serverInfo with version "{version}"')
def step_result_server_version(context: Context, req_id: int, version: str) -> None:
resp = _find_response(context, req_id)
result = resp.get("result", {})
server_info = result.get("serverInfo", {})
assert server_info.get("version") == version, (
f"Expected version={version}, got {server_info}"
)
@then('the result for id {req_id:d} should contain capabilities key "{key}"')
def step_result_capabilities_key(context: Context, req_id: int, key: str) -> None:
resp = _find_response(context, req_id)
result = resp.get("result", {})
caps = result.get("capabilities", {})
assert key in caps, f"Capabilities missing key '{key}': {caps}"
@then("the response for id {req_id:d} should have error code {code:d}")
def step_response_error_code(context: Context, req_id: int, code: int) -> None:
resp = _find_response(context, req_id)
assert "error" in resp, f"Response {resp} missing 'error'"
assert resp["error"]["code"] == code, (
f"Expected error code {code}, got {resp['error']['code']}"
)
@then('the error message for id {req_id:d} should contain "{text}"')
def step_error_message_for_id(context: Context, req_id: int, text: str) -> None:
resp = _find_response(context, req_id)
error = resp.get("error")
assert error is not None, f"Response for id={req_id} has no error: {resp}"
msg = error.get("message", "")
assert text in msg, (
f"Error message for id={req_id} does not contain '{text}': {msg}"
)
@then("a parse error response should have been sent")
def step_parse_error_sent(context: Context) -> None:
errors = [
r
for r in context.lsp_responses
if "error" in r and r["error"].get("code") == -32700
]
assert errors, f"No parse error (-32700) response found in {context.lsp_responses}"
@then("there should be no error responses")
def step_no_error_responses(context: Context) -> None:
errors = [r for r in context.lsp_responses if "error" in r]
assert not errors, f"Unexpected error responses: {errors}"
@then("the server exit code should be {code:d}")
def step_exit_code(context: Context, code: int) -> None:
assert context.lsp_exit_code == code, (
f"Expected exit code {code}, got {context.lsp_exit_code}"
)
@then("the server should have stopped")
def step_server_stopped(context: Context) -> None:
assert context.lsp_server is not None, "No server was created"
assert context.lsp_server.is_running is False, "Server is still running"
@then('a transport warning should have been logged for "{event}"')
def step_transport_warning_logged(context: Context, event: str) -> None:
captured = getattr(context, "lsp_captured_logs", [])
matches = [
e
for e in captured
if e.get("event") == event and e.get("log_level", "") == "warning"
]
assert matches, f"No warning log with event={event!r} found in {captured}"
@then("the server should have logged its PID")
def step_server_logged_pid(context: Context) -> None:
assert context.lsp_server is not None, "No server was created"
assert context.lsp_exit_code is not None, "Server did not complete"
captured = getattr(context, "lsp_captured_logs", [])
pid_entries = [
e for e in captured if "pid" in e and e.get("event") == "lsp.server.starting"
]
assert pid_entries, (
f"No log entry with PID found for 'lsp.server.starting' in {captured}"
)
assert isinstance(pid_entries[0]["pid"], int), "PID should be an integer"
@then("an InvalidRequest error response should have been sent")
def step_invalid_request_sent(context: Context) -> None:
errors = [
r
for r in context.lsp_responses
if "error" in r and r["error"].get("code") == -32600
]
assert errors, (
f"No InvalidRequest (-32600) response found in {context.lsp_responses}"
)
@then("the error response for id {req_id:d} should not contain a data field")
def step_error_no_data(context: Context, req_id: int) -> None:
resp = _find_response(context, req_id)
assert "error" in resp, f"Response {resp} missing 'error'"
assert "data" not in resp["error"], (
f"Error response unexpectedly contains 'data': {resp['error']}"
)
@then("the server should hold the provided ACP facade")
def step_server_holds_facade(context: Context) -> None:
assert context.lsp_server is not None, "No server was created"
assert context.lsp_facade is not None, "No facade was provided"
assert context.lsp_server.facade is context.lsp_facade, (
"Server does not hold the provided ACP facade instance"
)
@then("the server facade should be lazily created")
def step_server_facade_lazy(context: Context) -> None:
assert context.lsp_server is not None, "No server was created"
# Access the public property — this triggers lazy creation
facade = context.lsp_server.facade
assert facade is not None, "Facade was not lazily created"
from cleveragents.acp.facade import AcpLocalFacade
assert isinstance(facade, AcpLocalFacade), (
f"Expected AcpLocalFacade, got {type(facade).__name__}"
)
@then("a TypeError should be raised for input stream")
def step_type_error_input(context: Context) -> None:
assert context.lsp_error is not None, "Expected TypeError"
assert isinstance(context.lsp_error, TypeError), (
f"Expected TypeError, got {type(context.lsp_error).__name__}"
)
assert "input_stream" in str(context.lsp_error)
@then("a TypeError should be raised for output stream")
def step_type_error_output(context: Context) -> None:
assert context.lsp_error is not None, "Expected TypeError"
assert isinstance(context.lsp_error, TypeError), (
f"Expected TypeError, got {type(context.lsp_error).__name__}"
)
assert "output_stream" in str(context.lsp_error)
# ---------------------------------------------------------------------------
# Transport edge case steps
# ---------------------------------------------------------------------------
@given("a request with non-dict params and id {req_id:d}")
def step_non_dict_params(context: Context, req_id: int) -> None:
msg: dict[str, Any] = {
"jsonrpc": "2.0",
"id": req_id,
"method": "textDocument/completion",
"params": "not-a-dict",
}
payload = json.dumps(msg).encode("utf-8")
framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload
context.lsp_transport.send_raw(framed)
@given("a message with a JSON array body")
def step_json_array_body(context: Context) -> None:
# Send a valid JSON array instead of a JSON object so that
# _handle_message's isinstance(msg, dict) guard is exercised.
payload = json.dumps([1, "not", "a", "dict"]).encode("utf-8")
framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload
context.lsp_transport.send_raw(framed)
@given("a message with non-integer content length")
def step_non_integer_content_length(context: Context) -> None:
raw = b"Content-Length: abc\r\n\r\n"
context.lsp_transport.send_raw(raw)
@given("a message with initialize request without params key and id {req_id:d}")
def step_initialize_no_params(context: Context, req_id: int) -> None:
msg: dict[str, Any] = {
"jsonrpc": "2.0",
"id": req_id,
"method": "initialize",
}
payload = json.dumps(msg).encode("utf-8")
framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload
context.lsp_transport.send_raw(framed)
@given("a message with negative content length")
def step_negative_content_length(context: Context) -> None:
raw = b"Content-Length: -5\r\n\r\n"
context.lsp_transport.send_raw(raw)
@given("a message with incomplete body")
def step_incomplete_body(context: Context) -> None:
# Declare 100 bytes but only provide 5
raw = b"Content-Length: 100\r\n\r\nhello"
context.lsp_transport.send_raw(raw)
@given("a message with missing content length header")
def step_missing_content_length(context: Context) -> None:
raw = b"Content-Type: application/json\r\n\r\n{}"
context.lsp_transport.send_raw(raw)
@given("a message with content length exceeding the maximum")
def step_oversized_content_length(context: Context) -> None:
# Declare a Content-Length above the limit. The *actual* body is
# ``MAX_CONTENT_LENGTH`` bytes — matching the server's capped
# discard — so ``_discard_body`` consumes the entire body and
# subsequent messages (the exit notification) are not corrupted.
declared_length = MAX_CONTENT_LENGTH + 64
body = b"X" * MAX_CONTENT_LENGTH
raw = f"Content-Length: {declared_length}\r\n\r\n".encode() + body
context.lsp_transport.send_raw(raw)
@given("a message with content length zero")
def step_zero_content_length(context: Context) -> None:
raw = b"Content-Length: 0\r\n\r\n"
context.lsp_transport.send_raw(raw)
@given("a message with duplicate content-length headers and id {req_id:d}")
def step_duplicate_content_length(context: Context, req_id: int) -> None:
# Build an initialize request. The first Content-Length is wrong
# (too small), but the server takes the *last* value so the
# message is parsed correctly.
msg: dict[str, Any] = {
"jsonrpc": "2.0",
"id": req_id,
"method": "initialize",
"params": {"capabilities": {}},
}
payload = json.dumps(msg).encode("utf-8")
header = (f"Content-Length: 1\r\nContent-Length: {len(payload)}\r\n\r\n").encode()
context.lsp_transport.send_raw(header + payload)
@given("a message with deeply nested JSON")
def step_deeply_nested_json(context: Context) -> None:
"""Build a Content-Length framed message whose body is a deeply nested
JSON array (10 000 levels). When ``json.loads`` attempts to parse
this payload it triggers a ``RecursionError``, exercising the guard
in ``_read_message``.
"""
depth = 10000
payload = ("[" * depth + "]" * depth).encode("utf-8")
framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload
context.lsp_transport.send_raw(framed)
@given("an oversized message with a short body that triggers EOF during discard")
def step_oversized_short_body_eof(context: Context) -> None:
"""Build a message whose declared CL exceeds the limit but whose actual
body is much shorter than the discard cap. This forces
``_discard_body`` to hit EOF mid-read (the early-exit ``break``
inside ``LspServer._discard_body``'s read loop —
``cleveragents.lsp.server.LspServer._discard_body``), covering
the early-exit path.
"""
declared_length = MAX_CONTENT_LENGTH + 64
# Only provide a tiny body — far less than the capped discard amount
body = b"X" * 128
raw = f"Content-Length: {declared_length}\r\n\r\n".encode() + body
context.lsp_transport.send_raw(raw)
@given("a message with too many header lines")
def step_too_many_header_lines(context: Context) -> None:
lines = [f"X-Spam-{i}: value\r\n" for i in range(MAX_HEADER_LINES + 1)]
raw = "".join(lines).encode()
context.lsp_transport.send_raw(raw)
@given("a message with a header line exceeding the maximum line length")
def step_oversized_header_line(context: Context) -> None:
"""Build a single header line that exceeds ``MAX_HEADER_LINE_LENGTH``.
When ``readline(MAX_HEADER_LINE_LENGTH)`` is used the oversized
line is returned in multiple chunks, each consuming one slot in
the ``MAX_HEADER_LINES`` budget. Eventually the header-flood
guard fires, exercising the ``readline()`` per-line size cap
path described in ``cleveragents.lsp.server.LspServer._read_message``.
"""
# A single line without a newline that far exceeds the per-line
# limit. ``readline(MAX_HEADER_LINE_LENGTH)`` will return it in
# chunks of MAX_HEADER_LINE_LENGTH bytes until MAX_HEADER_LINES
# is exceeded.
oversized_line = b"X-Huge: " + b"A" * (
MAX_HEADER_LINE_LENGTH * (MAX_HEADER_LINES + 2)
)
context.lsp_transport.send_raw(oversized_line)
# ---------------------------------------------------------------------------
# Stream desync recovery steps
# ---------------------------------------------------------------------------
@given(
"an oversized message with real body followed by"
" an initialize request with id {req_id:d}"
)
def step_oversized_with_body_then_init(context: Context, req_id: int) -> None:
"""Build an oversized message with actual body bytes and append a valid init.
The declared Content-Length exceeds the limit so the server rejects the
message. The *actual* body is ``MAX_CONTENT_LENGTH`` bytes matching
the server's capped discard — so that ``_discard_body`` consumes
exactly the available body and the stream stays aligned for the next
message.
"""
declared_length = MAX_CONTENT_LENGTH + 64
# Actual body matches the discard cap so recovery succeeds.
body = b"X" * MAX_CONTENT_LENGTH
header = f"Content-Length: {declared_length}\r\n\r\n".encode()
raw = header + body
# Append the valid initialize request after the oversized body.
init_msg: dict[str, Any] = {
"jsonrpc": "2.0",
"id": req_id,
"method": "initialize",
"params": {"capabilities": {}},
}
init_payload = json.dumps(init_msg).encode("utf-8")
init_framed = f"Content-Length: {len(init_payload)}\r\n\r\n".encode() + init_payload
context.lsp_transport.send_raw(raw + init_framed)
@given(
"a message with non-integer content length followed by"
" an initialize request with id {req_id:d}"
)
def step_invalid_cl_then_init(context: Context, req_id: int) -> None:
"""Build a message with invalid CL header followed by valid init."""
# Invalid CL header + additional header + blank line (body-less)
bad_raw = b"Content-Length: abc\r\nX-Extra: foo\r\n\r\n"
init_msg: dict[str, Any] = {
"jsonrpc": "2.0",
"id": req_id,
"method": "initialize",
"params": {"capabilities": {}},
}
init_payload = json.dumps(init_msg).encode("utf-8")
init_framed = f"Content-Length: {len(init_payload)}\r\n\r\n".encode() + init_payload
context.lsp_transport.send_raw(bad_raw + init_framed)
@given(
"a message with too many header lines followed by"
" an initialize request with id {req_id:d}"
)
def step_too_many_headers_then_init(context: Context, req_id: int) -> None:
"""Build a message with excess headers + blank terminator, then valid init."""
lines = [f"X-Spam-{i}: value\r\n" for i in range(MAX_HEADER_LINES + 1)]
# Append the blank line terminator so _drain_headers can reach it.
lines.append("\r\n")
bad_raw = "".join(lines).encode()
init_msg: dict[str, Any] = {
"jsonrpc": "2.0",
"id": req_id,
"method": "initialize",
"params": {"capabilities": {}},
}
init_payload = json.dumps(init_msg).encode("utf-8")
init_framed = f"Content-Length: {len(init_payload)}\r\n\r\n".encode() + init_payload
context.lsp_transport.send_raw(bad_raw + init_framed)
# ---------------------------------------------------------------------------
# CLI serve command steps
# ---------------------------------------------------------------------------
@when('I run lsp CLI serve with log level "{level}"')
def step_run_lsp_serve_bad_level(context: Context, level: str) -> None:
result = _cli_runner.invoke(lsp_app, ["serve", "--log-level", level])
context.lsp_cli_result = result
@when("I run lsp CLI serve with piped empty stdin")
def step_run_lsp_serve_empty_stdin(context: Context) -> None:
result = _cli_runner.invoke(lsp_app, ["serve"], input="")
context.lsp_cli_result = result
@when('I run lsp CLI serve with log level "{level}" and piped empty stdin')
def step_run_lsp_serve_level_empty_stdin(context: Context, level: str) -> None:
result = _cli_runner.invoke(lsp_app, ["serve", "--log-level", level], input="")
context.lsp_cli_result = result
@then("the lsp serve CLI should have failed")
def step_lsp_serve_failed(context: Context) -> None:
result = context.lsp_cli_result
assert result is not None, "No CLI result captured"
assert result.exit_code != 0, (
f"Expected non-zero exit code, got {result.exit_code}. Output:\n{result.output}"
)
@then("the lsp serve CLI should have exited")
def step_lsp_serve_exited(context: Context) -> None:
result = context.lsp_cli_result
assert result is not None, "No CLI result captured"
# The server exits with code 0 (clean shutdown) or 1 (no shutdown sent).
assert result.exit_code in (0, 1), (
f"Unexpected exit code {result.exit_code}. Output:\n{result.output}"
)
@then("the lsp serve CLI should have exited with code {code:d}")
def step_lsp_serve_exited_with_code(context: Context, code: int) -> None:
result = context.lsp_cli_result
assert result is not None, "No CLI result captured"
assert result.exit_code == code, (
f"Expected exit code {code}, got {result.exit_code}. Output:\n{result.output}"
)
@then('the lsp serve CLI output should contain "{text}"')
def step_lsp_serve_output_contains(context: Context, text: str) -> None:
# NOTE: The serve command writes its startup banner to stderr via
# ``_serve_console = Console(stderr=True)``. This assertion checks
# ``result.output`` (stdout) which works because ``CliRunner()``
# defaults to ``mix_stderr=True``, merging stderr into stdout.
result = context.lsp_cli_result
assert result is not None, "No CLI result captured"
assert text in result.output, f"Expected '{text}' in output, got:\n{result.output}"
# ---------------------------------------------------------------------------
# String request id steps (JSON-RPC 2.0 allows string ids)
# ---------------------------------------------------------------------------
@given('an initialize request with string id "{req_id}"')
def step_initialize_string_id(context: Context, req_id: str) -> None:
context.lsp_transport.send_request(
"initialize",
{"capabilities": {}},
request_id=req_id,
)
@then('the response for string id "{req_id}" should have result')
def step_response_string_id_has_result(context: Context, req_id: str) -> None:
resp = _find_response_any(context, req_id)
assert "result" in resp, f"Response {resp} missing 'result'"
assert resp["result"] is not None, f"Response result is None: {resp}"
@then(
'the result for string id "{req_id}" should contain serverInfo with name "{name}"'
)
def step_result_string_id_server_name(
context: Context,
req_id: str,
name: str,
) -> None:
resp = _find_response_any(context, req_id)
result = resp.get("result", {})
server_info = result.get("serverInfo", {})
assert server_info.get("name") == name, (
f"Expected serverInfo.name={name}, got {server_info}"
)
+239
View File
@@ -0,0 +1,239 @@
"""Helper script for Robot Framework LSP stub smoke tests.
Usage:
python helper_lsp_stub.py <command>
Commands:
initialize Send initialize request and verify response
shutdown Full shutdown handshake
unsupported Test unsupported method error
startup-pid Verify startup PID reporting
full-lifecycle Complete initialize/shutdown/exit lifecycle
"""
from __future__ import annotations
import io
import json
import sys
from pathlib import Path
# Ensure src is on the path. Robot helpers are invoked as standalone
# scripts via ``Run Process`` so the project package is not always
# importable. This sys.path manipulation is required for that use
# case. If the project is installed in editable mode this insert is
# harmless (the installed entry-point takes precedence).
src_dir = str(Path(__file__).resolve().parents[1] / "src")
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
from cleveragents.lsp.server import LspServer # noqa: E402
# Import the shared response-parsing utility from the test mocks
# package so that parsing logic is defined in a single place.
# Same sys.path rationale as above — needed for standalone execution.
features_dir = str(Path(__file__).resolve().parents[1])
if features_dir not in sys.path:
sys.path.insert(0, features_dir)
from features.mocks.lsp_transport_mock import parse_lsp_responses # noqa: E402
def _encode_message(msg: dict) -> bytes:
"""Encode a JSON-RPC message with Content-Length framing."""
payload = json.dumps(msg).encode("utf-8")
header = f"Content-Length: {len(payload)}\r\n\r\n".encode()
return header + payload
def _run_server(messages: list[dict]) -> tuple[int, list[dict]]:
"""Run LSP server with the given messages and return (exit_code, responses)."""
raw = b"".join(_encode_message(msg) for msg in messages)
input_stream = io.BytesIO(raw)
output_stream = io.BytesIO()
server = LspServer(input_stream=input_stream, output_stream=output_stream)
exit_code = server.run()
output_stream.seek(0)
responses = parse_lsp_responses(output_stream.read())
return exit_code, responses
def cmd_initialize() -> None:
"""Test initialize handshake."""
_, responses = _run_server(
[
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"capabilities": {}},
},
{"jsonrpc": "2.0", "method": "exit"},
]
)
assert len(responses) >= 1, f"Expected at least 1 response, got {len(responses)}"
result = responses[0].get("result", {})
server_info = result.get("serverInfo", {})
assert server_info.get("name") == "cleveragents-lsp-stub", (
f"Unexpected serverInfo: {server_info}"
)
assert "capabilities" in result, "Missing capabilities"
print("lsp-initialize-ok")
def cmd_shutdown() -> None:
"""Test shutdown handshake."""
exit_code, responses = _run_server(
[
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"capabilities": {}},
},
{"jsonrpc": "2.0", "id": 2, "method": "shutdown"},
{"jsonrpc": "2.0", "method": "exit"},
]
)
assert exit_code == 0, f"Expected exit code 0, got {exit_code}"
shutdown_resp = None
for r in responses:
if r.get("id") == 2:
shutdown_resp = r
break
assert shutdown_resp is not None, "No shutdown response"
assert shutdown_resp.get("result") is None, "Shutdown result should be null"
print("lsp-shutdown-ok")
def cmd_unsupported() -> None:
"""Test unsupported method error after initialize."""
_, responses = _run_server(
[
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"capabilities": {}},
},
{
"jsonrpc": "2.0",
"id": 2,
"method": "textDocument/completion",
"params": {},
},
{"jsonrpc": "2.0", "method": "exit"},
]
)
assert len(responses) >= 2, f"Expected at least 2 responses, got {len(responses)}"
# Find the completion response (id=2)
completion_resp = None
for r in responses:
if r.get("id") == 2:
completion_resp = r
break
assert completion_resp is not None, "No response for textDocument/completion"
error = completion_resp.get("error", {})
assert error.get("code") == -32601, f"Expected -32601, got {error.get('code')}"
assert "Not implemented" in error.get("message", ""), (
f"Unexpected error message: {error.get('message')}"
)
print("lsp-unsupported-ok")
def cmd_startup_pid() -> None:
"""Test that server starts and processes messages (PID reported via logging).
Validates PID plausibility (> 0) by capturing the structlog
``lsp.server.starting`` event emitted during ``run()``.
"""
import structlog
exit_code: int
responses: list[dict]
messages = [
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"capabilities": {}},
},
{"jsonrpc": "2.0", "method": "exit"},
]
raw = b"".join(_encode_message(msg) for msg in messages)
input_stream = io.BytesIO(raw)
output_stream = io.BytesIO()
server = LspServer(input_stream=input_stream, output_stream=output_stream)
with structlog.testing.capture_logs() as captured:
exit_code = server.run()
output_stream.seek(0)
responses = parse_lsp_responses(output_stream.read())
assert len(responses) >= 1, "Server should produce responses"
# exit_code is 1 because no shutdown was sent
assert exit_code == 1, f"Expected exit code 1 (no shutdown), got {exit_code}"
# Validate PID plausibility: the server must log its PID as a
# positive integer in the ``lsp.server.starting`` event.
pid_entries = [
e for e in captured if e.get("event") == "lsp.server.starting" and "pid" in e
]
assert pid_entries, (
f"No 'lsp.server.starting' log with PID found in captured logs: {captured}"
)
logged_pid = pid_entries[0]["pid"]
assert isinstance(logged_pid, int), f"PID should be int, got {type(logged_pid)}"
assert logged_pid > 0, f"PID should be > 0, got {logged_pid}"
print("lsp-startup-pid-ok")
def _find_response_by_id(responses: list[dict], req_id: int) -> dict:
"""Find a response by its JSON-RPC id."""
for r in responses:
if r.get("id") == req_id:
return r
raise AssertionError(f"No response with id={req_id} in {responses}")
def cmd_full_lifecycle() -> None:
"""Test complete lifecycle."""
exit_code, responses = _run_server(
[
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"capabilities": {}},
},
{"jsonrpc": "2.0", "id": 2, "method": "shutdown"},
{"jsonrpc": "2.0", "method": "exit"},
]
)
assert exit_code == 0, f"Expected exit code 0, got {exit_code}"
assert len(responses) == 2, f"Expected 2 responses, got {len(responses)}"
# Verify initialize response (by id, not index)
init_resp = _find_response_by_id(responses, 1)
assert "capabilities" in init_resp.get("result", {})
# Verify shutdown response (by id, not index)
shutdown_resp = _find_response_by_id(responses, 2)
assert shutdown_resp.get("result") is None
print("lsp-full-lifecycle-ok")
COMMANDS = {
"initialize": cmd_initialize,
"shutdown": cmd_shutdown,
"unsupported": cmd_unsupported,
"startup-pid": cmd_startup_pid,
"full-lifecycle": cmd_full_lifecycle,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(COMMANDS)}>", file=sys.stderr)
sys.exit(1)
COMMANDS[sys.argv[1]]()
+49
View File
@@ -0,0 +1,49 @@
*** Settings ***
Documentation Smoke tests for LSP server stub startup and protocol
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_lsp_stub.py
*** Test Cases ***
LSP Server Initialize Handshake
[Documentation] Send initialize request and verify response
${result}= Run Process ${PYTHON} ${HELPER} initialize cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} lsp-initialize-ok
LSP Server Shutdown Handshake
[Documentation] Send initialize, shutdown, exit and verify clean exit
${result}= Run Process ${PYTHON} ${HELPER} shutdown cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} lsp-shutdown-ok
LSP Server Unsupported Method
[Documentation] Send unsupported method and verify MethodNotFound error
${result}= Run Process ${PYTHON} ${HELPER} unsupported cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} lsp-unsupported-ok
LSP Server Startup Reports PID
[Documentation] Verify server startup reports PID
${result}= Run Process ${PYTHON} ${HELPER} startup-pid cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} lsp-startup-pid-ok
LSP Server Full Lifecycle
[Documentation] Complete lifecycle: initialize, shutdown, exit
${result}= Run Process ${PYTHON} ${HELPER} full-lifecycle cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} lsp-full-lifecycle-ok
+94 -1
View File
@@ -45,9 +45,12 @@ task M2.7.lsp-stubs.
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Annotated, Any
import structlog
import typer
import yaml
from pydantic import ValidationError as PydanticValidationError
@@ -112,7 +115,6 @@ def add(
"--config",
"-c",
help="Path to the LSP server YAML configuration file",
exists=False,
),
],
update: Annotated[
@@ -134,6 +136,11 @@ def add(
agents lsp add --config ./lsp/pyright.yaml --update
"""
try:
# Typer's ``exists`` parameter defaults to ``False`` (no
# automatic file-existence validation). The manual check
# below is deliberate: it provides a user-friendly Rich
# error message instead of Typer's default traceback-style
# ``FileNotFoundError``.
if not config.exists():
console.print(f"[red]Config file error:[/red] {config} not found")
raise typer.Abort()
@@ -327,3 +334,89 @@ def show(
console.print(Panel("\n".join(env_lines), title="Environment", expand=False))
console.print("[green]OK[/green] LSP server loaded")
_VALID_LOG_LEVELS = ("debug", "info", "warning", "error")
# Dedicated stderr console for the serve command so that the
# startup banner does not corrupt the JSON-RPC transport on stdout.
_serve_console = Console(stderr=True)
@app.command("serve")
def serve(
log_level: Annotated[
str,
typer.Option(
"--log-level",
help="Logging level: debug, info, warning, error (default: info)",
),
] = "info",
) -> None:
"""Launch the stub LSP server over stdin/stdout.
The server speaks JSON-RPC with Content-Length header framing
(the standard LSP base protocol transport). It supports the
``initialize``, ``shutdown``, and ``exit`` lifecycle methods.
All other LSP methods return a MethodNotFound error.
Examples:
agents lsp serve
agents lsp serve --log-level debug
"""
normalised = log_level.lower()
if normalised not in _VALID_LOG_LEVELS:
_serve_console.print(
f"[red]ERROR[/red] Invalid log level '{log_level}'. "
f"Valid levels: {', '.join(_VALID_LOG_LEVELS)}"
)
raise typer.Exit(code=1)
level = getattr(logging, normalised.upper(), logging.INFO)
previous_config: dict[str, Any] = dict(structlog.get_config())
exit_code = 1
try:
structlog.configure(
wrapper_class=structlog.make_filtering_bound_logger(level),
)
log = structlog.get_logger("cleveragents.lsp.cli")
pid = os.getpid()
log.info("lsp.serve.startup", pid=pid, log_level=normalised)
_serve_console.print(
"[bold cyan]CleverAgents LSP Server[/bold cyan]"
f" (stub) starting — PID {pid}"
)
_serve_console.print(
f" Log level: {normalised}\n"
" Transport: stdin/stdout (JSON-RPC, Content-Length framing)\n"
" Supported methods: initialize, shutdown, exit\n"
" All other methods → MethodNotFound (-32601)"
)
# Lazy import: LspServer transitively imports AcpLocalFacade
# which pulls the full ACP dependency chain (~200ms cold).
# Non-serve subcommands (add, remove, list, show) do not
# need LspServer, so deferring the import avoids that cost
# for the common case.
from cleveragents.lsp.server import LspServer
server = LspServer()
exit_code = server.run()
log.info("lsp.serve.stopped", pid=pid, exit_code=exit_code)
finally:
# Restore the previous structlog configuration. Note:
# ``structlog.configure()`` unconditionally sets
# ``_CONFIG.is_configured = True`` internally, and
# ``get_config()`` does not capture this flag. If structlog
# was using defaults (not yet configured) before ``serve()``
# was called, subsequent ``configure_once()`` invocations
# will silently refuse to apply their configuration because
# they see ``is_configured == True``. There are currently
# zero ``configure_once()`` calls in this codebase so the
# impact is latent. Accept this limitation rather than
# calling ``reset_defaults()`` which would discard any
# processor chain the caller set up.
structlog.configure(**previous_config)
raise typer.Exit(code=exit_code)
+10 -4
View File
@@ -1,19 +1,23 @@
"""LSP (Language Server Protocol) integration package.
Provides the registry, runtime stub, tool adapter, and domain models
for managing language servers within CleverAgents.
Provides the registry, runtime stub, tool adapter, server stub,
and domain models for managing language servers within CleverAgents.
In **local mode** the :class:`LspRuntime` and :class:`LspToolAdapter`
are stubs: the runtime raises :class:`LspNotAvailableError` for every
operation and the tool adapter generates tool specs whose handlers
also raise immediately. When server mode is implemented the concrete
classes will replace these stubs while preserving the public API.
also raise immediately. The :class:`LspServer` provides a minimal
JSON-RPC server stub over stdin/stdout.
When server mode is implemented the concrete classes will replace
these stubs while preserving the public API.
Usage::
from cleveragents.lsp import (
LspRegistry,
LspRuntime,
LspServer,
LspToolAdapter,
LspServerConfig,
LspCapability,
@@ -32,6 +36,7 @@ from cleveragents.lsp.models import (
)
from cleveragents.lsp.registry import LspRegistry
from cleveragents.lsp.runtime import LspRuntime
from cleveragents.lsp.server import LspServer
from cleveragents.lsp.tool_adapter import LspToolAdapter
__all__ = [
@@ -41,6 +46,7 @@ __all__ = [
"LspNotAvailableError",
"LspRegistry",
"LspRuntime",
"LspServer",
"LspServerConfig",
"LspServerNotFoundError",
"LspToolAdapter",
+554
View File
@@ -0,0 +1,554 @@
"""Minimal LSP server stub with JSON-RPC over stdin/stdout transport.
Implements the Language Server Protocol base protocol with
Content-Length header framing. Supports the ``initialize``,
``shutdown``, and ``exit`` lifecycle methods. All other LSP
methods return a JSON-RPC ``MethodNotFound`` (-32601) error.
An :class:`AcpLocalFacade` instance is stored so that the
dispatch path can be wired through ACP when server mode lands.
In the current stub the facade is a placeholder all methods
are handled by hardcoded stub handlers.
Usage::
from cleveragents.lsp.server import LspServer
server = LspServer()
server.run() # blocks reading stdin
"""
from __future__ import annotations
import json
import os
import sys
from typing import Any, BinaryIO
import structlog
from cleveragents.acp.facade import AcpLocalFacade
logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)
# JSON-RPC error codes (LSP specification)
_PARSE_ERROR = -32700
_INVALID_REQUEST = -32600
_METHOD_NOT_FOUND = -32601
SERVER_NOT_INITIALIZED = -32002
# LSP server metadata
_SERVER_NAME = "cleveragents-lsp-stub"
_SERVER_VERSION = "0.1.0"
# Maximum Content-Length accepted (10 MB). Rejects messages that
# exceed this limit to prevent memory exhaustion from malicious or
# malformed headers.
MAX_CONTENT_LENGTH = 10 * 1024 * 1024
# Maximum number of header lines to read before giving up. Prevents
# denial-of-service from an adversarial client sending infinite
# non-empty header lines without a blank terminator.
MAX_HEADER_LINES = 32
# Maximum bytes ``readline()`` will consume for a single header line.
# Without this cap a malicious client sending a multi-gigabyte line
# without a newline could cause unbounded memory allocation. The
# MAX_HEADER_LINES guard fires *after* a complete line has been read,
# so it alone does not limit per-line memory. With this cap the
# worst-case header memory is MAX_HEADER_LINES * MAX_HEADER_LINE_LENGTH
# (32 * 8192 = 256 KB).
MAX_HEADER_LINE_LENGTH = 8192
class _SkipMessage:
"""Sentinel returned by ``_read_message`` for recoverable transport errors.
Distinguishes recoverable single-message failures (bad framing,
oversized payload, malformed JSON) from a true EOF condition. The
run loop continues when ``_SKIP`` is returned instead of
terminating the server.
"""
__slots__ = ()
_SKIP = _SkipMessage()
# Stubbed capability set reported during initialize. All
# capabilities from the specification's LSP Capability Exposure
# section are represented (disabled or null) so that clients see
# the full surface even while the server is a stub.
_STUBBED_CAPABILITIES: dict[str, Any] = {
"textDocumentSync": 0,
"completionProvider": None,
"hoverProvider": False,
"definitionProvider": False,
"referencesProvider": False,
"documentFormattingProvider": False,
"renameProvider": False,
"codeActionProvider": False,
"diagnosticProvider": None,
"signatureHelpProvider": None,
"documentSymbolProvider": False,
"workspaceSymbolProvider": False,
}
class LspServer:
"""Minimal LSP server stub over JSON-RPC stdin/stdout transport.
The server reads Content-Length framed JSON-RPC messages from
*input_stream* and writes responses to *output_stream*. LSP
requests are routed through an :class:`AcpLocalFacade` instance
in local mode.
Attributes:
_facade: ACP local-mode facade for request routing.
_input: Binary stream to read from (default: ``sys.stdin.buffer``).
_output: Binary stream to write to (default: ``sys.stdout.buffer``).
_running: Whether the event loop is active.
_initialized: Whether ``initialize`` has been received.
_shutdown_requested: Whether ``shutdown`` has been received.
"""
def __init__(
self,
input_stream: BinaryIO | None = None,
output_stream: BinaryIO | None = None,
facade: AcpLocalFacade | None = None,
) -> None:
if input_stream is not None and not hasattr(input_stream, "read"):
raise TypeError("input_stream must support read()")
if output_stream is not None and not hasattr(output_stream, "write"):
raise TypeError("output_stream must support write()")
self._input: BinaryIO = input_stream or sys.stdin.buffer
self._output: BinaryIO = output_stream or sys.stdout.buffer
self._facade: AcpLocalFacade | None = facade
self._running: bool = False
self._initialized: bool = False
self._shutdown_requested: bool = False
@property
def facade(self) -> AcpLocalFacade:
"""Return the ACP facade, creating it lazily on first access.
The facade is stored for future server-mode wiring but is
not called in the current stub. Lazy creation avoids the
overhead of ``AcpLocalFacade()`` construction when the
facade is never needed (e.g., benchmarks).
"""
if self._facade is None:
self._facade = AcpLocalFacade()
return self._facade
@property
def is_running(self) -> bool:
"""Whether the server event loop is currently active."""
return self._running
# ------------------------------------------------------------------
# Transport layer
# ------------------------------------------------------------------
def _drain_headers(self) -> None:
"""Consume remaining header lines up to the blank terminator.
Called after an early-exit error during header parsing
(invalid Content-Length, too many header lines) so that the
stream is left positioned at the start of the next message.
Without this drain, unconsumed header bytes would be
misinterpreted as headers of subsequent messages, causing a
cascading transport desync.
A secondary line-count cap (``MAX_HEADER_LINES``) prevents
a malicious client from keeping the server in this drain
loop indefinitely by streaming infinite non-blank lines
after the primary guard fires.
"""
for _ in range(MAX_HEADER_LINES):
raw_line = self._input.readline(MAX_HEADER_LINE_LENGTH)
if not raw_line:
return
line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
if line == "":
return
def _discard_body(self, content_length: int) -> None:
"""Read and discard body bytes from the input stream.
Called when a message is rejected after headers are fully
parsed (oversized payload) so that the body bytes do not
corrupt the framing of subsequent messages.
The number of bytes actually discarded is capped at
:data:`MAX_CONTENT_LENGTH` to prevent a malicious client
from blocking the server with an absurdly large declared
``Content-Length`` (e.g. 10 TB). Beyond that cap the
stream framing is unrecoverable anyway.
"""
remaining = min(content_length, MAX_CONTENT_LENGTH)
while remaining > 0:
chunk = self._input.read(min(remaining, 65536))
if not chunk:
break
remaining -= len(chunk)
def _read_message(self) -> Any | None:
"""Read a single JSON-RPC message using Content-Length framing.
Returns the parsed JSON value (typically a dict, but may be
any JSON-decoded type such as a list or scalar), ``None`` on
true EOF, or the :data:`_SKIP` sentinel for recoverable
transport errors (bad framing, oversized payload, malformed
JSON). The sentinel allows the run loop to drop the current
message and continue reading the next one rather than
terminating the server.
On recoverable errors the method drains unconsumed header
and/or body bytes so that the stream is correctly positioned
for the next message, preventing transport desync.
"""
content_length: int | None = None
header_lines_read = 0
while True:
raw_line = self._input.readline(MAX_HEADER_LINE_LENGTH)
if not raw_line:
return None
header_lines_read += 1
if header_lines_read > MAX_HEADER_LINES:
logger.warning(
"lsp.transport.too_many_header_lines",
count=header_lines_read,
max_allowed=MAX_HEADER_LINES,
)
self._drain_headers()
return _SKIP
line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
if line == "":
break
if line.lower().startswith("content-length:"):
try:
content_length = int(line.split(":", 1)[1].strip())
except (ValueError, IndexError):
logger.warning("lsp.transport.invalid_content_length", raw=line)
self._drain_headers()
return _SKIP
if content_length is None:
logger.warning("lsp.transport.missing_content_length")
return _SKIP
if content_length < 0:
logger.warning(
"lsp.transport.negative_content_length",
content_length=content_length,
)
return _SKIP
if content_length > MAX_CONTENT_LENGTH:
logger.warning(
"lsp.transport.content_length_exceeded",
content_length=content_length,
max_allowed=MAX_CONTENT_LENGTH,
)
self._discard_body(content_length)
return _SKIP
# SEC: ``read(content_length)`` blocks until exactly
# ``content_length`` bytes arrive or EOF. A malicious client
# that sends a valid Content-Length but stalls mid-body will
# block the server indefinitely. MAX_CONTENT_LENGTH limits
# memory but not time. For this stub the caller (IDE /
# development harness) is trusted; when evolving to
# production, wrap the read in ``select()`` / ``asyncio``
# with a per-message timeout.
data = self._input.read(content_length)
if len(data) < content_length:
logger.warning(
"lsp.transport.incomplete_read",
expected=content_length,
got=len(data),
)
return _SKIP
try:
parsed: Any = json.loads(data)
except json.JSONDecodeError as exc:
logger.warning("lsp.transport.json_parse_error", error=str(exc))
self._write_message(
self._make_error_response(None, _PARSE_ERROR, f"Parse error: {exc}")
)
return _SKIP
except RecursionError:
logger.warning(
"lsp.transport.json_recursion_error",
size=len(data),
)
self._write_message(
self._make_error_response(
None, _PARSE_ERROR, "JSON nesting depth exceeded"
)
)
return _SKIP
return parsed
def _write_message(self, body: dict[str, Any]) -> bool:
"""Write a JSON-RPC response with Content-Length framing.
Returns ``True`` on success, ``False`` if the output stream
is broken (e.g. closed pipe). The caller should stop the
event loop when ``False`` is returned.
"""
payload = json.dumps(body, separators=(",", ":")).encode("utf-8")
header = f"Content-Length: {len(payload)}\r\n\r\n".encode()
try:
self._output.write(header + payload)
self._output.flush()
except OSError:
logger.warning("lsp.transport.write_error")
return False
return True
# ------------------------------------------------------------------
# JSON-RPC helpers
# ------------------------------------------------------------------
def _make_response(
self,
request_id: int | str | None,
result: Any,
) -> dict[str, Any]:
"""Build a JSON-RPC success response."""
return {"jsonrpc": "2.0", "id": request_id, "result": result}
def _make_error_response(
self,
request_id: int | str | None,
code: int,
message: str,
data: Any = None,
) -> dict[str, Any]:
"""Build a JSON-RPC error response."""
error: dict[str, Any] = {"code": code, "message": message}
if data is not None:
error["data"] = data
return {"jsonrpc": "2.0", "id": request_id, "error": error}
# ------------------------------------------------------------------
# Method dispatch
# ------------------------------------------------------------------
def _handle_message(self, msg: Any) -> dict[str, Any] | None:
"""Dispatch a parsed JSON-RPC message to the appropriate handler.
*msg* may be any JSON-decoded value (``json.loads`` can
return lists, strings, etc.). Non-dict values are rejected
with ``InvalidRequest``.
Returns a response dict, or ``None`` for notifications (no id).
"""
if not isinstance(msg, dict):
return self._make_error_response(
None, _INVALID_REQUEST, "Invalid JSON-RPC message"
)
jsonrpc = msg.get("jsonrpc")
if jsonrpc != "2.0":
return self._make_error_response(
msg.get("id"),
_INVALID_REQUEST,
"Invalid or missing jsonrpc version",
)
method = msg.get("method")
if not isinstance(method, str):
return self._make_error_response(
msg.get("id"),
_INVALID_REQUEST,
"Missing or invalid method field",
)
request_id = msg.get("id")
params = msg.get("params", {})
if not isinstance(params, dict):
params = {}
result = self._dispatch_stub(method, params, request_id)
return result
def _dispatch_stub(
self,
method: str,
params: dict[str, Any],
request_id: int | str | None,
) -> dict[str, Any] | None:
"""Dispatch LSP methods to hardcoded stub handlers.
The :attr:`_facade` is stored for future server-mode wiring
but is **not called** in the current stub implementation.
When server mode lands this method should route through the
facade instead of the hardcoded ``if`` chain below.
.. todo:: Wire dispatch through ``self._facade`` when server
mode is implemented (see M7+ roadmap).
"""
logger.debug("lsp.dispatch", method=method, request_id=request_id, mode="stub")
# After shutdown only ``exit`` is accepted (LSP spec §3.16).
if self._shutdown_requested and method != "exit":
if request_id is None:
return None
return self._make_error_response(
request_id,
_INVALID_REQUEST,
"Server is shutting down",
)
if method == "initialize":
return self._handle_initialize(request_id, params)
if method == "exit":
self._handle_exit()
return None
# Before ``initialize`` is received, requests must receive
# ``ServerNotInitialized`` (-32002) per LSP specification.
# Only ``initialize`` and ``exit`` are exempt from this
# guard — ``shutdown`` is a normal request and must be
# rejected when the server has not been initialized.
if not self._initialized and request_id is not None:
return self._make_error_response(
request_id,
SERVER_NOT_INITIALIZED,
f"Server not initialized: {method}",
)
if method == "shutdown":
return self._handle_shutdown(request_id)
# Notification (no id) — silently ignore
if request_id is None:
logger.info("lsp.notification.ignored", method=method)
return None
return self._make_error_response(
request_id,
_METHOD_NOT_FOUND,
f"Not implemented: {method}",
)
# ------------------------------------------------------------------
# LSP lifecycle handlers
# ------------------------------------------------------------------
def _handle_initialize(
self,
request_id: int | str | None,
params: dict[str, Any],
) -> dict[str, Any]:
"""Handle the ``initialize`` request.
Returns a stubbed ``InitializeResult`` with server info and
capabilities. A second ``initialize`` request is rejected
with ``InvalidRequest`` per the LSP specification.
"""
if self._initialized:
logger.warning("lsp.initialize.duplicate", pid=os.getpid())
return self._make_error_response(
request_id,
_INVALID_REQUEST,
"Server already initialized",
)
logger.info(
"lsp.initialize",
client_info=params.get("clientInfo"),
pid=os.getpid(),
)
# NOTE: ``_initialized`` is set upon receiving the ``initialize``
# *request*, not the subsequent ``initialized`` *notification*.
# Per a strict LSP reading the server should not fully activate
# until the ``initialized`` notification arrives, but for a stub
# this is acceptable. Track this deviation when evolving into
# the real server (see M7+ roadmap).
self._initialized = True
result: dict[str, Any] = {
"capabilities": dict(_STUBBED_CAPABILITIES),
"serverInfo": {
"name": _SERVER_NAME,
"version": _SERVER_VERSION,
},
}
return self._make_response(request_id, result)
def _handle_shutdown(
self,
request_id: int | str | None,
) -> dict[str, Any]:
"""Handle the ``shutdown`` request.
Acknowledges and prepares for exit.
"""
logger.info("lsp.shutdown", pid=os.getpid())
self._shutdown_requested = True
return self._make_response(request_id, None)
def _handle_exit(self) -> None:
"""Handle the ``exit`` notification.
Terminates the server process. The final exit code is
computed by :meth:`run` after the event loop ends (single
source of truth based on ``_shutdown_requested``).
"""
logger.info(
"lsp.exit",
code=0 if self._shutdown_requested else 1,
pid=os.getpid(),
)
self._running = False
# ------------------------------------------------------------------
# Main event loop
# ------------------------------------------------------------------
def run(self) -> int:
"""Start the LSP server event loop.
Reads JSON-RPC messages from stdin and writes responses to
stdout until an ``exit`` notification is received or stdin
is closed.
Returns:
Exit code: 0 if shutdown was requested before exit, 1
otherwise.
"""
pid = os.getpid()
logger.info("lsp.server.starting", pid=pid, server=_SERVER_NAME)
self._running = True
while self._running:
msg = self._read_message()
if msg is None:
logger.info("lsp.server.eof", pid=pid)
self._running = False
break
if isinstance(msg, _SkipMessage):
continue
response = self._handle_message(msg)
if response is not None and not self._write_message(response):
self._running = False
break
exit_code = 0 if self._shutdown_requested else 1
logger.info("lsp.server.stopped", pid=pid, exit_code=exit_code)
return exit_code
__all__ = [
"MAX_CONTENT_LENGTH",
"MAX_HEADER_LINES",
"MAX_HEADER_LINE_LENGTH",
"SERVER_NOT_INITIALIZED",
"LspServer",
]
+13
View File
@@ -816,3 +816,16 @@ ScoredFragment # noqa: B018, F821
score_detailed # noqa: B018, F821
depth_fallback_steps # noqa: B018, F821
min_fragment_tokens # noqa: B018, F821
# LSP Server Stub — public API (issue #203)
LspServer # noqa: B018, F821
MAX_CONTENT_LENGTH # noqa: B018, F821
MAX_HEADER_LINES # noqa: B018, F821
SERVER_NOT_INITIALIZED # noqa: B018, F821
MockLspTransport # noqa: B018, F821
# cleveragents.cli.commands.lsp — Typer CLI entrypoint for ``agents lsp serve``
serve # noqa: B018, F821
# cleveragents.lsp.server.LspServer.facade — lazy ACP facade property
facade # noqa: B018, F821