diff --git a/CHANGELOG.md b/CHANGELOG.md
index c346b1e6e..e2bb52577 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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``,
diff --git a/benchmarks/lsp_stub_bench.py b/benchmarks/lsp_stub_bench.py
new file mode 100644
index 000000000..ff7bfc6f2
--- /dev/null
+++ b/benchmarks/lsp_stub_bench.py
@@ -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()
diff --git a/docs/reference/lsp_stub.md b/docs/reference/lsp_stub.md
new file mode 100644
index 000000000..1b7a2d7dc
--- /dev/null
+++ b/docs/reference/lsp_stub.md
@@ -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.
diff --git a/docs/specification.md b/docs/specification.md
index 0927b6d00..ea14ef534 100644
--- a/docs/specification.md
+++ b/docs/specification.md
@@ -301,6 +301,7 @@ The following standards are integrated into the architecture:
agents lsp remove [--yes|-y] <NAME>
agents lsp list [(--namespace|-n) <NS>] [--language <LANG>]
agents lsp show <NAME>
+agents lsp serve [--log-level <LEVEL>]
agents resource type add (--config|-c) <FILE> [--update]
agents resource type remove [--yes|-y] <NAME>
@@ -9205,6 +9206,52 @@ Show full details for a registered LSP server, including its configuration, supp
- LSP server details loaded
```
+##### agents lsp serve
+
+
agents lsp serve [--log-level <LEVEL>]
+
+**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"
+
+
+ $ 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)
+
+
+=== "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"
diff --git a/features/lsp_server_stub.feature b/features/lsp_server_stub.feature
new file mode 100644
index 000000000..44c76cdc6
--- /dev/null
+++ b/features/lsp_server_stub.feature
@@ -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
diff --git a/features/mocks/__init__.py b/features/mocks/__init__.py
index 038b9843c..4e144fc97 100644
--- a/features/mocks/__init__.py
+++ b/features/mocks/__init__.py
@@ -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",
+]
diff --git a/features/mocks/lsp_transport_mock.py b/features/mocks/lsp_transport_mock.py
new file mode 100644
index 000000000..bcf85f521
--- /dev/null
+++ b/features/mocks/lsp_transport_mock.py
@@ -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",
+]
diff --git a/features/steps/lsp_server_stub_steps.py b/features/steps/lsp_server_stub_steps.py
new file mode 100644
index 000000000..f5ea7cb09
--- /dev/null
+++ b/features/steps/lsp_server_stub_steps.py
@@ -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}"
+ )
diff --git a/robot/helper_lsp_stub.py b/robot/helper_lsp_stub.py
new file mode 100644
index 000000000..c7f98035f
--- /dev/null
+++ b/robot/helper_lsp_stub.py
@@ -0,0 +1,239 @@
+"""Helper script for Robot Framework LSP stub smoke tests.
+
+Usage:
+ python helper_lsp_stub.py
+
+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]]()
diff --git a/robot/lsp_stub_smoke.robot b/robot/lsp_stub_smoke.robot
new file mode 100644
index 000000000..17145e256
--- /dev/null
+++ b/robot/lsp_stub_smoke.robot
@@ -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
diff --git a/src/cleveragents/cli/commands/lsp.py b/src/cleveragents/cli/commands/lsp.py
index 811d893a9..d77f3fb75 100644
--- a/src/cleveragents/cli/commands/lsp.py
+++ b/src/cleveragents/cli/commands/lsp.py
@@ -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)
diff --git a/src/cleveragents/lsp/__init__.py b/src/cleveragents/lsp/__init__.py
index 1ed862cd3..b706ebb76 100644
--- a/src/cleveragents/lsp/__init__.py
+++ b/src/cleveragents/lsp/__init__.py
@@ -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",
diff --git a/src/cleveragents/lsp/server.py b/src/cleveragents/lsp/server.py
new file mode 100644
index 000000000..2c93564d8
--- /dev/null
+++ b/src/cleveragents/lsp/server.py
@@ -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",
+]
diff --git a/vulture_whitelist.py b/vulture_whitelist.py
index 12182182b..0eab344aa 100644
--- a/vulture_whitelist.py
+++ b/vulture_whitelist.py
@@ -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