Skip to content

client

code_context_agent.tools.lsp.client

LSP JSON-RPC client over stdio.

This module implements a Language Server Protocol client that communicates with language servers using JSON-RPC 2.0 over stdio with Content-Length framing.

The LSP specification requires messages to be framed with HTTP-like headers

Content-Length:

Reference: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/

LspClient

LspClient(request_timeout=30.0, workspace_settings=None)

LSP client using JSON-RPC 2.0 over stdio.

This client manages communication with an LSP server subprocess, handling the Content-Length message framing and request/response correlation.

Attributes:

Name Type Description
workspace_path str

Path to the workspace root.

server_cmd list[str]

Command to start the LSP server.

Example

client = LspClient() await client.start(["typescript-language-server", "--stdio"], "/path/to/workspace") symbols = await client.document_symbols("/path/to/file.ts") await client.shutdown()

Initialize the LSP client.

Parameters:

Name Type Description Default
request_timeout float

Timeout in seconds for LSP requests (default 30.0).

30.0
workspace_settings dict[str, Any] | None

Settings dict used to respond to workspace/configuration requests from the server. Keys are dot-notation sections (e.g., {"python": {"analysis": {"diagnosticMode": "openFilesOnly"}}}) that the server fetches after initialization.

None
Source code in src/code_context_agent/tools/lsp/client.py
def __init__(
    self,
    request_timeout: float = 30.0,
    workspace_settings: dict[str, Any] | None = None,
) -> None:
    """Initialize the LSP client.

    Args:
        request_timeout: Timeout in seconds for LSP requests (default 30.0).
        workspace_settings: Settings dict used to respond to workspace/configuration
            requests from the server. Keys are dot-notation sections (e.g.,
            ``{"python": {"analysis": {"diagnosticMode": "openFilesOnly"}}}``)
            that the server fetches after initialization.
    """
    self._process: asyncio.subprocess.Process | None = None
    self._msg_id = 0
    self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
    self._background_tasks: set[asyncio.Task[None]] = set()
    self._reader_task: asyncio.Task[None] | None = None
    self._initialized = False
    self._opened_docs: set[str] = set()
    self._workspace_settings: dict[str, Any] = workspace_settings or {}
    self.workspace_path: str = ""
    self.server_cmd: list[str] = []
    self.request_timeout: float = request_timeout

is_connected property

is_connected

Check if client is connected to server.

start async

start(
    server_cmd,
    workspace_path,
    startup_timeout=30.0,
    initialization_options=None,
)

Start LSP server and initialize the connection.

Parameters:

Name Type Description Default
server_cmd list[str]

Command and arguments to start the LSP server.

required
workspace_path str

Absolute path to the workspace root.

required
startup_timeout float

Maximum seconds to wait for server initialization.

30.0
initialization_options dict[str, Any] | None

Optional initialization options for the server.

None

Returns:

Type Description
dict[str, Any]

Server capabilities from the initialize response.

Raises:

Type Description
RuntimeError

If server fails to start or initialize within timeout.

Source code in src/code_context_agent/tools/lsp/client.py
async def start(
    self,
    server_cmd: list[str],
    workspace_path: str,
    startup_timeout: float = 30.0,
    initialization_options: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Start LSP server and initialize the connection.

    Args:
        server_cmd: Command and arguments to start the LSP server.
        workspace_path: Absolute path to the workspace root.
        startup_timeout: Maximum seconds to wait for server initialization.
        initialization_options: Optional initialization options for the server.

    Returns:
        Server capabilities from the initialize response.

    Raises:
        RuntimeError: If server fails to start or initialize within timeout.
    """
    self.server_cmd = server_cmd
    self.workspace_path = workspace_path

    logger.info(f"Starting LSP server: {' '.join(server_cmd)} (timeout: {startup_timeout}s)")

    try:
        # Use a shared deadline so subprocess spawn + initialize share the budget
        deadline = time.monotonic() + startup_timeout

        # Create subprocess (near-instant, but cap at 10s to leave budget for init)
        spawn_timeout = min(10.0, startup_timeout * 0.3)
        try:
            self._process = await asyncio.wait_for(
                asyncio.create_subprocess_exec(
                    *server_cmd,
                    stdin=asyncio.subprocess.PIPE,
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE,
                    cwd=workspace_path,
                ),
                timeout=spawn_timeout,
            )
        except TimeoutError as err:
            logger.error(f"LSP server failed to start within {spawn_timeout}s")
            raise RuntimeError(f"LSP server failed to start within {spawn_timeout}s") from err

        if self._process.stdout is None or self._process.stdin is None:
            raise RuntimeError("Failed to create subprocess pipes")

        # Start background reader
        self._reader_task = asyncio.create_task(self._read_responses())

        # Send initialize request with remaining time budget
        remaining = max(5.0, deadline - time.monotonic())

        root_uri = Path(workspace_path).as_uri()
        init_params: dict[str, Any] = {
            "processId": None,
            "rootUri": root_uri,
            "capabilities": {
                "workspace": {
                    "workspaceFolders": True,
                    "configuration": True,
                    "didChangeConfiguration": {"dynamicRegistration": False},
                },
                "textDocument": {
                    "documentSymbol": {
                        "hierarchicalDocumentSymbolSupport": True,
                    },
                    "definition": {},
                    "references": {},
                    "hover": {
                        "contentFormat": ["markdown", "plaintext"],
                    },
                },
            },
            "workspaceFolders": [{"uri": root_uri, "name": Path(workspace_path).name}],
            "clientInfo": {"name": "code-context-agent", "version": "0.1.0"},
        }

        # Add initialization options if provided (e.g., pyright config)
        if initialization_options:
            init_params["initializationOptions"] = initialization_options

        try:
            init_result = await asyncio.wait_for(
                self._request("initialize", init_params),
                timeout=remaining,
            )
        except TimeoutError as err:
            logger.error(f"LSP initialization timed out after {remaining:.1f}s")
            raise RuntimeError(f"LSP initialization timed out after {remaining:.1f}s") from err

        if "error" in init_result:
            raise RuntimeError(f"LSP initialize error: {init_result['error']}")

        # Send initialized notification
        await self._notify("initialized", {})

        # Send workspace/didChangeConfiguration as a fallback mechanism.
        # Pyright uses this to trigger workspace initialization when
        # workspace/configuration capability is declared.
        if self._workspace_settings:
            await self._notify(
                "workspace/didChangeConfiguration",
                {"settings": self._workspace_settings},
            )

        self._initialized = True

        logger.info("LSP server initialized successfully")
        return init_result.get("result", {})

    except Exception:
        # Clean up on any failure
        await self._cleanup_on_error()
        raise

did_open async

did_open(file_path, language_id=None, *, force=False)

Notify server that a document was opened.

Skips the notification if the document was already opened (unless force=True), avoiding redundant file reads and server re-parsing.

Parameters:

Name Type Description Default
file_path str

Absolute path to the file.

required
language_id str | None

Language identifier (auto-detected if not provided).

None
force bool

If True, re-send didOpen even if already tracked.

False

Raises:

Type Description
FileNotFoundError

If the file does not exist.

Source code in src/code_context_agent/tools/lsp/client.py
async def did_open(self, file_path: str, language_id: str | None = None, *, force: bool = False) -> None:
    """Notify server that a document was opened.

    Skips the notification if the document was already opened (unless force=True),
    avoiding redundant file reads and server re-parsing.

    Args:
        file_path: Absolute path to the file.
        language_id: Language identifier (auto-detected if not provided).
        force: If True, re-send didOpen even if already tracked.

    Raises:
        FileNotFoundError: If the file does not exist.
    """
    path = Path(file_path)
    uri = path.as_uri()

    # Skip if already opened (avoids redundant file read + server re-parse)
    if not force and uri in self._opened_docs:
        return

    if not path.exists():
        raise FileNotFoundError(f"File not found: {file_path}")

    if language_id is None:
        language_id = self._detect_language(path)

    text = path.read_text(encoding="utf-8", errors="replace")

    await self._notify(
        "textDocument/didOpen",
        {
            "textDocument": {
                "uri": uri,
                "languageId": language_id,
                "version": 1,
                "text": text,
            },
        },
    )
    self._opened_docs.add(uri)

document_symbols async

document_symbols(file_path)

Get document symbols (outline).

Parameters:

Name Type Description Default
file_path str

Absolute path to the file.

required

Returns:

Type Description
list[dict[str, Any]]

List of DocumentSymbol or SymbolInformation objects.

Source code in src/code_context_agent/tools/lsp/client.py
async def document_symbols(self, file_path: str) -> list[dict[str, Any]]:
    """Get document symbols (outline).

    Args:
        file_path: Absolute path to the file.

    Returns:
        List of DocumentSymbol or SymbolInformation objects.
    """
    path = Path(file_path)

    # Ensure document is open
    await self.did_open(file_path)

    response = await self._request(
        "textDocument/documentSymbol",
        {"textDocument": {"uri": path.as_uri()}},
    )

    return response.get("result", []) or []

hover async

hover(file_path, line, character)

Get hover information at position.

Parameters:

Name Type Description Default
file_path str

Absolute path to the file.

required
line int

0-indexed line number.

required
character int

0-indexed column number.

required

Returns:

Type Description
dict[str, Any] | None

Hover information with contents, or None if not available.

Source code in src/code_context_agent/tools/lsp/client.py
async def hover(self, file_path: str, line: int, character: int) -> dict[str, Any] | None:
    """Get hover information at position.

    Args:
        file_path: Absolute path to the file.
        line: 0-indexed line number.
        character: 0-indexed column number.

    Returns:
        Hover information with contents, or None if not available.
    """
    path = Path(file_path)

    response = await self._request(
        "textDocument/hover",
        {
            "textDocument": {"uri": path.as_uri()},
            "position": {"line": line, "character": character},
        },
    )

    return response.get("result")

references async

references(
    file_path, line, character, include_declaration=True
)

Find all references to symbol at position.

Parameters:

Name Type Description Default
file_path str

Absolute path to the file.

required
line int

0-indexed line number.

required
character int

0-indexed column number.

required
include_declaration bool

Whether to include the declaration.

True

Returns:

Type Description
list[dict[str, Any]]

List of Location objects with uri and range.

Source code in src/code_context_agent/tools/lsp/client.py
async def references(
    self,
    file_path: str,
    line: int,
    character: int,
    include_declaration: bool = True,
) -> list[dict[str, Any]]:
    """Find all references to symbol at position.

    Args:
        file_path: Absolute path to the file.
        line: 0-indexed line number.
        character: 0-indexed column number.
        include_declaration: Whether to include the declaration.

    Returns:
        List of Location objects with uri and range.
    """
    path = Path(file_path)

    response = await self._request(
        "textDocument/references",
        {
            "textDocument": {"uri": path.as_uri()},
            "position": {"line": line, "character": character},
            "context": {"includeDeclaration": include_declaration},
        },
    )

    return response.get("result", []) or []

definition async

definition(file_path, line, character)

Go to definition of symbol at position.

Parameters:

Name Type Description Default
file_path str

Absolute path to the file.

required
line int

0-indexed line number.

required
character int

0-indexed column number.

required

Returns:

Type Description
list[dict[str, Any]]

List of Location objects.

Source code in src/code_context_agent/tools/lsp/client.py
async def definition(self, file_path: str, line: int, character: int) -> list[dict[str, Any]]:
    """Go to definition of symbol at position.

    Args:
        file_path: Absolute path to the file.
        line: 0-indexed line number.
        character: 0-indexed column number.

    Returns:
        List of Location objects.
    """
    path = Path(file_path)

    response = await self._request(
        "textDocument/definition",
        {
            "textDocument": {"uri": path.as_uri()},
            "position": {"line": line, "character": character},
        },
    )

    result = response.get("result")
    if result is None:
        return []
    if isinstance(result, dict):
        return [result]
    return result

workspace_symbols async

workspace_symbols(query)

Search for workspace symbols matching query.

Parameters:

Name Type Description Default
query str

Search query string (partial name matching).

required

Returns:

Type Description
list[dict[str, Any]]

List of SymbolInformation objects.

Source code in src/code_context_agent/tools/lsp/client.py
async def workspace_symbols(self, query: str) -> list[dict[str, Any]]:
    """Search for workspace symbols matching query.

    Args:
        query: Search query string (partial name matching).

    Returns:
        List of SymbolInformation objects.
    """
    result = await self._request("workspace/symbol", {"query": query})
    return result.get("result", []) or []

diagnostics async

diagnostics(file_uri)

Get diagnostics for a file. Uses textDocument/diagnostic (pull model).

Parameters:

Name Type Description Default
file_uri str

File URI (file:///path/to/file).

required

Returns:

Type Description
list[dict[str, Any]]

List of Diagnostic objects.

Source code in src/code_context_agent/tools/lsp/client.py
async def diagnostics(self, file_uri: str) -> list[dict[str, Any]]:
    """Get diagnostics for a file. Uses textDocument/diagnostic (pull model).

    Args:
        file_uri: File URI (file:///path/to/file).

    Returns:
        List of Diagnostic objects.
    """
    result = await self._request(
        "textDocument/diagnostic",
        {"textDocument": {"uri": file_uri}},
    )
    response = result.get("result")
    if response and isinstance(response, dict) and "items" in response:
        return response["items"]
    if isinstance(response, list):
        return response
    return []

shutdown async

shutdown()

Shutdown the LSP server gracefully.

Source code in src/code_context_agent/tools/lsp/client.py
async def shutdown(self) -> None:
    """Shutdown the LSP server gracefully."""
    if self._process is None:
        return

    try:
        # Send shutdown request
        await self._request("shutdown", {})
        # Send exit notification
        await self._notify("exit", {})
    except (OSError, TimeoutError, RuntimeError) as e:
        logger.warning(f"LSP shutdown error: {e}")
    finally:
        # Cancel reader task
        if self._reader_task:
            self._reader_task.cancel()
            try:
                await self._reader_task
            except asyncio.CancelledError:
                pass  # expected during shutdown — task was intentionally cancelled

        # Terminate process
        if self._process:
            self._process.terminate()
            try:
                await asyncio.wait_for(self._process.wait(), timeout=5.0)
            except TimeoutError:
                self._process.kill()

        self._process = None
        self._initialized = False
        self._pending.clear()
        self._opened_docs.clear()