LSP Auto-Detect
Zero-config language, workspace root, and server-availability detection for LSP-driven agents
Zero-config helpers that pick the right language server, its real project root, and tell you plainly when it isn’t installed.
from praisonaiagents import Agent
from praisonaiagents.tools import lsp_definition
agent = Agent(
name="Coder",
instructions="Navigate the codebase with LSP accuracy.",
tools=[lsp_definition],
)
agent.start("Where is `authenticate` defined in this monorepo?")
# The LSP client now finds the nearest pyproject.toml / go.mod / Cargo.toml
# on its own; no rootUri configuration needed.Quick Start
- Let the agent do it (nothing to configure)
python from praisonaiagents import Agent from praisonaiagents.tools import lsp_definition, lsp_references agent = Agent( name="Coder", instructions="Find definitions and references before editing.", tools=[lsp_definition, lsp_references], ) agent.start("What calls `parse_config`?") - Detect language and root yourself (advanced)
python from praisonaiagents.lsp import detect_language, detect_root_uri detect_language("services/auth/main.go") # → "go" detect_root_uri("services/auth/main.go") # → "file:///abs/path/to/repo" (nearest go.mod) - Probe availability before you use LSP
python from praisonaiagents.lsp import probe available, command, install_hint = probe("rust") # → (False, "rust-analyzer", "rustup component add rust-analyzer") if not available: print(f"Install `{command}` first: {install_hint}") - Talk to the client directly with a workspace-aware root
python import asyncio from praisonaiagents.lsp import LSPClient client = LSPClient(language="python", workspace_file="services/api/mod.py") started = asyncio.run(client.start()) if not started: print(client.last_error) # "language server `pylsp` not found on PATH; install with `pip install python-lsp-server`"
How It Works
Each language maps to a default server, its file extensions, and the root markers that pin a project root — the nearest marker wins.
| Language | Extensions | Root markers (nearest wins) | Command | Install hint |
|---|---|---|---|---|
python | .py, .pyi | pyproject.toml, setup.py, setup.cfg, requirements.txt, .git | pylsp | pip install python-lsp-server |
javascript | .js, .jsx, .mjs, .cjs | package.json, tsconfig.json, jsconfig.json, .git | typescript-language-server | npm install -g typescript-language-server typescript |
typescript | .ts, .tsx | tsconfig.json, package.json, jsconfig.json, .git | typescript-language-server | npm install -g typescript-language-server typescript |
rust | .rs | Cargo.toml, Cargo.lock, .git | rust-analyzer | rustup component add rust-analyzer |
go | .go | go.mod, go.sum, .git | gopls | go install golang.org/x/tools/gopls@latest |
Adding or Overriding a Language Server
Register a new language or swap a built-in server by passing servers= — no package source edits needed.
import asyncio
from praisonaiagents.lsp import LSPClient
# Register Java once; the LSP layer honours the extension, root markers,
# install hint, and initialization options from this entry.
JAVA_SERVERS = {
"java": {
"command": "jdtls",
"args": ["--stdio"],
"extensions": [".java"],
"root_markers": ["pom.xml", ".git"],
"install_hint": "install eclipse.jdt.ls",
}
}
client = LSPClient(language="java", servers=JAVA_SERVERS)
started = asyncio.run(client.start())
if not started:
print(client.last_error)
# "language server `jdtls` not found on PATH; install with `install eclipse.jdt.ls`"Four Ways to Configure
Add a language
from praisonaiagents.lsp import LSPClient
LSPClient(language="java", servers={
"java": {
"command": "jdtls",
"args": ["--stdio"],
"extensions": [".java"],
"root_markers": ["pom.xml"],
}
})Override a built-in
from praisonaiagents.lsp import LSPClient
# Swap the Python server for Pyright.
LSPClient(language="python", servers={
"python": {"command": "pyright-langserver", "args": ["--stdio"]}
})
# Extensions, root markers, and install hint fall back to the built-in `python` row.With init options
from praisonaiagents.lsp import LSPClient
# `initialization_options` are picked up automatically when you don't pass your own.
LSPClient(language="java", servers={
"java": {
"command": "jdtls",
"args": ["--stdio"],
"extensions": [".java"],
"root_markers": ["pom.xml"],
"initialization_options": {"bundles": []},
}
})Compose the registry
from praisonaiagents.lsp import resolve_servers
merged = resolve_servers({
"java": {"command": "jdtls", "args": ["--stdio"], "extensions": [".java"]}
})
merged["python"]["command"] # → "pylsp" (built-in kept)
merged["java"]["command"] # → "jdtls" (added)How the Merge Works
User entries override built-ins per language key; unset fields fall back to the built-in row.
| Scenario | What the merge does |
|---|---|
No user config (None or {}) | Returns DEFAULT_SERVERS unchanged (identity return) |
New language key (e.g. "java") | Added to the merged registry; built-ins untouched |
Same key, command differs, no args given | Built-in args are dropped (never leaked to a replacement binary) |
Same key, command same, other fields changed | Built-in args retained; other fields updated |
Non-dict user entry ({"bogus": "not-a-dict"}) | Silently ignored |
DEFAULT_SERVERS is never mutated by a merge — resolve_servers({...}) always returns a fresh dict.
Which Configuration Style?
User Interaction Flow
A registered language behaves exactly like a built-in one.
User: “Where is
AuthService.logindefined in this Maven repo?” Agent uses a Java LSP client registered viaservers=, which finds the nearestpom.xmlfor the root and queriesjdtls. Tool returns:src/main/java/com/acme/AuthService.java:42:5Agent replies: “AuthService.loginis defined insrc/main/java/com/acme/AuthService.javaat line 42.”
Which Helper Should I Use?
Public API Surface
These are pure helpers plus one new keyword and one new attribute on LSPClient.
| Symbol | Kind | Import path | Returns | Notes |
|---|---|---|---|---|
detect_language(file_path, servers=None) | function | from praisonaiagents.lsp import detect_language | Optional[str] | Case-insensitive extension lookup; honours custom extensions from servers; None for unknown extensions |
detect_root_uri(file_path, language=None, servers=None) | function | from praisonaiagents.lsp import detect_root_uri | Optional[str] | Walks up from the file; honours custom root markers from servers; nearest match wins; None if none found |
probe(language, servers=None) | function | from praisonaiagents.lsp import probe | Tuple[bool, Optional[str], Optional[str]] | (available, command, install_hint); honours custom install hints from servers |
resolve_servers(user_servers=None) | function | from praisonaiagents.lsp import resolve_servers | Dict[str, Dict] | Merges user servers over DEFAULT_SERVERS; identity return when input is falsy |
path_to_uri(path) | function | from praisonaiagents.lsp.config import path_to_uri | str | Percent-encodes spaces, #, and other reserved characters |
DEFAULT_SERVERS | constant | from praisonaiagents.lsp import DEFAULT_SERVERS | Dict[str, Dict] | Rows contain command, args, extensions, root_markers, install_hint |
LSPClient(..., workspace_file=None, servers=None) | class | from praisonaiagents.lsp import LSPClient | — | Root auto-detected from workspace_file; servers= threads a custom registry through detect / probe / start |
LSPClient.last_error | attribute | (on any client) | Optional[str] | Structured “not found on PATH” message; None on success |
Common Patterns
Guarded startup logging — log what the LSP layer would do for each language:
from praisonaiagents.lsp import probe
for lang in ("python", "typescript", "go", "rust"):
ok, cmd, hint = probe(lang)
print(f"{lang}: {'ok' if ok else 'missing'} ({cmd or 'no server'})")Monorepo-aware navigation — prove you’re pointing at the right project root:
from praisonaiagents.lsp import detect_root_uri
assert detect_root_uri("services/api/mod.py").endswith("/services/api")Fallback to grep with a real reason — surface client.last_error so the model picks a fallback intelligently:
import asyncio
from praisonaiagents.lsp import LSPClient
client = LSPClient(language="go", workspace_file="services/auth/main.go")
if not asyncio.run(client.start()):
print(f"LSP unavailable — {client.last_error}. Falling back to grep.")Best Practices
Prefer probe() over try/except
LSPClient.start() no longer raises on a missing binary — it sets last_error and returns False. Reserve try/except for real IO failures.
Pass workspace_file= to LSPClient in monorepos
Without it, the client falls back to os.getcwd(), which is almost never the file’s real project root. workspace_file lets detect_root_uri initialise the server against the nearest root marker.
Do not parse the phrases
Error text like install with \…\` is for humans. For machine-readable data, call probe(language) and read the (available, command, install_hint)` tuple.
Treat the 'not found on PATH' note as a call to action
The edit_tools note appears once per language per run. When you see it, install the server rather than ignore the diagnostic signal for the rest of the session.
Pin the server your team uses
Define a servers= mapping in your project bootstrap and pass it to every LSPClient, so contributors get the same server without installing an alternative. New teammates inherit the choice instead of debugging a mismatch.
Set install_hint for internal tooling
When you register a private or in-house server, include install_hint. It flows into client.last_error so a teammate missing the binary sees exactly how to install it.
Provide initialization_options in the registry
Put server-specific tuning in the registry entry’s initialization_options. It is auto-picked when the caller didn’t set its own — cleaner than passing the same options to every client.