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.

python
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.
Diagram

Zero-Config LSP

Yes

No

📄 file.py

🔍 detect_language

python

📁 detect_root_uri

pyproject.toml dir

⚡ probe

Installed?

🚀 start server

💡 install hint

Quick Start

  1. 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`?")
  2. 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)
  3. 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}")
  4. 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

Diagram
PATHlsp.configLSPClientAgentPATHlsp.configLSPClientAgentalt[Missing][Present]LSPClient(language, workspace_file=…)await client.start()shutil.which(configured command)Noneprobe(language) — for install hintFalse (last_error populated)detect_root_uri(workspace_file)file:// URI of nearest root markerspawn server with correct rootUriTrue

Each language maps to a default server, its file extensions, and the root markers that pin a project root — the nearest marker wins.

LanguageExtensionsRoot markers (nearest wins)CommandInstall hint
python.py, .pyipyproject.toml, setup.py, setup.cfg, requirements.txt, .gitpylsppip install python-lsp-server
javascript.js, .jsx, .mjs, .cjspackage.json, tsconfig.json, jsconfig.json, .gittypescript-language-servernpm install -g typescript-language-server typescript
typescript.ts, .tsxtsconfig.json, package.json, jsconfig.json, .gittypescript-language-servernpm install -g typescript-language-server typescript
rust.rsCargo.toml, Cargo.lock, .gitrust-analyzerrustup component add rust-analyzer
go.gogo.mod, go.sum, .gitgoplsgo 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.

python
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`"
Diagram

Yes, built-in

Yes, user override

Yes, new language

No

Agent asks for language X

resolve_servers – merge user over DEFAULT_SERVERS

X in merged registry?

Use DEFAULT_SERVERS entry

Use user entry – args dropped if command replaced

Use user entry as-is

ValueError: No default server for language X

Four Ways to Configure

Add a language

python
from praisonaiagents.lsp import LSPClient

LSPClient(language="java", servers={
    "java": {
        "command": "jdtls",
        "args": ["--stdio"],
        "extensions": [".java"],
        "root_markers": ["pom.xml"],
    }
})

Override a built-in

python
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

python
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

python
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.

ScenarioWhat 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 givenBuilt-in args are dropped (never leaked to a replacement binary)
Same key, command same, other fields changedBuilt-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?

Diagram

No, brand new

Yes, wrong binary

Yes, just swap command

No, full control

Yes

I need a different server

Language shipped by default?

Add a new key with servers=

Keep the built-in metadata?

Override command only – args reset

Provide command, args, extensions, root_markers

Need the merged dict directly?

Call resolve_servers to inspect

User Interaction Flow

A registered language behaves exactly like a built-in one.

User: “Where is AuthService.login defined in this Maven repo?” Agent uses a Java LSP client registered via servers=, which finds the nearest pom.xml for the root and queries jdtls. Tool returns: src/main/java/com/acme/AuthService.java:42:5 Agent replies: “AuthService.login is defined in src/main/java/com/acme/AuthService.java at line 42.”


Which Helper Should I Use?

Diagram

Yes

No, custom code

What language is this file?

Where is the project root?

Is the server installed?

Full client control

I want to work with LSP

Just using the tools?

Use lsp_definition / lsp_references / …

What do I need to know?

detect_language

detect_root_uri

probe

LSPClient with workspace_file


Public API Surface

These are pure helpers plus one new keyword and one new attribute on LSPClient.

SymbolKindImport pathReturnsNotes
detect_language(file_path, servers=None)functionfrom praisonaiagents.lsp import detect_languageOptional[str]Case-insensitive extension lookup; honours custom extensions from servers; None for unknown extensions
detect_root_uri(file_path, language=None, servers=None)functionfrom praisonaiagents.lsp import detect_root_uriOptional[str]Walks up from the file; honours custom root markers from servers; nearest match wins; None if none found
probe(language, servers=None)functionfrom praisonaiagents.lsp import probeTuple[bool, Optional[str], Optional[str]](available, command, install_hint); honours custom install hints from servers
resolve_servers(user_servers=None)functionfrom praisonaiagents.lsp import resolve_serversDict[str, Dict]Merges user servers over DEFAULT_SERVERS; identity return when input is falsy
path_to_uri(path)functionfrom praisonaiagents.lsp.config import path_to_uristrPercent-encodes spaces, #, and other reserved characters
DEFAULT_SERVERSconstantfrom praisonaiagents.lsp import DEFAULT_SERVERSDict[str, Dict]Rows contain command, args, extensions, root_markers, install_hint
LSPClient(..., workspace_file=None, servers=None)classfrom praisonaiagents.lsp import LSPClient—Root auto-detected from workspace_file; servers= threads a custom registry through detect / probe / start
LSPClient.last_errorattribute(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:

python
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:

python
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:

python
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.


Related