Revisiting LSP in Claude Code
Back in March I wrote about enabling LSP in Claude Code. It turned into one of my most-read posts this year, which is a bit awkward, because a fair chunk of it is now out of date. I told people to set an undocumented flag, I framed LSP as go-to-definition, and I said Iād check back in a week. I didnāt.
So this is the check back, seven months late. I read the current docs, went through my own session history, and ran some tests locally to see what Claude Code actually does with a language server.
TlDr: the flag is gone, the first answer LSP gives in every session is wrong, thatās a known bug, and for TypeScript thereās a workaround thatās three lines of config. After all that, grep was better for finding where something is used.
Whatās changed since March
The setup in the old post was a feature flag plus a plugin in the ~/.claude/settings.json file:
1{
2 "env": {
3 "ENABLE_LSP_TOOL": "1"
4 },
5 "enabledPlugins": {
6 "typescript-lsp@claude-plugins-official": true
7 }
8}The flag isnāt needed any more. I removed ENABLE_LSP_TOOL from my ~/.claude/settings.json, restarted, and the LSP tool still loads
and answers on Claude Code 2.1.296. LSP is now a documented feature:
install the language server binary, install the code intelligence plugin for the language, done.
The other thing I got wrong was what LSP is for. The docs describe two separate things a language server gives Claude Code:
- Diagnostics after edits. Every time Claude edits or writes a file the server handles, type errors and warnings get pushed into context automatically. No tool call involved.
- Code navigation. An
LSPtool Claude can call for definitions, references, hover types, symbols and call hierarchy.
My March post was entirely about the second one. The first one didnāt get a mention.
Does Claude Code actually use it?
I keep my session history in local archives, with ccrecall and now omnirecall, so I went back through it to see how much Claude Code had actually used LSP.
From when I switched it on at the end of February to April thereās
about 650 sessions, and it made 32 LSP calls in 15 of them. 12 of
those came back with nothing, mostly No LSP server available for file type: .svelte, and then it went to
grep. It never called findReferences, not once.
After that I wasnāt really using Claude Code until a couple of weeks ago. Since then thereās 118 sessions, half of them subagents, and no LSP calls until I started writing this.
Youāll know when it has used LSP because youāll see something like this in Claudeās output:
1ā LSP(operation: "findReferences", symbol: "get_from_cache", in: "src/lib/cache/server-cache.ts")
2 ā Found 51 references across 20 filesSo, I got Claude Code to test itself. I asked it to run headless
sessions against this siteās repo with claude -p and save the output
from each one, so the tool calls could be counted:
1claude -p "$prompt" --output-format stream-json --verboseThese are the four prompts it used, three runs of each. None of them mention LSP or grep:
1<!-- Prompt 1 -->
2
3In `src/lib/posts.test.ts`, `get_posts` is imported. Where is that
4function actually defined? Give me the file and line.
5
6<!-- Prompt 2 -->
7
8I want to change the signature of `get_from_cache`. List every call
9site (`file:line`) I would need to update. Do not change anything.
10
11<!-- Prompt 3 -->
12
13What exactly does `get_post_tags` return? Give me the fully resolved
14return type and where it is defined.
15
16<!-- Prompt 4 -->
17
18Change get_period_boundaries in
19`src/lib/analytics/period-stats.helpers.ts` to take a single options
20`object ({ period, now })` instead of positional arguments, and update
21every caller. Make sure it still type-checks.Then the same again with the LSP rule taken out of my CLAUDE.md.
| Test | Used LSP, with the rule | Used LSP, without the rule |
|---|---|---|
| Definition | 3 of 3 | 2 of 3 |
| Call sites | 3 of 3 | 1 of 3 |
| Type | 3 of 3 | 3 of 3 |
| Refactor | 3 of 3 | 0 of 3 |
With the rule, Claude Code reached for LSP every time. Without it,
half the time, and never for the refactor. I ran the with-the-rule set
both headless with claude -p and in real interactive sessions and
got twelve out of twelve both ways.
So the rule from the March post does work when the prompt is about a symbol. Going by my history, in real sessions it mostly went to grep anyway.
Whether it should be using LSP for that is another thing, thatās further down in Just use grep?
The first answer is wrong
This is the bit I didnāt expect. The second prompt is the one asking
for every call site of get_from_cache. Each time it ran, Claude Code
made one findReferences call and got this back:
1Found 1 reference:
2 src/lib/cache/server-cache.ts:42:17One reference, which is the definition itself. That function is used
in 20 files. Each run then built its answer from git grep instead.
The answers were right, but LSP contributed nothing to them.
So, I wanted to know how wrong, and for how long. I got Claude Code to run some fresh headless sessions, each one making the same set of calls one after another across three functions in three different files, and to time each call.
This is the prompt for one of them:
1This is a mechanical probe of the LSP tool. If the LSP tool is not
2loaded, load it first with ToolSearch query `"select:LSP"`. Then do
3these steps in order, ONE tool call per message, never in parallel,
4never repeating a call unless the step says so, and using no other
5tools:
6
71. Call the LSP tool: `findReferences` on
8 `src/lib/server/bluesky-posts.ts` line 140 character 14. If the
9 result is "Found 1 reference", make the identical call again, up to
10 6 calls in total, stopping as soon as it reports more than 1
11 reference.
122. Exactly once: `findReferences` on `src/lib/cache/server-cache.ts`
13 line 42 character 17.
143. Exactly once: `findReferences` on
15 `src/lib/utils/group-posts-by-tag.ts` line 14 character 14.
164. Exactly once: goToDefinition on
17 `src/lib/components/newsletter-signup/newsletter.remote.ts` line 60
18 character 30.
195. Exactly once: hover on `src/lib/seo/default.ts` line 20
20 character 20.
21
22When finished reply with the single word DONE.And this is what came back, the time is how long since the session started:
| Time | Call | Result |
|---|---|---|
| 4.7s | findReferences, function B | Found 1 reference |
| 6.6s | findReferences, function B | Found 1 reference |
| 7.8s | findReferences, function B | Found 5 references across 3 files |
| 9.1s | findReferences, function A | Found 51 references across 20 files |
| 10.6s | findReferences, function C | Found 10 references across 3 files |
| 11.9s | goToDefinition, a fourth symbol | The right file and line |
| 13.2s | hover, a fifth symbol | The full signature |
Same call three times, wrong, wrong, right. Then everything after that
was right, different functions, different files, goToDefinition and hover as well.
I got it to run a load of these, changing the order each time. The first call of a fresh session was wrong in 18 of the first 19 sessions, and in all 13 of the ones I timed after that. Every wrong answer came back inside about two seconds of the first LSP call in the session.
A few other things that came out of it:
- Itās the session, not the function. One wrong call on one function, wait a couple of seconds, and the first call on a completely different function in a different file is fine.
- Waiting before asking doesnāt help. One session sat there for 30 seconds before making its first call and it was still wrong. The language server doesnāt get started until something asks it a question.
- Asking about something else straight away doesnāt help either. In that same session the second call was on a different function, 1.7 seconds later, and that was wrong too.
goToDefinitiondoes it as well. As the first call it sent me to the import line at the top of the file I was already in.- One retry isnāt always enough. With the retries about 1.3 seconds apart it took a third go in five sessions out of seven.
I updated typescript-language-server from 5.3.0 to 6.0.2 to see if
that helped, it didnāt.
Funnily enough, āfirst LSP call can missā was one of the gotchas in my March post. I called it a minor quirk. I donāt think itās minor now, Claude Code made one call, got the wrong answer and went to grep.
Whyās it doing that?
Turns out itās a known bug, thereās an open issue for it on the Claude Code repo. Other people are getting the same āone reference, then all of themā with other language servers, Python ones as well, so itās not only TypeScript.
Claude Code asks the language server the question before the server has finished loading the project.
With TypeScript thereās a bit more to it. typescript-language-server starts up two tsserver processes, the full one and a lightweight one
it calls the syntax server. The docs say the syntax server is there to āprovide IntelliSense while projects
are loadingā.
So, while the project is loading, anything asking for references or definitions gets its answer from the lightweight one, and that one only knows about the file it was asked about. Thatās why I get one reference back, itās the only one in that file.
In VS Code that makes sense, I get something straight away and the rest fills in. Claude Code asks once and takes the answer.
The workaround
typescript-language-server has an option to turn the syntax server
off, itās called useSyntaxServer. I got Claude Code to go looking
and it found a discussion on the language server repo from April. Someone had this exact problem
with Claude Code and the maintainer suggested trying it.
Claude Code starts a language server from a bit of config in a plugin,
which server to run and which file extensions itās for. That config
can also pass options to the server when it starts up, thatās what initializationOptions is for.
So hereās the config from the official typescript-lsp plugin, with
the option added to it on the highlighted lines:
1{
2 "typescript": {
3 "command": "typescript-language-server",
4 "args": ["--stdio"],
5 "extensionToLanguage": {
6 ".ts": "typescript",
7 ".tsx": "typescriptreact",
8 ".js": "javascript",
9 ".jsx": "javascriptreact",
10 ".mts": "typescript",
11 ".cts": "typescript",
12 ".mjs": "javascript",
13 ".cjs": "javascript"
14 },
15 "initializationOptions": {
16 "tsserver": { "useSyntaxServer": "never" }
17 }
18 }
19}With that in place the first call of a session came back right in all twelve of the sessions Claude Code ran with it.
Thereās two ways I know of to get that config in.
Option 1, edit the official plugin config
The official plugin doesnāt have a config file of its own, the config is in the marketplace file, here:
1~/.claude/plugins/marketplaces/claude-plugins-official/.claude-plugin/marketplace.jsonFind typescript under lspServers in there and add the initializationOptions block to it. Thatās what the person in the
discussion did and it worked for them.
Iāve not done it this way, because you could change the file, the plugin could be updated and probably overwrite that file again.
Option 2, use my plugin
I didnāt want to be editing a file that might get overwritten, so I made a plugin with the config above in it. Itās the official one with the option added.
Iāve added it to my svelte-skills-kit marketplace as typescript-lsp. Itās not Svelte specific, thatās just
where I keep my plugins, it works with any TypeScript or JavaScript
project.
The plugin is only config, so the language server needs to be installed. If the official plugin is already working then itās there already, if not:
1pnpm i -g typescript-language-server typescript
2# or npm or whatever your package manager of choice isThen in Claude Code, add the marketplace:
1/plugin marketplace add spences10/svelte-skills-kitI already had it added, so I updated it to get the new plugin showing up:
1/plugin marketplace update svelte-skills-kitInstall the plugin:
1/plugin install typescript-lsp@svelte-skills-kitDisable the official one. Both of them are set up for the same file extensions so I only want one of them on:
1/plugin disable typescript-lsp@claude-plugins-officialIf youāre using Svelte thereās a plugin for that in the same marketplace, see the Svelte section.
Then reload the plugins:
1/reload-pluginsI restarted Claude Code after that as well.
To check itās working I start a new session and, as the very first thing, ask for the references to something I know is used in a few files. If more than one reference comes back itās working.
Does it work?
Hereās the default config against the one with the syntax server turned off:
| Default | Syntax server off | |
|---|---|---|
| First call of a session | Wrong, back in 0.5s | Right, back in about 2s |
| Same call three times | 1, 1, 51 references | 51, 51, 51 references |
goToDefinition as first call | The import line | The definition |
| Memory once loaded | About 935 MB, 4 processes | About 725 MB, 3 processes |
The first call is slower because itās now waiting for the project to load before it answers. This is a small project, on a big one Iād expect that wait to be a lot longer than two seconds.
Type errors after an edit still show up with the option on, I got Claude Code to check that as well.
The maintainer did say it āmight not solve this issue by itselfā. For this project it did.
ā Note: this is a workaround until itās fixed in Claude Code, the issue is the place to keep an eye on.
Once thatās sorted, switching back to the official plugin is:
1/plugin uninstall typescript-lsp@svelte-skills-kit
2/plugin enable typescript-lsp@claude-plugins-officialChanges from the shell get missed
That same issue has a second problem in it, so I got Claude Code to test that as well.
In a session that was already warmed up it changed files three different ways and asked for the references after each one:
| How the file changed | What findReferences did |
|---|---|
| The Edit tool | Included the new line on the next call |
| A new file written from the shell | Missed it, then found it seconds later |
| A shell append to a file LSP had already asked about | Never saw it, across three calls |
So edits Claude Code makes with its own tools get to the language server. A change made from the shell to a file the server already had open didnāt.
It only tested appending a line. The issue has the same thing
happening with git checkout, formatters and anything else that gets
run through Bash.
The workaround doesnāt do anything for this one.
Just use grep?
So with the first answer fixed, is LSP actually any better than grep for this? When the first answer was wrong Claude Code went to grep anyway, and the answers were right.
I got Claude Code to run the same questions three different ways, three runs of each, 54 sessions in all:
- Grep only, with the LSP tool turned off.
- LSP and grep, which is my normal setup with the rule in
CLAUDE.md. - LSP only, with Bash, Grep and Glob turned off.
Thereās three functions called get_post_tags in this project, so I
used that for most of them, I thought a text search would struggle
with it. These are the prompts:
1<!-- References 1, the only function with that name -->
2
3I want to change the signature of `get_from_cache`. List every call
4site (`file:line`) I would need to update. Do not change anything.
5
6<!-- References 2, one of three functions with the same name -->
7
8I want to change the signature of `get_post_tags` in
9`src/lib/data/post-tags.remote.ts`. List every call site (`file:line`)
10I would need to update. Do not change anything.
11
12<!-- References 3, a function nothing uses -->
13
14Is `get_post_tags` in `src/lib/state/post-tags.svelte.ts` used
15anywhere, or can I delete it? Do not change anything.
16
17<!-- Types 1, all three functions -->
18
19What exactly does `get_post_tags` return? Give me the fully resolved
20return type and where it is defined.
21
22<!-- Types 2, no return type written down -->
23
24What exactly does `get_post_tags` in `src/lib/post-tags.ts` return? It
25has no return type annotation. Give me the fully resolved return type.
26
27<!-- Types 3, a type that comes from SvelteKit -->
28
29What is the exact TypeScript type of `get_post_tags` exported from
30`src/lib/data/post-tags.remote.ts`, and what type do I get when I
31await a call to it?Hereās the three references questions:
| Grep only | LSP and grep | LSP only | |
|---|---|---|---|
| Right call sites | 9 of 9 | 9 of 9 | 9 of 9 |
| Found the test mock that needs updating | 3 of 3 | 3 of 3 | 0 of 3 |
| Straight answer on ācan I delete itā | 3 of 3 | 3 of 3 | 0 of 3 |
| Turns per question | 2 to 3 | 4 to 6 | 7 to 12 |
| Seconds per question | 9 to 15 | 14 to 19 | 16 to 29 |
| Cost per question | $0.04 to $0.08 | $0.06 to $0.09 | $0.07 to $0.12 |
And the three types questions:
| Grep only | LSP and grep | LSP only | |
|---|---|---|---|
| Right answer | 9 of 9 | 9 of 9 | 9 of 9 |
| Turns per question | 3 to 6 | 6 to 15 | 6 to 16 |
| Seconds per question | 12 to 22 | 11 to 29 | 11 to 29 |
| Cost per question | $0.05 to $0.10 | $0.06 to $0.14 | $0.06 to $0.16 |
Grep on its own didnāt get a single answer wrong, and for references it was the quickest and the cheapest of the three.
Some things that came out of it:
- With both available Claude Code ran grep every time anyway. My rule tells it to check the LSP count against grep, so the LSP call was extra work on top.
- The three functions with the same name werenāt a problem for grep. It searched for the name and the file itās imported from, and split them up correctly every time.
findReferencesgives back a file, a line and a column, not the code. With only LSP to go on Claude Code had to read three to six files to see what each reference was. Grep gives back the line.- Thereās a
vi.mockfor that function in a test that would break if the signature changed. Grep found it, LSP canāt, itās not a reference as far as TypeScript is concerned. - With only LSP, all three ācan I delete itā answers came back with a
ābut I canāt check the
.sveltefilesā. - Types is where LSP did alright.
hovergives the exact type in one call, and on two of the three questions it was as quick as grep. - Grep still got the types right though. For the SvelteKit one it read
the type declarations in
node_modules, and in one run it wrote a script to ask the TypeScript compiler.
ā Note: this is a small project with function names that donāt clash with much, one model (Opus 5.5) and three runs of each. On a big codebase with thousands of matches for a name it could be a different story. I didnāt test a refactor either.
Diagnostics
This is the half of LSP I skipped in March, and the one that needs nothing from me to work. I had Claude Code append a line with an obvious type error to a TypeScript file and report back whatever feedback it was shown:
1export const lsp_probe: number = 'text';In a longer session the errors arrived like this, with no tool call asking for them:
1<new-diagnostics>The following new diagnostic issues were detected:
2
3period-stats.helpers.ts:
4 ā [Line 293:14] Type 'string' is not assignable to type 'number'. [2322] (typescript)
5 ā [Line 294:14] Type 'number' is not assignable to type 'string'. [2322] (typescript)</new-diagnostics>They arenāt instant though. In two short sessions where the edit was the last thing that happened, the type error went unreported. In the longer one both errors showed up about 30 seconds later. A fresh sessionās language server needs time to load the project, and Claude Code doesnāt wait for it.
Svelte
The official plugin list has no Svelte entry, which is the gap I complained about in March. Without one, any LSP call on a component comes back with:
1No LSP server available for file type: .svelteSame deal as the TypeScript plugin, itās only config. This is all of
it, in a file called .lsp.json in the plugin:
1{
2 "svelte": {
3 "command": "svelteserver",
4 "args": ["--stdio"],
5 "extensionToLanguage": {
6 ".svelte": "svelte"
7 }
8 }
9}Iāve added it to my svelte-skills-kit marketplace as svelte-lsp. The plugin doesnāt include the language
server, so that goes on first:
1pnpm i -g svelte-language-server
2# or npm or whatever your package manager of choice isThen in Claude Code, with the marketplace added:
1/plugin install svelte-lsp@svelte-skills-kit
2/reload-pluginsWith that loaded, hover, go-to-definition, references and document
symbols all work on .svelte files, and a type error I added inside a
componentās <script> block came back as a diagnostic. Without the
plugin the same edit got no feedback at all.
It also showed up another gap in the TypeScript server on a SvelteKit
project. number_crunch is a utility on this site thatās mostly
called from components. I asked for its references from both sides:
| Asked from | References found |
|---|---|
The .ts file where itās defined | 23 across 3 files, none of them .svelte |
A .svelte file that uses it | 89 across 18 files, 15 of them .svelte |
grep | 90 lines across 18 files |
The TypeScript server canāt see into components, so from a .ts file
it misses every .svelte caller, however many times the call is
repeated. The Svelte server gets the full list, and got it on the
first call.
What Iāve changed
The rule from the March post told Claude Code to use LSP over grep for
definitions and references. After the tests above Iāve turned that
round. This is whatās in my CLAUDE.md now:
1## Code Navigation
2
3- To find where a symbol is defined or used, use grep. When more than
4 one symbol has the same name, search for the name and the module it
5 is imported from, and read the import lines to tell them apart.
6- Use LSP hover when the exact type of a symbol matters, such as an
7 inferred return type or a type that comes from a library.
8- Do not treat an LSP findReferences result as the full list. It
9 misses .svelte callers when run from a .ts file, mocks and other
10 string references, and files changed from the shell. Confirm with
11 grep before deleting or changing a signature.
12- LSP diagnostics arrive on a later turn after an edit, not
13 immediately. No diagnostics straight after an edit does not mean the
14 edit is clean; run the type check before reporting done.Then I got Claude Code to run the same six questions again with the new rule in, three runs of each:
| Old rule | New rule | |
|---|---|---|
| Right answers | 18 of 18 | 18 of 18 |
| Used LSP on the references questions | 9 of 9 | 0 of 9 |
Used hover on the types questions | 9 of 9 | 9 of 9 |
| Turns per references question | 4 to 6 | 2 to 3 |
| Seconds per references question | 14 to 19 | 10 to 20 |
So it does what it says. Grep for where something is used, hover for
types, and the references questions take about half the turns.
Iāve also swapped the official typescript-lsp plugin for mine, and
added svelte-lsp alongside it.
Is it worth it?
Yes, but not for the reason I gave in March.
- Diagnostics are the reason to have it. Type errors come back after an edit without anything having to ask for them, and thatās the one thing here grep canāt do.
- For finding where something is used, grep was better. It was right every time, quicker and cheaper, and it found things LSP canāt see.
- For types it was a draw.
hovergives the exact type in one call, grep got to the same answer. - The first answer of a session is wrong. Itās a known bug. For TypeScript, turning the syntax server off fixed it in every session Claude Code ran.
- It doesnāt see everything. Files changed from the shell can be stale, and on a SvelteKit project the TypeScript server never sees the components.
- The flag is gone.
ENABLE_LSP_TOOLcan come out ofsettings.json. - Svelte takes one small config file.
So the advice in the March post, tell Claude Code to use LSP in place of grep, Iād take back. On this project it made things slower without making them more right.
Going by my history Claude Code had worked that out before I did, it was using grep the whole time.
There's a reactions leaderboard you can check out too.
Sign up for the newsletter
Want to keep up to date with what I'm working on?
Join other developers and sign up for the newsletter.
I care about the protection of your data. Read the Privacy Policy for more info.