LSPSteer
by haristariq
README.md
# LSPSteer
[](https://github.com/haristariq/lspsteer/actions/workflows/ci.yml)
**Compiler-in-the-loop steering for coding agents - Cursor *and* Claude Code.**
LSPSteer runs your workspace's real language server on each edit, extracts the
diagnostics scoped to the lines that changed *and the in-scope type signatures
needed to fix them*, and feeds that back to the coding agent - so generation is
constrained by program analysis instead of corrected by post-hoc lint.
It plugs into both editors through the same warm daemon: an **MCP tool**
(`typecheck_scope`) the agent calls after editing, plus optional **hooks** that
enforce a clean type-check before the agent ends its turn. One command wires it
up: `lspsteer init`.
> An agent's edit can be perfectly well-formed and still wrong: it passes a
> `number` where a `string` id is required, or reads `.balance` off a
> `Promise<User>`. Grammar- and syntax-level guidance never catches this.
> Studies of LLM compilation failures find the overwhelming majority are
> **type-check** errors, not syntax errors - so the dominant failure class is
> exactly the one a type checker sees and a linter doesn't.
**Honest result up front:** in a paired, deterministically-scored A/B (150 runs,
`eval/`), compiler-in-the-loop feedback beats no feedback on first-attempt
correctness, but on small readable repos LSPSteer matches an agent that just runs
`tsc` itself. The edge it is designed for (scoping when `tsc` emits hundreds of
errors, resolving types that are expensive to read) should show on large repos,
which the current suite does not cover. Full numbers below and in
[`eval/FINDINGS.md`](eval/FINDINGS.md).
---
## What it looks like
The core mechanism is the **steering note**: after an edit, LSPSteer returns the
diagnostics scoped to the changed lines plus the in-scope type signatures, so the
agent works from the resolved types instead of guessing them.
```
LSPSteer found 2 errors in your edit to payments.ts. Fix before finishing.
Errors:
- payments.ts:4:25 [error ts2345] Argument of type 'number' is not assignable
to parameter of type 'string'.
> const user = findUser(42);
- payments.ts:5:15 [error ts2339] Property 'balance' does not exist on type
'Promise<User>'.
> return user.balance * 1.1;
Relevant types in scope (use these to fix correctly):
- function chargeUser
- findUser: function findUser(id: string): Promise<User>
```
The difference from "just run `tsc`": errors are scoped to the edit, and the
agent is handed the **resolved types it is actually working with**
(`findUser(id: string): Promise<User>`) rather than a wall of project-wide output.
<details>
<summary>Illustrative full loop (<code>npm run demo</code>): broken edit, steered, fixed</summary>
This shows the steering note inside an end-to-end flow on the included fixture.
It is an illustration of the mechanism, not a benchmark; for measured impact see
the result line above.
```
STEP 1 - Agent writes payments.ts (compiles? no. syntactically valid? yes)
export function chargeUser(): number {
const user = findUser(42);
return user.balance * 1.1;
}
STEP 2 - LSPSteer analyzes the edit (cold: daemon boot + tsserver load)
[the steering note shown above]
STEP 3 - Re-analyze the same edit (warm: server already loaded, sub-second)
[same note, now in < 1s]
STEP 4 - Agent applies the guidance
export async function chargeUser(id: string): Promise<number> {
const user = await findUser(id);
return user.isActive ? 10 : 0;
}
LSPSteer: payments.ts is type-clean.
```
</details>
## Why a daemon
A cold `tsserver`/Pyright/rust-analyzer load is several seconds; warm edits are
sub-second. LSPSteer keeps language servers **warm in a long-lived daemon**, one
per project, behind a Unix-socket IPC. The hooks and the MCP server are thin
clients. Measured on the fixture:
| | latency |
|---|---|
| cold load (one-time per project) | ~5-12s |
| **warm analysis (every edit after)** | **< 1s** |
## How it plugs into your agent
Two layers; the first is the robust default. Both work identically in Cursor and
Claude Code - only the config file locations and the hook event names differ, and
`lspsteer init` writes the right ones for you.
1. **MCP tool + rule (recommended).** LSPSteer exposes a `typecheck_scope` MCP
tool. A bundled rule (Cursor's `.cursor/rules/lspsteer.mdc`, or a block in
`CLAUDE.md` for Claude Code) tells the agent to call it after editing a file
and fix any reported errors before finishing. This rides stable,
well-specified MCP I/O.
2. **Hooks (optional enforcement).** An edit hook (Cursor `afterFileEdit`,
Claude Code `PostToolUse` on `Edit|Write|MultiEdit`) records edited files and
pre-warms the server; a stop hook (Cursor `stop`, Claude Code `Stop`)
re-checks them when the agent tries to end the turn and hands back a steering
note if errors remain. The two harnesses expect different Stop-hook output
shapes; LSPSteer detects which one it's running under and emits the right one
(`{"decision":"block","reason":…}` for Claude Code), failing open on any error.
### MCP tools
- `typecheck_scope(file, changedRanges?)` - diagnostics scoped to the changed
lines + in-scope type signatures. The steering tool.
- `get_diagnostics(file)` - every diagnostic for a file (whole-file check).
- `lspsteer_status(workspaceRoot?)` - which servers are warm.
## Install
LSPSteer is a TypeScript/Node project. From a clone:
```bash
npm install
npm run build
node dist/cli.js doctor . # verifies the language server resolves & starts
```
Then wire it into any project with a single command:
```bash
# from inside the target project (writes config for both editors by default):
lspsteer init
# or pick one, and/or pass a target directory:
lspsteer init --cursor /path/to/project
lspsteer init --claude /path/to/project
```
`init` writes the MCP registration, the rule, and the (optional) hooks with
absolute paths filled in, **merging** into any existing config rather than
overwriting it, and is safe to re-run (idempotent). It targets:
| | Cursor | Claude Code |
|---|---|---|
| MCP | `.cursor/mcp.json` | `.mcp.json` |
| Rule | `.cursor/rules/lspsteer.mdc` | block appended to `CLAUDE.md` |
| Hooks | `.cursor/hooks.json` | `.claude/settings.json` |
Reload your editor afterward. To wire it by hand instead, see
[`cursor/README.md`](cursor/README.md) and [`claude/README.md`](claude/README.md).
### CLI
```
lspsteer init [dir] Wire LSPSteer into a project (--cursor | --claude | --both)
lspsteer daemon [root] Run the warm-LSP daemon in the foreground
lspsteer mcp Run the MCP server on stdio (what the editor launches)
lspsteer doctor [root] Check that language servers resolve and start
lspsteer status [root] List warm language servers
```
## Language support
TypeScript/JavaScript works today (via `typescript-language-server`, which uses
your project's own TypeScript). Support is **pluggable**: a language is one
`LanguageAdapter` entry (how to launch the server + which files it owns).
Python (Pyright) and Rust (rust-analyzer) adapters are stubbed in
`src/lsp/adapters.ts` - install the server and the rest of the pipeline
(diff-scoping, scope resolution, daemon, hooks, MCP) works unchanged.
## How it works
```
Cursor / Claude Code agent
│ (edits a file)
┌────────────┴─────────────┐
│ │
edit hook typecheck_scope (MCP)
(record + pre-warm) (scoped diagnostics + types)
afterFileEdit / PostToolUse
│ │
└──────────┐ ┌──────────┘
▼ ▼
LSPSteer daemon ── warm pool, one server per project
│ (Unix-socket IPC)
▼
LSP client ──► typescript-language-server ──► tsserver
didOpen/didChange · publishDiagnostics · hover · documentSymbol
```
The steering note is built by (1) syncing the file to the warm server and
collecting the diagnostics it publishes, (2) keeping only those overlapping the
changed lines (plus a little context), and (3) resolving the in-scope types -
the enclosing function's signature plus the resolved types of the identifiers in
the changed region (via `hover`/`documentSymbol`). See
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
## Does it work?
`npm test` runs the hermetic component tests (LSP client, daemon IPC, MCP
server). `npm run e2e` drives a live headless Claude Code agent and shows the
steering loop end to end: with the agent denied `Read`, `typecheck_scope` is its
only source of type info, and it goes from a `TS2339` first guess to type-clean.
That demo isolates the mechanism but overstates the payoff. A controlled,
paired A/B (`eval/`, 150 runs, scored by `tsc --noEmit` + held-out behavioral
tests) gives the honest picture:
- A compiler-in-the-loop signal improves first-attempt correctness over no tool
(0% to ~32-44%, McNemar p=0.013).
- On small readable repos, LSPSteer shows no measurable advantage over just
running `tsc` (first-diff 32% vs 36%, p=1.0), and final correctness is at
ceiling because a capable agent self-corrects by reading.
- The tool adds ~1.4 turns and ~$0.015 per task.
So today the defensible claim is convenience and warmth, not a correctness win
over the compiler. The advantage LSPSteer is designed for (diff-scoping when
`tsc` dumps hundreds of errors, resolved types when reading is expensive) should
appear on large repos, which the current suite does not cover. Full method and
numbers: [`eval/README.md`](eval/README.md), [`eval/FINDINGS.md`](eval/FINDINGS.md).
Test ladder: [`docs/TESTING.md`](docs/TESTING.md).
## Background and inspiration
LSPSteer operationalizes, as a Cursor capability, a line of recent work on
feeding program-analysis signals back into LLM code generation:
- **Type-Constrained Code Generation with Language Models** - arXiv 2504.09246
(PLDI 2025). Constraining decoding to the type system more than halves
compilation errors and improves repair of non-compiling code. Background for
why type signals are the right control signal.
- The observation that **type-check failures dominate** LLM compilation errors
(with syntax errors a small minority for TypeScript) - Mündler et al., PLDI
2025 - motivates targeting types rather than grammar.
- **Lanser-CLI** (arXiv 2510.22907) and **LSPRAG** (arXiv 2510.22210) - using
the language server as a process-reward / retrieval source for agents. These
are the closest prior work. LSPSteer differs on what it ships and where: it is
not a CLI the agent shells out to or a retrieval index, but an always-on warm
daemon wired into Cursor and Claude Code through their native MCP + hooks
surfaces, returning a single diff-scoped steering note at edit time.
- Adjacent: **SAILOR** (arXiv 2604.06506, 2026), static analysis guiding LLM
symbolic execution, and **Agentic Code Reasoning** (arXiv 2603.01896, 2026) -
related uses of analysis signals in LLM reasoning, not editor-time type
steering.
LSPSteer's contribution is not a new model or decoding algorithm; it is the
*editor-time plumbing* - a warm, diff-scoped, type-resolving steering loop wired
into the MCP + hooks surfaces of Cursor and Claude Code - that the above research
implies but that no editor tool currently provides off the shelf.
## Status
Working end-to-end and verified: LSP client, diff-scoped analysis + type
resolution, warm daemon with IPC, MCP server, hooks (Cursor **and** Claude Code),
the `init` installer, and the CLI all run today - `npm test` exercises the LSP
client, the daemon IPC path, and the MCP server against the bundled fixture.
TypeScript is the proven language; Python/Rust adapters are stubs. The
Stop-hook output contract is isolated in one harness-aware function
(`emitDecision`) because the shape differs between Claude Code and Cursor (and
has varied across Cursor versions).
## License
MIT.
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues